Skip to content
fullstackhero

Recipe

Add a module

End-to-end recipe for creating a new bounded-context module — the two-project layout, IModule and FshModule registration, BaseDbContext, migrations, and the four wire points people miss.

views 0 Last updated

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

Terminal window
dotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory/Modules.Inventory.csproj
dotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory.Contracts/Modules.Inventory.Contracts.csproj

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

src/Modules/Notifications/Modules.Notifications/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:

ModuleOrderModuleOrder
Identity100Billing500
Multitenancy200Catalog600
Auditing300Tickets700
Files350Notifications750
Webhooks400Chat800

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

Terminal window
dotnet tool restore # dotnet-ef is pinned in .config/dotnet-tools.json
dotnet 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 Inventory

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

Terminal window
dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # preview
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply

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

TestCatches
ModuleArchitectureTests.Modules_Should_Not_Depend_On_Other_ModulesA <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_ValidatorsA command handler without a {Command}Validator
HandlerValidatorPairingTests.QueryHandlers_With_Pagination_Should_Have_ValidatorsA paginated query handler without a validator
TenantIsolationTests.BaseDbContext_Entities_Should_Be_TenantIsolated_Or_Marked_GlobalAn 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:

Terminal window
dotnet build src/FSH.Starter.slnx # TreatWarningsAsErrors — 0 warnings
dotnet test src/Tests/Architecture.Tests
dotnet test src/FSH.Starter.slnx # integration tests require Docker

Checklist

  1. Two projects under src/Modules/Inventory/ — csproj copied from an existing module, runtime references Contracts + BuildingBlocks, Contracts stays thin.
  2. InventoryContractsMarker class in the Contracts project.
  3. Both projects added to src/FSH.Starter.slnx; runtime referenced from FSH.Starter.Migrations.PostgreSQL (and directly from both hosts, matching existing modules).
  4. [assembly: FshModule(typeof(InventoryModule), 900)] — assembly-level, positional order.
  5. InventoryModule : IModulePermissionConstants.Register, AddHeroDbContext, IDbInitializer, health check, versioned endpoint group.
  6. InventoryPermissions in Contracts/Authorization with an All list.
  7. InventoryDbContext : BaseDbContext — 4-arg ctor, HasDefaultSchema, base.OnModelCreating last.
  8. Registered in all four places — Mediator pair + moduleAssemblies in FSH.Starter.Api/Program.cs and FSH.Starter.DbMigrator/Program.cs.
  9. Inventory/ migrations folder + initial migration (--context InventoryDbContext --output-dir Inventory), applied via DbMigrator.
  10. Inventory.Tests project; build + Architecture.Tests + full suite green.
  11. Hit an endpoint and confirm a handler actually runs — the four-wire-points check no test performs for you.