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 two real lines:

src/Host/FSH.Starter.Api/Program.cs
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:

  1. builder.AddMediator(o => { o.Assemblies = moduleAssemblies; }) — registers Mediator 3’s source-generated dispatch. The Assemblies array tells the generator which marker types to scan for ICommand<T> / IQuery<T> / handler implementations. Every module’s marker class (e.g. IdentityModule) goes here.

  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 scans the supplied marker assemblies for IModule implementations, sorts them by [FshModule(Order)], and invokes each one’s ConfigureServices(IHostApplicationBuilder).

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

LifetimeUse forExample
SingletonStateless / app-wide / thread-safeIEventSerializer, JsonMaskingService, OpenTelemetry meter providers
ScopedPer-request DbContext, per-request user, per-request servicesDbContext, ICurrentUser, IUserService, IQuotaService (when Valkey-backed)
TransientStateless helpers that aren’t expensive to constructSpecifications, command/query handlers

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 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:

  1. The module’s IModule implementation with [FshModule(Order = n)].
  2. Program.cs moduleAssemblies array — add typeof(MyModule).Assembly.
  3. Program.cs AddMediator(o => o.Assemblies = [...]) — add the same assembly so source-gen sees the new handlers.
  4. The module’s *.Contracts and runtime projects added to FSH.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 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 override etc.
12. UseRateLimiter
13. UseAuthorization
14. Per-module MapEndpoints
15. Health, SSE, SignalR
16. CurrentUserMiddleware ← last, so authorization already done

Two ordering rules are unusual and important:

  • CORS before HTTPS redirect. Preflight OPTIONS requests can’t follow redirects per the Fetch spec.
  • CurrentUserMiddleware last. It populates ICurrentUser from claims; placing it after authorization means anything reading ICurrentUser upstream sees null. The kit’s middleware that needs ICurrentUser runs from inside the endpoint or as a Mediator pipeline behavior, not in the pre-auth chain.