fullstackhero’s composition root is src/Host/FSH.Starter.Api/Program.cs, and it’s short. Condensed:
// src/Host/FSH.Starter.Api/Program.cs (condensed)var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediator(o =>{ o.ServiceLifetime = ServiceLifetime.Scoped; o.Assemblies = [ typeof(GenerateTokenCommand), typeof(GenerateTokenCommandHandler), // Identity — Contracts + runtime typeof(GetTenantStatusQuery), typeof(GetTenantStatusQueryHandler), // Multitenancy — Contracts + runtime /* …two marker types per module, for all ten modules… */ ];});
var moduleAssemblies = new Assembly[]{ typeof(IdentityModule).Assembly, typeof(MultitenancyModule).Assembly, /* …all ten module runtime assemblies… */};
builder.AddHeroPlatform(o => { o.EnableCaching = true; o.EnableJobs = true; /* … */ });builder.AddModules(moduleAssemblies);
var app = builder.Build();app.UseHeroMultiTenantDatabases(); // Finbuckle UseMultiTenant() — before the rest of the pipelineapp.UseHeroPlatform(p => { p.MapModules = true; /* … */ });await app.RunAsync();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 specific wiring failure that catches every team at least once.
The four calls
-
builder.Services.AddMediator(o => ...)— registers Mediator 3’s source-generated dispatch.o.Assembliesis a list of marker types: the generator scans the assembly each type lives in forICommand<T>/IQuery<T>/ handler implementations. Every module contributes two markers — one type from its.Contractsassembly and one from its runtime assembly. Handlers are registered withServiceLifetime.Scoped. -
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 registers every FluentValidation validator in the supplied assemblies, discovers the assembly-level[FshModule]attributes, sorts modules by order, and invokes each one’sConfigureServices(IHostApplicationBuilder). -
app.UseHeroPlatform(p => ...)— wires the middleware pipeline in the right order, invoking each module’sConfigureMiddleware(right after authentication) andMapEndpoints(after authorization) in module order.
The IModule contract
Every module implements IModule and declares itself with an assembly-level [FshModule] attribute (positional: module type, then order):
public interface IModule{ void ConfigureServices(IHostApplicationBuilder builder); void MapEndpoints(IEndpointRouteBuilder endpoints); void ConfigureMiddleware(IApplicationBuilder app) { } // optional — default no-op}[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)]// Modules.Identity/IdentityModule.cs (condensed)public class IdentityModule : IModule{ public void ConfigureServices(IHostApplicationBuilder builder) { PermissionConstants.Register(IdentityPermissions.All); builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddHeroDbContext<IdentityDbContext>(); // ... }
public void MapEndpoints(IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("api/v{version:apiVersion}/identity") .WithTags("Identity") .WithApiVersionSet(apiVersionSet); group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth"); group.MapRegisterUserEndpoint(); // ... the rest of the module's endpoints }}The ModuleLoader runs ConfigureServices for every module in order, then later runs ConfigureMiddleware and 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 scans assemblies that appear in o.Assemblies. A module’s commands live in its .Contracts assembly and its handlers live in its runtime assembly — so each module needs both markers in the list. Leave one out and nothing breaks at build time; the handler is just missing from the dispatch table, and mediator.Send(...) fails at runtime. The registration sites and the two-marker rule are documented in .agents/rules/architecture.md and the mediator-reference skill.
FluentValidation auto-registration
ModuleLoader.AddModules calls services.AddValidatorsFromAssemblies(moduleAssemblies), so every AbstractValidator<TCommand> in any module is registered automatically. The Mediator pipeline behavior ValidationBehavior<TRequest, TResponse> (registered by AddHeroPlatform) 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, ITenantInitialPasswordBuffer, metrics/meter types |
| Scoped | Per-request DbContext, per-request user, Mediator handlers | DbContext, ICurrentUser, ITokenService, IQuotaService, command/query handlers (o.ServiceLifetime = ServiceLifetime.Scoped) |
| Transient | Stateless services that aren’t expensive to construct | IUserService and the focused user sub-services, IConnectionStringValidator |
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 in their own scope, not the publisher’s. Handlers publish to the outbox (same transaction as the business write);
OutboxDispatcherHostedServicepicks the row up asynchronously and the event bus creates a fresh DI scope per event before resolving handlers. Don’t expect to share a DbContext or transaction with the publisher — idempotency comes from the Inbox ({EventId, HandlerName}dedupe), not shared state.
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 Identity module’s RequiredPermissionAuthorizationHandler reads the permission from the endpoint metadata and checks it against the caller’s claims. The kit’s RequiredPermissionAttribute and .RequirePermission() fluent helper both produce that same metadata.
The four wire points when you add a new module
Adding a new module touches four lists across two host files. Miss any one of them and it fails silently:
| # | Place | File | Symptom if missed |
|---|---|---|---|
| 1 | Mediator o.Assemblies — two markers (a Contracts type and the module type) | src/Host/FSH.Starter.Api/Program.cs | Handlers silently undiscovered |
| 2 | moduleAssemblies array | src/Host/FSH.Starter.Api/Program.cs | Module never loaded |
| 3 | Mediator assemblies (same pair) | src/Host/FSH.Starter.DbMigrator/Program.cs | Migrate/seed misses the module |
| 4 | Module assemblies array | src/Host/FSH.Starter.DbMigrator/Program.cs | Migrate/seed misses the module |
The DbMigrator pair is the one everyone forgets: it mirrors the API’s registration because some module DbInitializers depend on services the Mediator pipeline builds. Forgetting #1 is the nastiest 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. (You’ll also add the new *.Contracts and runtime projects to FSH.Starter.slnx, but the compiler catches that one for you.)
The middleware pipeline order
app.UseHeroMultiTenantDatabases() runs first, in Program.cs before UseHeroPlatform — it’s Finbuckle’s UseMultiTenant(), so tenant resolution happens before everything below (including authentication). Then 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 root override etc.12. UseRateLimiter13. Quota enforcement (if quotas enabled)14. UseAuthorization15. Per-module MapEndpoints16. Health, SSE, SignalR17. CurrentUserMiddleware ← last, so authorization already doneThree ordering rules are unusual and important:
- Tenant resolution before authentication. Finbuckle’s strategy chain sees an anonymous
User, which is why claim-aware tenant logic lives in post-auth module middleware — see the multitenancy deep dive. - CORS before HTTPS redirect. Preflight
OPTIONSrequests can’t follow redirects per the Fetch spec. - Per-module middleware after authentication.
UseModuleMiddlewaresruns each module’sConfigureMiddlewareright afterUseAuthentication, so module middleware (like Multitenancy’s root-operator override) can read claims.
CurrentUserMiddleware runs last: it populates ICurrentUser from claims for the endpoint to consume. Anything reading ICurrentUser earlier in the chain sees nothing — middleware that needs the user reads claims directly.
Related
- Web building block — the composition + middleware code.
- Vertical Slice — what a handler looks like.
- Modular monolith — the module ordering rules.