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.
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()insideConfigureServicesis 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:60to 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 optionsin its constructor instead ofIOptions<MongoOptions>, andMicrosoft.Extensions.Optionsnever entersAbstractions. 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.