fullstackhero’s composition root is two real lines:
var builder = WebApplication.CreateBuilder(args);
builder.AddMediator(o => { o.Assemblies = moduleAssemblies; });builder.AddHeroPlatform(o => { o.EnableCaching = true; o.EnableJobs = true; /* ... */ });builder.AddModules(moduleAssemblies);
var app = builder.Build();app.UseHeroMultiTenantDatabases();app.UseHeroPlatform(p => { p.MapModules = true; });app.Run();That’s the whole composition root. Everything underneath is the platform’s job. This page is about what those calls actually do, the rules they impose, and the two specific wiring failures that catch every team at least once.
The four-line composition
Each of those four lines does one thing:
-
builder.AddMediator(o => { o.Assemblies = moduleAssemblies; })— registers Mediator 3’s source-generated dispatch. TheAssembliesarray tells the generator which marker types to scan forICommand<T>/IQuery<T>/ handler implementations. Every module’s marker class (e.g.IdentityModule) goes here. -
builder.AddHeroPlatform(o => ...)— registers the cross-cutting platform: logging, OpenTelemetry, OpenAPI, CORS, security headers, API versioning, exception handling, the validation pipeline behavior, and any optional sub-blocks (caching, jobs, mailing, feature flags, idempotency, SSE, realtime, quotas) you opted into. -
builder.AddModules(moduleAssemblies)— runs theModuleLoader. It scans the supplied marker assemblies forIModuleimplementations, sorts them by[FshModule(Order)], and invokes each one’sConfigureServices(IHostApplicationBuilder). -
app.UseHeroPlatform(p => ...)— wires the middleware pipeline in the right order, then invokes each module’sMapEndpointsandConfigureMiddlewarein registration order.
The IModule contract
Every module implements IModule:
public interface IModule{ void ConfigureServices(IHostApplicationBuilder builder); void MapEndpoints(IEndpointRouteBuilder endpoints); void ConfigureMiddleware(WebApplication app); // optional override}
[FshModule(Order = 100)]public sealed class IdentityModule : IModule{ public void ConfigureServices(IHostApplicationBuilder builder) { builder.Services.AddHeroDbContext<IdentityDbContext>(); builder.Services.AddScoped<ITokenService, TokenService>(); PermissionConstants.Register(IdentityPermissions.All); }
public void MapEndpoints(IEndpointRouteBuilder endpoints) { var v1 = endpoints.MapGroup("api/v{version:apiVersion}/identity") .WithTags("Identity") .HasApiVersion(1); v1.MapGenerateTokenEndpoint(); v1.MapRefreshTokenEndpoint(); // ... 46 more }}The ModuleLoader runs ConfigureServices for every module in order, then later runs MapEndpoints for every module in the same order. This two-phase wiring means a module can resolve dependencies registered by an earlier-order module during its own service registration.
Mediator 3 — source-generated dispatch
Mediator 3 (the martinothamar/Mediator library, not to be confused with MediatR) is source-generated. At compile time, its generator walks MediatorOptions.Assemblies, finds every ICommandHandler and IQueryHandler, and emits a static dispatch table. Zero runtime reflection.
The catch: the generator only inspects assemblies that also reference Mediator types. If you add a new module marker (say ChatModule) but the runtime assembly has no command or handler yet, Mediator’s generator fails with MSG0007: "Assembly does not consume Mediator types".
The kit’s workaround during scaffolding: add the marker to AddMediator(o => o.Assemblies = [...]) only after the module has its first command or handler. The kit’s Program.cs has a comment by the AddMediator block reminding next-session you of this.
FluentValidation auto-registration
Every module loads FluentValidation.DependencyInjectionExtensions. The kit’s AddHeroPlatform calls services.AddValidatorsFromAssemblies(moduleAssemblies) so every AbstractValidator<TCommand> in any module is registered automatically. The Mediator pipeline behavior ValidationBehavior<TRequest, TResponse> runs them before every command handler.
What this means: drop a MyCommandValidator : AbstractValidator<MyCommand> next to the handler, and validation runs on every mediator.Send(new MyCommand(...)). No registration line in your module’s ConfigureServices.
Three lifetimes, applied consistently
The kit uses standard IServiceCollection lifetimes:
| Lifetime | Use for | Example |
|---|---|---|
| Singleton | Stateless / app-wide / thread-safe | IEventSerializer, JsonMaskingService, OpenTelemetry meter providers |
| Scoped | Per-request DbContext, per-request user, per-request services | DbContext, ICurrentUser, IUserService, IQuotaService (when Valkey-backed) |
| Transient | Stateless helpers that aren’t expensive to construct | Specifications, command/query handlers |
Two things to watch out for:
- Hangfire jobs get a new scope per execution.
FshJobActivatorcreates anIServiceScopeper job. So a scoped service injected into a job sees a fresh instance for each run. Don’t try to share state via scoped services across job invocations; use Valkey or the database. - Integration event handlers run synchronously in the publisher’s scope. When a Chat handler publishes a
MentionedInChannelIntegrationEvent, the Notifications handler runs in the same scope. The Notifications handler can resolve the same DbContext the Chat handler holds — which is intentional, because it lets the inbox row write commit in the same transaction as the message.
Permission registration
PermissionConstants is a static registry. Each module calls it during ConfigureServices:
PermissionConstants.Register(IdentityPermissions.All);PermissionConstants.Register(CatalogPermissions.All);PermissionConstants.Register(TicketsPermissions.All);// ...The registration is global, deduped by Name, and additive. There’s no removal API — once registered, a permission is part of the runtime for the process lifetime.
The Web block’s RequiredPermissionAuthorizationHandler resolves the policy name from the endpoint metadata and looks up the registered permission to check claims. The kit’s RequiredPermissionAttribute and .RequirePermission() fluent helper both target the same metadata.
The four wire points when you add a new module
Adding a new module touches four files. Miss any one of them and the module loads but doesn’t actually do anything useful:
- The module’s
IModuleimplementation with[FshModule(Order = n)]. Program.csmoduleAssembliesarray — addtypeof(MyModule).Assembly.Program.csAddMediator(o => o.Assemblies = [...])— add the same assembly so source-gen sees the new handlers.- The module’s
*.Contractsand runtime projects added toFSH.Starter.slnx— without this they don’t get built.
Forgetting #3 is the silent failure mode: the host boots, the module’s services register, the endpoints map, but mediator.Send(new MyCommand()) fails at runtime because the dispatch table doesn’t include the handler. There’s a comment in Program.cs next to the AddMediator block to flag it; read it before you scaffold a new module.
The middleware pipeline order
UseHeroPlatform wires the pipeline in a deliberate order:
1. UseExceptionHandler → RFC 9457 ProblemDetails2. UseResponseCompression3. UseCors ← before HTTPS redirect (preflight)4. UseHttpsRedirection5. Security headers6. UseStaticFiles (optional)7. Hangfire dashboard (if jobs enabled)8. UseRouting9. OpenAPI + Scalar10. UseAuthentication11. Per-module ConfigureMiddleware ← multi-tenant override etc.12. UseRateLimiter13. UseAuthorization14. Per-module MapEndpoints15. Health, SSE, SignalR16. CurrentUserMiddleware ← last, so authorization already doneTwo ordering rules are unusual and important:
- CORS before HTTPS redirect. Preflight
OPTIONSrequests can’t follow redirects per the Fetch spec. - CurrentUserMiddleware last. It populates
ICurrentUserfrom claims; placing it after authorization means anything readingICurrentUserupstream sees null. The kit’s middleware that needsICurrentUserruns from inside the endpoint or as a Mediator pipeline behavior, not in the pre-auth chain.
Related
- Web building block — the composition + middleware code.
- Vertical Slice — what a handler looks like.
- Modular monolith — the module ordering rules.