A module is a bounded context: a runtime project (internal — handlers, endpoints, domain, data) plus a .Contracts project (its only public API — commands, queries, DTOs, events, permissions). Other modules may reference the Contracts project, never the runtime project. That boundary is enforced by architecture tests, so wiring it wrong fails loudly.
What does not fail loudly is registration. A module must be wired in four places across two Program.cs files, and missing any one of them produces a host that boots green and a module that silently doesn’t work. That’s the part this guide exists for — everything else is mechanical.
Before you start: if you’re adding a feature to an existing module, this is the wrong page — see Add a feature. And if you’re working with an AI coding agent, the repo ships a scaffolding recipe at .agents/skills/add-module/ that encodes everything below; pointing your agent at it is the fastest route.
This guide builds a fictional Inventory module and uses the real Notifications module (src/Modules/Notifications/) as the reference implementation throughout — it’s the smallest complete module in the kit.
1. Create the two projects
src/Modules/Inventory/├── Modules.Inventory/ ← runtime (internal)│ ├── AssemblyInfo.cs ← [assembly: FshModule(...)] lives here│ ├── InventoryModule.cs ← IModule implementation│ ├── Domain/│ ├── Data/ ← DbContext, configurations, initializer│ └── Features/v1/ ← vertical slices└── Modules.Inventory.Contracts/ ← public API ├── InventoryContractsMarker.cs ├── Authorization/ ← permissions └── v1/ ← Commands/, Queries/, DTOs/Don’t hand-write the .csproj files — copy them from an existing module and rename. The Notifications pair shows the shape:
<!-- Modules.Inventory/Modules.Inventory.csproj --><PropertyGroup> <RootNamespace>FSH.Modules.Inventory</RootNamespace> <AssemblyName>FSH.Modules.Inventory</AssemblyName></PropertyGroup><ItemGroup> <PackageReference Include="Mediator.Abstractions" /></ItemGroup><ItemGroup> <ProjectReference Include="..\..\..\BuildingBlocks\Persistence\Persistence.csproj" /> <ProjectReference Include="..\..\..\BuildingBlocks\Web\Web.csproj" /> <ProjectReference Include="..\Modules.Inventory.Contracts\Modules.Inventory.Contracts.csproj" /> <!-- other modules ONLY via their .Contracts projects --></ItemGroup><!-- Modules.Inventory.Contracts/Modules.Inventory.Contracts.csproj --><PropertyGroup> <RootNamespace>FSH.Modules.Inventory.Contracts</RootNamespace> <AssemblyName>FSH.Modules.Inventory.Contracts</AssemblyName></PropertyGroup><ItemGroup> <PackageReference Include="Mediator.Abstractions" /></ItemGroup><ItemGroup> <ProjectReference Include="..\..\..\BuildingBlocks\Shared\Shared.csproj" /></ItemGroup>The Contracts project stays deliberately thin: Mediator.Abstractions plus shared contracts (Shared, and Eventing.Abstractions if it publishes integration events). No EF Core, no FluentValidation, no module internals — ContractsPurityTests checks all of that.
Add a marker class to the Contracts project. It exists purely so Program.cs can point the Mediator source generator at the assembly:
namespace FSH.Modules.Inventory.Contracts;
/// <summary>/// Marker referenced by Program.cs::AddMediator(o => o.Assemblies = [...]) so the/// Mediator source generator scans this assembly for ICommand/IQuery records./// </summary>public abstract class InventoryContractsMarker;2. Add to the solution and reference from the hosts
dotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory/Modules.Inventory.csprojdotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory.Contracts/Modules.Inventory.Contracts.csprojThen make the runtime project reachable from both hosts. Reference it from src/Host/FSH.Starter.Migrations.PostgreSQL/FSH.Starter.Migrations.PostgreSQL.csproj — both FSH.Starter.Api and FSH.Starter.DbMigrator reference the Migrations project, so your module flows to them transitively. Most existing modules are also referenced directly from both host .csproj files; the direct reference costs nothing and makes the dependency explicit, so follow suit.
3. Implement IModule and declare it with [FshModule]
[FshModule] is an assembly-level attribute, not a class attribute, and its order is a positional argument. Notifications puts it in AssemblyInfo.cs:
using FSH.Framework.Web.Modules;
[assembly: FshModule(typeof(FSH.Modules.Notifications.NotificationsModule), 750)]ModuleLoader.AddModules (in BuildingBlocks/Web/Modules/ModuleLoader.cs) discovers these attributes from the moduleAssemblies array, orders by Order (lower loads first), instantiates each module, and calls ConfigureServices. The orders currently in use:
| Module | Order | Module | Order | |
|---|---|---|---|---|
| Identity | 100 | Billing | 500 | |
| Multitenancy | 200 | Catalog | 600 | |
| Auditing | 300 | Tickets | 700 | |
| Files | 350 | Notifications | 750 | |
| Webhooks | 400 | Chat | 800 |
Pick something after the modules yours depends on — 900 is a safe default for a new leaf module. Order matters more than it looks: Notifications sits at 750 deliberately so its integration-event handlers register before Chat (800) starts publishing.
The module class itself, condensed from NotificationsModule.cs:
namespace FSH.Modules.Inventory;
public sealed class InventoryModule : IModule{ public void ConfigureServices(IHostApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder);
PermissionConstants.Register(InventoryPermissions.All); builder.Services.AddHeroDbContext<InventoryDbContext>(); builder.Services.AddScoped<IDbInitializer, InventoryDbInitializer>();
builder.Services.AddHealthChecks() .AddDbContextCheck<InventoryDbContext>(name: "db:inventory"); }
public void MapEndpoints(IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints);
var versionSet = endpoints.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build();
var group = endpoints.MapGroup("api/v{version:apiVersion}/inventory") .WithTags("Inventory") .WithApiVersionSet(versionSet) .RequireAuthorization();
// group.MapCreateItemEndpoint(); … }}IModule also has a ConfigureMiddleware(IApplicationBuilder app) member with a no-op default implementation — only override it if your module needs request middleware (it runs after UseAuthentication, so claims are available). Validators need no per-module registration: ModuleLoader.AddModules calls AddValidatorsFromAssemblies over the whole moduleAssemblies array.
If the module publishes or consumes integration events, also add AddEventingForDbContext<InventoryDbContext>() and AddIntegrationEventHandlers(typeof(InventoryModule).Assembly) — see the eventing rules in .agents/rules/eventing.md.
4. Permissions
Permissions live in the Contracts project under Authorization/, follow the Permissions.{Resource}.{Action} shape, and expose an All list that ConfigureServices registers. From NotificationPermissions.cs:
namespace FSH.Modules.Inventory.Contracts.Authorization;
public static class InventoryPermissions{ public static class Items { public const string Resource = "Inventory.Items"; public const string View = $"Permissions.{Resource}.View"; public const string Create = $"Permissions.{Resource}.Create"; }
public static IReadOnlyList<FshPermission> All { get; } = [ new("View Inventory", ActionConstants.View, Items.Resource), new("Create Inventory", ActionConstants.Create, Items.Resource), ];}5. DbContext — subclass BaseDbContext, call base.OnModelCreating last
Every module gets its own schema and its own DbContext extending BaseDbContext. Tenant isolation is default-on: BaseDbContext.OnModelCreating auto-applies the tenant query filter (and soft-delete filter) to every entity it sees, so your subclass must call it last, after all entity configuration, or late-configured entities escape the filter. Verbatim from NotificationsDbContext.cs, renamed:
namespace FSH.Modules.Inventory.Data;
public sealed class InventoryDbContext : BaseDbContext{ public const string Schema = "inventory";
public InventoryDbContext( IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor, DbContextOptions<InventoryDbContext> options, IOptions<DatabaseOptions> settings, IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { }
public DbSet<Item> Items => Set<Item>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(InventoryDbContext).Assembly); base.OnModelCreating(modelBuilder); // MUST be last — applies tenant + soft-delete filters }}If an entity genuinely must span tenants, mark it IGlobalEntity to opt out — that’s the only sanctioned escape hatch, and TenantIsolationTests checks every entity is one or the other.
6. Register in all four places
This is the step people miss, and every miss is silent.
The Mediator list needs two markers per module — one type from the Contracts assembly and one from the runtime assembly, because commands live in Contracts and handlers live in the runtime project. From the real FSH.Starter.Api/Program.cs, condensed:
builder.Services.AddMediator(o =>{ o.ServiceLifetime = ServiceLifetime.Scoped; o.Assemblies = [ // …existing pairs… typeof(FSH.Modules.Catalog.Contracts.CatalogContractsMarker), typeof(FSH.Modules.Catalog.CatalogModule), typeof(FSH.Modules.Notifications.Contracts.v1.Commands.MarkNotificationReadCommand), typeof(FSH.Modules.Notifications.NotificationsModule), typeof(FSH.Modules.Inventory.Contracts.InventoryContractsMarker), // ← add typeof(FSH.Modules.Inventory.InventoryModule)]; // ← add});
var moduleAssemblies = new Assembly[]{ // …existing modules… typeof(FSH.Modules.Notifications.NotificationsModule).Assembly, typeof(FSH.Modules.Inventory.InventoryModule).Assembly, // ← add};(Some older entries use a real command type instead of a dedicated marker class — either works; any type from the right assembly does. New modules use the marker convention.)
Now open src/Host/FSH.Starter.DbMigrator/Program.cs and make the same two edits. The migrator mirrors the API’s Mediator registration and module list on purpose — its comment says why: module IDbInitializers depend on services the module wiring provides, and the per-tenant migration pass only iterates modules it loaded.
The fastest sanity check after wiring: build, run, hit one of your endpoints, and confirm the handler actually executes.
7. Migrations
All EF migrations live in one project — src/Host/FSH.Starter.Migrations.PostgreSQL — foldered per module, each folder with its own model snapshot:
src/Host/FSH.Starter.Migrations.PostgreSQL/├── Audit/├── Catalog/├── Notifications/│ ├── 20260512204534_InitialNotifications.cs│ └── NotificationsDbContextModelSnapshot.cs└── …Add an Inventory/ folder (and a matching <Folder Include="Inventory\" /> entry in the Migrations .csproj, like the existing ones), make sure the runtime project is referenced from the Migrations project (step 2), then create the initial migration. Build first — dotnet ef migrations add reads the compiled snapshot, so generating against a stale build silently drops your latest model changes. The same footgun applies in reverse: migrations remove rewrites the snapshot, so only ever remove the latest migration and rebuild before the next add.
dotnet tool restore # dotnet-ef is pinned in .config/dotnet-tools.jsondotnet build src/FSH.Starter.slnx # ALWAYS build before migrations add
dotnet ef migrations add InitialInventory \ --project src/Host/FSH.Starter.Migrations.PostgreSQL \ --startup-project src/Host/FSH.Starter.Api \ --context InventoryDbContext \ --output-dir InventoryAll three of --project, --startup-project, and --context are required — there are ten DbContexts in that project, and --output-dir is what lands the files in your module’s folder. Apply through the DbMigrator (the database is not migrated at API startup):
dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # previewdotnet run --project src/Host/FSH.Starter.DbMigrator -- applyMore on the migrator’s verbs and deployment story in database migrations.
8. Tests — and what fails if you wired it wrong
Add a per-module unit test project following the existing convention: src/Tests/Inventory.Tests (see Catalog.Tests, Billing.Tests, Chat.Tests for the shape), and add it to the solution. If your handlers test internal types, the runtime module grants [assembly: InternalsVisibleTo("Inventory.Tests")] in its AssemblyInfo.cs — Notifications does exactly this.
src/Tests/Architecture.Tests is the safety net for this whole guide. The rules that bite module authors:
| Test | Catches |
|---|---|
ModuleArchitectureTests.Modules_Should_Not_Depend_On_Other_Modules | A <ProjectReference> from your runtime project to another module’s runtime project (csproj scan — only .Contracts references are allowed) |
ContractsPurityTests.* | EF Core, FluentValidation, Hangfire, DbContexts, or module internals leaking into your Contracts project |
HandlerValidatorPairingTests.CommandHandlers_Should_Have_Corresponding_Validators | A command handler without a {Command}Validator |
HandlerValidatorPairingTests.QueryHandlers_With_Pagination_Should_Have_Validators | A paginated query handler without a validator |
TenantIsolationTests.BaseDbContext_Entities_Should_Be_TenantIsolated_Or_Marked_Global | An entity that is neither tenant-isolated nor explicitly IGlobalEntity |
EndpointConventionTests.* | Endpoint classes that aren’t static, misnamed, or missing a Map* method |
Run the full gauntlet:
dotnet build src/FSH.Starter.slnx # TreatWarningsAsErrors — 0 warningsdotnet test src/Tests/Architecture.Testsdotnet test src/FSH.Starter.slnx # integration tests require DockerChecklist
- Two projects under
src/Modules/Inventory/— csproj copied from an existing module, runtime references Contracts + BuildingBlocks, Contracts stays thin. InventoryContractsMarkerclass in the Contracts project.- Both projects added to
src/FSH.Starter.slnx; runtime referenced fromFSH.Starter.Migrations.PostgreSQL(and directly from both hosts, matching existing modules). [assembly: FshModule(typeof(InventoryModule), 900)]— assembly-level, positional order.InventoryModule : IModule—PermissionConstants.Register,AddHeroDbContext,IDbInitializer, health check, versioned endpoint group.InventoryPermissionsinContracts/Authorizationwith anAlllist.InventoryDbContext : BaseDbContext— 4-arg ctor,HasDefaultSchema,base.OnModelCreatinglast.- Registered in all four places — Mediator pair +
moduleAssembliesinFSH.Starter.Api/Program.csandFSH.Starter.DbMigrator/Program.cs. Inventory/migrations folder + initial migration (--context InventoryDbContext --output-dir Inventory), applied via DbMigrator.Inventory.Testsproject; build +Architecture.Tests+ full suite green.- Hit an endpoint and confirm a handler actually runs — the four-wire-points check no test performs for you.
Related
- Architecture: modular monolith — why modules are shaped this way.
- Architecture: dependency injection —
ModuleLoader, platform composition, lifetimes. - Deployment: database migrations — the DbMigrator in CI/CD.
- Add a feature — the slice-level recipe once your module exists.
- AI route: point your coding agent at
.agents/skills/add-module/SKILL.mdin the repo — it encodes this entire recipe as a scaffolding script.