AddX, UseX, and Two Deliberate Violations
Trill's hand-rolled framework follows the ASP.NET Core registration convention with unusual discipline, then breaks it twice on purpose - and the exceptions turn out to be more instructive than the rule.
Every .NET developer has internalised a convention nobody wrote down: things that go in the container are services.AddX(), things that go in the pipeline are app.UseX(), and things that configure the host are builder.UseX(). It is so consistent across Microsoft's own packages that a method named AddRedis tells you its signature before you look.
Part 4 covered what the Trill monolith's shared kernel replaces. This part is about how it speaks — because a hand-rolled framework has to pick a vocabulary, and this one picked the house style, followed it across twelve subsystems, and then broke it in two places where following it would have hurt.
The convention, inventoried
Twelve subsystem registrations hang off IServiceCollection in Trill.Shared.Infrastructure: AddAuth, AddCommands, AddDomainEvents, AddEvents, AddInfrastructure, AddMessaging, AddModuleInfo, AddModuleRequests, AddMongo, AddQueries, AddRedis, AddVault. Three more extend IHostBuilder — ConfigureModules, UseLogging, UseVault — and all three are chained in the entry point (Bootstrapper/Program.cs:17-22):
public static IHostBuilder CreateHostBuilder(string[] args)
=> Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>())
.ConfigureModules()
.UseLogging()
.UseVault();
Each module then repeats the pattern one level down with AddCore() and, where it needs middleware, UseCore(). Five of the six modules have an AddCore; Saga's ConfigureServices is the single line services.AddChronicle().
The discipline pays off in the composition root, which is 43 lines and readable end to end. Startup.ConfigureServices calls AddInfrastructure once and then loops the modules; AddInfrastructure chains eleven AddX calls in a single fluent expression (Extensions.cs:77-88). Anyone who has read one ASP.NET Core application can read this one, which is the entire point of adopting somebody else's vocabulary rather than inventing your own.
Violation one: Use* that returns a registry
Two methods wear the Use prefix and do not behave like pipeline stages at all (Modules/Extensions.cs:22-26):
public static IModuleSubscriber UseModuleRequests(this IApplicationBuilder app)
=> app.ApplicationServices.GetRequiredService<IModuleSubscriber>();
public static IContractRegistry UseContracts(this IApplicationBuilder app)
=> app.ApplicationServices.GetRequiredService<IContractRegistry>();
- Neither adds middleware. They resolve a singleton out of the application services and hand it back.
- Neither returns
IApplicationBuilder, so neither can be chained into a pipeline. They are terminal in theUse*sense and initial in a different sense. - They exist to give modules a fluent registration handle inside
ConfigureMiddleware, which is the only hookIModulegives a module where the application services are available.
Read at the call site the theft is convincing (StoriesModule.cs:38-45):
public void ConfigureMiddleware(IApplicationBuilder app)
{
app.UseContracts()
.Register<UserCreated>()
.RegisterPath<GetUser, UserDto>("users-module/get-user");
app.UseInfrastructure();
app.UseModuleRequests()
.Subscribe<SendStory, SendStory.Response>($"{Path}/send-story", async cmd => { /* ... */ });
}
It reads well. It also means that app.UseContracts() and app.UseInfrastructure() — sitting three lines apart, with identical shapes — do completely different kinds of thing. One mutates the request pipeline; the other is a disguised service locator. The convention is being used as a lookup table for “where in startup am I”, not for “what kind of thing is this”, and once you notice that you cannot unnotice it.
The honest defence is that IModule gives a module exactly three hooks — ConfigureServices, ConfigureMiddleware, ConfigureEndpoints — and cross-module contract registration genuinely belongs in the middle one, after the container is built and before routing. Given that interface, this is the least-bad shape. Given a fourth hook — ConfigureIntegration(IModuleSubscriber, IContractRegistry) — it would not be necessary at all, and the framework would gain a place to put exactly the concern that is currently smuggled.
Violation two: the pipeline step that declines the prefix
The mirror image sits in the same file family. Extensions.cs:157-163:
public static IApplicationBuilder ValidateContracts(this IApplicationBuilder app)
{
var contractRegistry = app.ApplicationServices.GetRequiredService<IContractRegistry>();
contractRegistry.Validate();
return app;
}
This one does take an IApplicationBuilder and does return it. It is called from Startup.Configure in sequence with the real pipeline stages (Startup.cs:49-52):
app.ValidateContracts();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
By the letter of the convention this is the one that has earned the Use prefix, and it is the one that doesn't have it. The two methods that break the shape borrowed the name; the one that keeps the shape refused it. Which is, I think, the right call in both directions — UseContracts reads better than GetContractRegistry at a module's call site, and ValidateContracts says what it does in a way UseContracts never could. But it means the naming carries no information at all, and a reader who trusts the convention will be wrong twice.
The distilled rule this repository teaches by counterexample: a naming convention is only load-bearing if the return type honours it. Once UseX can mean three different things, you are reading the body anyway.
There is one more ordering hazard worth naming while we are inside the composition root, because it is the same class of problem as the naming. AddMessaging() runs at Extensions.cs:82, inside the fluent chain; the TryDecorate block that installs the unit-of-work and logging decorators runs at :138, after it. InboxCommandHandlerDecorator computes its module name in its constructor from the handler it wraps, and that is correct only because the inbox decoration was applied first and therefore sits innermost. Swap those two blocks and every inbox collection silently becomes -module.inbox. Nothing documents the dependency and nothing tests it; the ordering is load-bearing and invisible.
The convention that does carry information
There is a third piece of vocabulary here, and this one is genuinely enforced. The string {module}-module is the single most overloaded token in the codebase, and it does four jobs at once:
| Role | Where |
|---|---|
| HTTP path prefix | IModule.Path → endpoints.MapGet($"{Path}/stories") |
| Mongo collection prefix | private const string Schema = "stories-module" |
| Module-client route prefix | SendAsync("users-module/charge-funds") |
| Inbox/outbox collection prefix | $"{module}-module.{_collectionName}" |
One string, four meanings, and — as part 3 counted — eleven independent declarations of it. The convention is real and consistently applied; what is missing is a single derivation. ModuleLoader already extracts the module name from an assembly filename, ModuleInfoProvider already holds the list, and GetModuleName() already derives one from a type's namespace. Three mechanisms compute the same fact and none of them feeds the query handlers.
The module lifecycle, which is the framework's best idea
Worth stating because it is the thing Convey has no answer to. A module in this application can be switched off at assembly-load time, with no recompilation, through one boolean.
ModuleLoader.LoadAssemblies (Bootstrapper/ModuleLoader.cs:13-46) enumerates the .dll files in the base directory, extracts the module name out of the filename, checks {module}:module:enabled in configuration, and simply does not load the disabled ones:
var moduleName = file.Split(modulePart)[1].Split(".")[0].ToLowerInvariant();
var enabled = configuration.GetValue<bool>($"{moduleName}:module:enabled");
if (!enabled)
{
disabledModules.Add(file);
}
- The configuration comes from the module itself.
ConfigureModulesglobsmodule.*.jsonrecursively from the content root, and each module ships an eight-linemodule.ads.jsoncopied to output on every build. A module carries its own on/off switch. - MVC controllers are stripped separately.
AddInfrastructurere-derives the disabled set and removes matchingApplicationParts (Extensions.cs:120-133), so a disabled module's controllers vanish from routing too. - Duplicate paths fail startup.
ValidateModulesgroups byIModule.Pathand throws on any collision (ModuleLoader.cs:64-76).
Three layers — assembly, services, routes — driven by one flag. In the distributed build “disabled” means “do not deploy the container,” which is simpler but coarser: you cannot ship one artefact that a customer configures down to four modules.
The wrinkle is worth flagging. The controller-stripping pass matches with x.Name.Contains(disabledModule, StringComparison.InvariantCultureIgnoreCase), and Contains on an assembly name is loose. A module named Ad would strip Trill.Modules.Ads.Api along with itself. Nothing in this repository triggers it, and a StartsWith($"Trill.Modules.{disabledModule}.") would close it permanently.
What the two columns teach
The distributed build never needs any of this vocabulary, because Convey supplies it and every service composes itself identically in a Program.cs of about forty lines. The monolith had to invent a language, and inventing a language means discovering that the borrowed one has edges.
Both violations here are the same discovery: Add/Use encodes when something happens, and this framework needed to encode what kind of thing it is. When your composition model has more than two phases, a two-word vocabulary runs out — and the sensible response is a third verb, not a fourth meaning for Use.
Next, a sentinel that carries even more meaning than a prefix does: Guid.Empty is a mode flag.