Skip to content
fullstackhero

Concept

Dependency injection & module loading

How AddHeroPlatform, the module loader, the Mediator source generator, and FluentValidation auto-registration compose into a working host.

views 0 Last updated

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 pipeline
app.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

  1. builder.Services.AddMediator(o => ...) — registers Mediator 3’s source-generated dispatch. o.Assemblies is a list of marker types: the generator scans the assembly each type lives in for ICommand<T> / IQuery<T> / handler implementations. Every module contributes two markers — one type from its .Contracts assembly and one from its runtime assembly. Handlers are registered with ServiceLifetime.Scoped.

  2. 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.

  3. builder.AddModules(moduleAssemblies) — runs the ModuleLoader. It registers every FluentValidation validator in the supplied assemblies, discovers the assembly-level [FshModule] attributes, sorts modules by order, and invokes each one’s ConfigureServices(IHostApplicationBuilder).

  4. app.UseHeroPlatform(p => ...) — wires the middleware pipeline in the right order, invoking each module’s ConfigureMiddleware (right after authentication) and MapEndpoints (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):

BuildingBlocks/Web/Modules/IModule.cs
public interface IModule
{
void ConfigureServices(IHostApplicationBuilder builder);
void MapEndpoints(IEndpointRouteBuilder endpoints);
void ConfigureMiddleware(IApplicationBuilder app) { } // optional — default no-op
}
Modules.Identity/AssemblyInfo.cs
[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:

LifetimeUse forExample
SingletonStateless / app-wide / thread-safeIEventSerializer, ITenantInitialPasswordBuffer, metrics/meter types
ScopedPer-request DbContext, per-request user, Mediator handlersDbContext, ICurrentUser, ITokenService, IQuotaService, command/query handlers (o.ServiceLifetime = ServiceLifetime.Scoped)
TransientStateless services that aren’t expensive to constructIUserService and the focused user sub-services, IConnectionStringValidator

Two things to watch out for:

  • Hangfire jobs get a new scope per execution. FshJobActivator creates an IServiceScope per 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); OutboxDispatcherHostedService picks 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:

#PlaceFileSymptom if missed
1Mediator o.Assemblies — two markers (a Contracts type and the module type)src/Host/FSH.Starter.Api/Program.csHandlers silently undiscovered
2moduleAssemblies arraysrc/Host/FSH.Starter.Api/Program.csModule never loaded
3Mediator assemblies (same pair)src/Host/FSH.Starter.DbMigrator/Program.csMigrate/seed misses the module
4Module assemblies arraysrc/Host/FSH.Starter.DbMigrator/Program.csMigrate/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 ProblemDetails
2. UseResponseCompression
3. UseCors ← before HTTPS redirect (preflight)
4. UseHttpsRedirection
5. Security headers
6. UseStaticFiles (optional)
7. Hangfire dashboard (if jobs enabled)
8. UseRouting
9. OpenAPI + Scalar
10. UseAuthentication
11. Per-module ConfigureMiddleware ← multi-tenant root override etc.
12. UseRateLimiter
13. Quota enforcement (if quotas enabled)
14. UseAuthorization
15. Per-module MapEndpoints
16. Health, SSE, SignalR
17. CurrentUserMiddleware ← last, so authorization already done

Three 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 OPTIONS requests can’t follow redirects per the Fetch spec.
  • Per-module middleware after authentication. UseModuleMiddlewares runs each module’s ConfigureMiddleware right after UseAuthentication, 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.