Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/a-framework-instead-of-twenty-four-packages && read --section="top" 0%
Architecture

A Framework Instead of Twenty-Four Packages

Trill's saga microservice references twenty-four third-party packages; the same saga as a module references one - because the monolith deleted Convey and rewrote 4,600 lines of it by hand.

By Kumar Chandrachooda 31 Dec 2025 6 min read
A tall stack of small blocks beside one solid block of the same height

Here is the single line of XML that captures the whole argument. In Trill.Saga/src/Trill.Saga/Trill.Saga.csproj the ItemGroup is twenty-six lines long — twenty-three Convey packages, Chronicle_, and two Microsoft.Extensions references. In Trill.Modules.Saga.csproj the equivalent block is this:

<PackageReference Include="Chronicle_" Version="3.2.1" />
<ProjectReference Include="..\..\..\..\Shared\Trill.Shared.Infrastructure.csproj" />

Twenty-four third-party packages become one. Part 3 showed what consolidation did to the data layer; this is what it did to the dependency graph, and the bill for it is 138 files nobody would otherwise have written.

What Convey was doing there

Convey is DevMentors' own microservices chassis — the framework this corpus already read in depth, and Trill is one of its consumers. Across the nine microservice repositories, twenty-six distinct Convey packages appear: the CQRS trio, RabbitMQ and its outbox, Mongo and Redis persistence, Consul discovery, Fabio load balancing, Jaeger tracing, Prometheus metrics, JWT auth, Vault secrets, Serilog logging, the WebApi surface and its Swagger and security add-ons.

Trill.Saga — a service whose entire job is to run one process manager over four message types, ten .cs files in total — carries twenty-three of them. That is not an indictment of the service; it is the shape of a chassis. You take the whole thing because the pieces are cheap and the alternative is picking. The Convey series makes the case for that trade properly, and it is a good case.

The monolith's answer is to stop making it. Trill.Shared.Abstractions and Trill.Shared.Infrastructure between them are 138 C# files and 4,632 lines, which is roughly 30% of the repository's files and 35% of its lines. Nearly a third of this codebase is a framework it wrote so it would not have to take somebody else's.

The replacement ledger

Read package by package, the mapping is unusually clean:

