Startup Order Is Load-Bearing
The last commit of ModularMonolith moves one line of Startup from first to last - and the whole cross-module event system depends on it. Assembly scanning, throwaway service providers and migrations inside ConfigureServices.
Some of the most consequential lines in a codebase look like formatting choices. In the DevMentors ModularMonolith repo, the final commit — the one that delivers the entire cross-module messaging system — also contains this two-line diff to Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
- services.AddInfrastructure();
services.AddConferencesModule();
services.AddSpeakersModule();
services.AddTicketsModule();
+ services.AddInfrastructure();
}
git show 60840b2 (2021-08-23) records it. Shared infrastructure moved from first to last, and nothing in the file says why. Part 3 read the recipe these four lines implement; this part reads the physics underneath them — because that reorder is not tidiness, it is a correctness fix, and understanding it exposes three startup-time idioms worth judging carefully.
Why last means correct
Two pieces of the shared layer discover things by scanning loaded assemblies. Handler discovery, in Events/Extensions.cs, uses Scrutor:
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
services.Scan(s => s.FromAssemblies(assemblies)
.AddClasses(c => c.AssignableTo(typeof(IEventHandler<>)))
.AsImplementedInterfaces()
.WithScopedLifetime());
And the module registry, in Modules/Extensions.cs, walks the same list for every class implementing IEvent. Both take a snapshot of AppDomain.CurrentDomain.GetAssemblies() — whatever the CLR has loaded at that moment.
.NET loads assemblies lazily. Conferences.Core is not in the AppDomain because the Bootstrapper transitively references it; it arrives when the first code touching it executes — which is AddConferencesModule() calling AddCore(). So the ordering rule falls out: the module registration calls are not just registrations, they are the force-loads that make the module assemblies visible to the scan that follows. Run AddInfrastructure() first, as the code did between April and August 2021, and the scans can miss module assemblies entirely — no ConferenceCreated in the registry, no ConferenceCreatedHandler in the container, no error anywhere. Events would simply not exist.
The fix works, and it costs four reordered lines. What it does not cost — and this is the finding — is any expression of the constraint. No comment, no assertion, no analyzer. The correctness of the entire event system rests on the vertical order of four method calls, documented only in a diff. Today you would make the dependency structural: pass the module assemblies explicitly (AddInfrastructure(typeof(ConferencesModule).Assembly, ...)), or have each module hand its assembly to a builder. An implicit temporal coupling became a latent bug once; nothing prevents it becoming one again.
The throwaway container factory
The second idiom hides in a helper the whole estate leans on. Options are needed during registration — AddMessaging must decide whether to register the background dispatcher before the container exists:
public static T GetOptions<T>(this IServiceCollection services, string sectionName) where T : new()
{
using var serviceProvider = services.BuildServiceProvider();
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var section = configuration.GetSection(sectionName);
var options = new T();
section.Bind(options);
return options;
}
BuildServiceProvider() inside ConfigureServices is the documented ASP.NET Core anti-pattern — warning ASP0000 — because each call constructs a complete container from a partially-populated collection: every singleton registered so far gets its own duplicate instance in the throwaway provider, and configuration is read before the host finishes composing it. Count the invocations in this estate: AddMessaging once, the non-generic AddPostgres once, and AddPostgres<T> once per module DbContext — four full container builds before the real one, plus two more we will meet in a moment. In an eighty-four-file sample the cost is milliseconds; the pattern, copied into an estate with expensive singletons, is not.
To be fair to the design: the alternative in 2021 was clumsy. IOptions<T> cannot answer questions at registration time, and the honest workaround — build IConfiguration directly from appsettings.json rather than resolving it from a fake container — is only marginally cleaner. The estate chose ergonomics, visibly and consistently. The judgement to carry away is narrower: registration-time configuration reads are a real need, and every solution to it should look slightly embarrassed.
Migrating the database before the app exists
The third idiom escalates the second. AddPostgres<T>, the shared helper every module's AddCore calls, does not stop at registration:
public static IServiceCollection AddPostgres<T>(this IServiceCollection services) where T : DbContext
{
var options = services.GetOptions<PostgresOptions>("postgres");
services.AddDbContext<T>(x => x.UseNpgsql(options.ConnectionString));
using var scope = services.BuildServiceProvider().CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<T>();
dbContext.Database.Migrate();
return services;
}
Read it slowly: bind options (one throwaway provider), register the context, then build another complete provider, resolve the context, and run Database.Migrate() — synchronously, inside ConfigureServices, once per module. Two consequences follow, in ascending severity:
- Startup cost multiplies silently. Each module pays a full container build plus a migration round-trip before
Configureever runs; with Conferences and Speakers both calling it, the app builds six containers where one would do. - The app cannot even fail gracefully. If Postgres is down, the exception flies out of
ConfigureServices— before health checks, before logging pipelines, before anything that could report readiness. The process just dies. Migration-on-start is a defensible sample-ware choice; migration-in-ConfigureServices puts it in the one place the host cannot supervise it.
The era matters here. This is net5.0 code from 2021, when the idiomatic home for such work — an IHostedService that migrates before the app accepts traffic, or Program.Main between Build() and Run() — required more ceremony than a sample wants. Today you would also have IHostApplicationBuilder and health-check-gated readiness, and no excuse.
What order-sensitivity tells you about a composition root
Put the three idioms together and a pattern emerges: this composition root does work — loads assemblies, reads config, builds containers, migrates databases — where most composition roots only make promises. Work has ordering; promises do not. The moment ConfigureServices acquired side effects, its line order became semantics, and the head commit's reorder is simply the first invoice.
The distilled rule: if swapping two lines in your Startup can break production without failing a build or a test, those lines are not configuration — they are a program, and programs need their invariants written down. An architecture test asserting the registry contains ConferenceCreated would have pinned this forever; the estate, having no tests at all, pinned it in a commit message that just says “head”.
The reward for all this startup machinery is real, though: what it boots is a complete in-process event bus in seven small files — next part reads it end to end.