Add, Use, Repeat — The Module Recipe
Every module in ModularMonolith plugs in through the same Add/Use extension pair - a recipe small enough to memorise, complete enough to be a mini-framework, and visible even where a module registers nothing at all.
Frameworks are usually things you download. Sometimes they are three conventions applied without exception until a fourth developer can add a module without asking anyone. Part 2 read the walls between ModularMonolith's modules; this part reads the sockets — the Add/Use recipe that makes three very different modules compose identically, and what the recipe's degenerate form teaches when a module has nothing to say.
The whole composition root
Here is Startup.ConfigureServices and Configure in the Bootstrapper, in full:
public void ConfigureServices(IServiceCollection services)
{
services.AddConferencesModule();
services.AddSpeakersModule();
services.AddTicketsModule();
services.AddInfrastructure();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseInfrastructure();
app.UseRouting();
app.UseConferencesModule();
app.UseSpeakersModule();
app.UseTicketsModule();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapGet("/", context => context.Response.WriteAsync("Modular Monolith API"));
});
}
One line per module per phase. The Bootstrapper knows module names and nothing else — no repositories, no DbContexts, no handlers. Everything behind each line follows the same recipe, and the recipe has exactly three layers.
Layer one: the Api facade
Each module's .Api project carries a static class pairing an Add with a Use:
[assembly:InternalsVisibleTo("ModularMonolith.Bootstrapper")]
namespace ModularMonolith.Modules.Speakers.Api
{
internal static class SpeakersModule
{
public static IServiceCollection AddSpeakersModule(this IServiceCollection services)
{
services.AddCore();
return services;
}
public static IApplicationBuilder UseSpeakersModule(this IApplicationBuilder app)
{
return app;
}
}
}
The Add side delegates to the module's .Core; the Use side returns the app untouched in all three modules. Empty methods are easy to sneer at, but they are load-bearing convention: the pipeline slot for module-specific middleware exists on day one, so adding it later is a change inside one module rather than a renegotiation of Startup. It mirrors how ASP.NET Core itself pairs AddX with UseX — the estate speaks the framework's own dialect, which is why the recipe needs no documentation to be guessable.
Layer two: the Core registration
The .Core project owns what the module actually is. Speakers is the recipe's cleanest expression:
[assembly: InternalsVisibleTo("ModularMonolith.Modules.Speakers.Api")]
namespace ModularMonolith.Modules.Speakers.Core
{
internal static class Extensions
{
public static IServiceCollection AddCore(this IServiceCollection services)
=> services
.AddScoped<ISpeakersService, SpeakersService>()
.AddScoped<ISpeakersRepository, SpeakersRepository>()
.AddPostgres<SpeakersDbContext>();
}
}
Service, repository, database — three lines, and the AddPostgres<T> call comes from the shared mini-framework, binding the connection string, registering the context with the module's own schema, and running migrations. Conferences follows the same shape with more history in it (its AddCore still registers leftover in-memory repositories alongside the EF ones — the archaeology of part 10), and, as part 2 noted, forgets the internal keyword.
Layer three: the shared surface
The fourth Startup line, AddInfrastructure(), is what makes the per-module lines so short. Its body in Shared.Infrastructure/Extensions.cs is the estate's table of contents:
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddControllers()
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Add(new InternalControllerFeatureProvider());
});
services.AddSingleton<ErrorHandlerMiddleware>();
services.AddPostgres();
services.AddEvents();
services.AddMessaging();
services.AddModuleRequests();
return services;
}
Read it as a service catalogue: controller discovery that honours internal (part 2), one error envelope for every module (part 11), Postgres option binding, event handler scanning, the in-memory broker (part 5), and the module registry that routes events across borders (part 6). Modules contribute domain code; the platform contributes everything cross-cutting. One wrinkle worth flagging now: that non-generic AddPostgres() binds and registers a PostgresOptions singleton that nothing in the estate ever consumes — the generic per-module AddPostgres<T> re-binds its own copy each time. Dead registration in the platform layer, four lines from the top of the composition root.
The degenerate form proves the recipe
Tickets is the module that tests whether the recipe holds at zero content. Its entire .Core registration:
[assembly: InternalsVisibleTo("ModularMonolith.Modules.Tickets.Api")]
namespace ModularMonolith.Modules.Tickets.Core
{
internal static class Extensions
{
public static IServiceCollection AddCore(this IServiceCollection services)
=> services;
}
}
No controllers, no entity, no DbContext — the module exists solely to consume one event, and its handler is discovered by assembly scanning rather than explicit registration, so AddCore genuinely has nothing to register. The file still exists, the InternalsVisibleTo still points at its Api, the facade still occupies its Startup lines. And one detail gives the intent away: the file carries using ModularMonolith.Shared.Infrastructure.Postgres; — an import for the AddPostgres<TDbContext> call that was never written. The module is a socket with the wiring run to it and no fixture installed. A recipe followed even when it does nothing is what makes deviations detectable at all — you can only notice a module is a stub because the other two make the full shape obvious.
The cost of a fourth module
The recipe's real test is a thought experiment the structure invites: add a Venues module. The bill, priced from what the three existing modules did —
Venues.Api: aBaseControllerwithBasePath = "venues-module",internalcontrollers, aVenuesModulefacade,[assembly:InternalsVisibleTo("ModularMonolith.Bootstrapper")].Venues.Core: entities, a service, a repository, aVenuesDbContextwithHasDefaultSchema("venues"), anAddCore(keptinternal, with the grant to its Api), andAddPostgres<VenuesDbContext>.- Two lines in
Startupplus oneProjectReferencein the Bootstrapper. - Optionally, copied records under
Events/External/for any event Venues wants to hear.
Nothing else. No routing table to edit, no shared registry to update, no central configuration beyond the connection string every module already shares. That is the mini-framework claim made good: the marginal cost of a module is the module itself.
Two honest caveats keep the claim from inflating. The recipe is enforced by imitation, not by tooling — nothing fails if your AddCore is public (Conferences) or your schema name drifts from your route prefix; a module template or an architecture test would harden it. And step 3 hides a trap: those two Startup lines must appear above AddInfrastructure(), because the shared layer discovers module assemblies by scanning whatever is already loaded. Get the order wrong and events silently vanish — which is not a footnote but the whole next part: startup order is load-bearing.