Convey package Monolith equivalent Verdict
Convey.CQRS.Commands/Events/Queries Shared.Abstractions.{Commands,Events,Queries} + three dispatchers Near-identical API
Convey.MessageBrokers.RabbitMQ InMemoryMessageBroker + ModuleClient Replaced wholesale
Convey.MessageBrokers.Outbox.Mongo MongoOutbox, OutboxProcessor, and MongoInbox Extended
Convey.Auth AuthManager, AuthOptions, DisabledAuthenticationPolicyEvaluator Reimplemented
Convey.Secrets.Vault Vault/*, six files on VaultSharp Ported near-verbatim
Convey.Logging Logging/*, Serilog plus three decorators Ported
Convey.WebApi.CQRS Api/Extensions.Get/Post/Put/Delete Ported, including the auth parameters
Convey.Persistence.MongoDB Mongo/* including IMongoRepository<,> Ported
Convey.Discovery.Consul Dropped
Convey.LoadBalancing.Fabio Dropped
Convey.Tracing.Jaeger Dropped
Convey.Metrics.Prometheus Dropped
Convey.HTTP Dropped

Five entire concern-families vanish. Not “become simpler” — cease to exist as code, as configuration and as containers. Service discovery, load balancing, distributed tracing, metrics and service-to-service HTTP are questions the monolith is never asked, because there is one process and it knows where all its parts are.

That row of dashes is the honest measure of what a monolith buys you, and it is bigger than the row above it. Everything in the “ported” half is work that got moved rather than eliminated; only the dropped half is work that stopped existing.

What “ported” actually cost

The ported half is where the interesting engineering lives, because a hand-rolled framework has to make every decision a packaged one made for you, and the decisions are visible.

The public/internal split is deliberate and defensible. Trill.Shared.Abstractions is 43 files and 589 lines, entirely public, and contains almost no behaviour: marker interfaces (ICommand, IEvent, IQuery, each literally annotated //Marker), dispatcher contracts, Paged<T>, AggregateRoot<T>, IClock, IRng, IIdGenerator, and Contract<T>. Trill.Shared.Infrastructure is 95 files and 4,043 lines, almost entirely internal. A module can depend on the abstractions alone and compile; only the bootstrapper can compose the app. That is a framework-author's instinct rather than an app-author's.

The options pattern is hand-rolled and eager, and this is the decision I would push back on. There is no IOptions<T> anywhere in the repository. Instead, Extensions.cs:174-180:

public static TModel GetOptions<TModel>(this IServiceCollection services, string settingsSectionName)
    where TModel : new()
{
    using var serviceProvider = services.BuildServiceProvider();
    var configuration = serviceProvider.GetService<IConfiguration>();
    return configuration.GetOptions<TModel>(settingsSectionName);
}
  • BuildServiceProvider() inside ConfigureServices is the canonical ASP.NET Core anti-pattern, the one the ASP0000 analyser exists to flag. Each call builds a throwaway container, and any singleton resolved during it is a different instance from the one the real container will hand out later.
  • It is called roughly ten times during startup — once per options type, plus once directly at Extensions.cs:60 to compute the disabled-module list.
  • Options are frozen at startup. No IOptionsMonitor, no reload-on-change. For this application that is fine, and it should be a conscious choice rather than an accident.
  • But it buys something real. Because options are materialised POCOs registered as singletons, a decorator can take MongoOptions options in its constructor instead of IOptions<MongoOptions>, and Microsoft.Extensions.Options never enters Abstractions. Every constructor in the framework reads better for it.

That is a genuine trade rather than a mistake: readability and a smaller abstraction surface, paid for with a startup-time anti-pattern and no live reconfiguration. Worth naming as a trade, and worth noting that binding the IConfiguration once in Startup's constructor would have bought the same readability for nothing.

The leak is in the Mongo layer. Mongo/Extensions.cs:14 declares a public extension class whose AddMongo is internal, and MongoOptions is internal yet appears as a constructor parameter of decorators that modules' handlers sit inside. The net effect is that modules consume IMongoDatabase directly — AdRepository takes the raw driver type and builds its own collection handle. The abstraction that was meant to keep persistence out of module code lets the driver straight through, which is precisely how eleven copies of a collection prefix came to exist.

What the monolith built that Convey never had

Two things, and both are worth the price of admission.

MongoInbox is the first. Convey shipped an outbox; it did not ship an inbox as a first-class handler decorator. The monolith has both, wired as open-generic Scrutor decorations over ICommandHandler<> and IEventHandler<>, giving idempotent receive for free on every handler in the application when you flip one flag. It is off in every shipped configuration, which is its own finding, but the code is complete.

Contract<T> and ContractRegistry is the second, and it has no equivalent in Convey, in the microservices build, or in any other sample repository I have read. It is a build-time structural checker for message shapes that modules declare independently, and it exists because the distributed build shipped exactly the drift it catches. That is part 7, and it is the best idea in either column.

The number that settles nothing

Both builds are the same size — 435 files against 453 — so the framework is not overhead in any simple sense. It is a reallocation. The distributed build spent 24 package references and got 4,600 lines of somebody else's tested code, at the cost of nine repositories floating on 0.5.* version ranges, which means two repos can build against different Convey builds on different days. The monolith spent 138 files of its own and got one restore graph with every version pinned, at the cost of owning every bug in it.

There is no version-skew risk in a solution with one .sln and pinned versions, and there is no free maintenance in 4,600 lines you wrote yourself. Which of those you would rather carry is a team question, not an architecture question, and the repository is admirably neutral about it — it just did the work and left both columns visible.

Next, how that framework talks about itself: AddX, UseX, and two deliberate violations.