Add a module
Add a bounded context to the AppointMe .NET 10 modular monolith, with three projects, an EF Core DbContext in its own schema, a permission and an endpoint, wired into the API host.
- You'll build
- An
Inventorymodule withPOST /api/v1/products - Time
- About 30 minutes
- Touches
- 3 new projects, 2 solution files, the API host
- Before you start
- .NET 10 SDK,
dotnet-ef, a green build
What you’ll create
Section titled “What you’ll create”A module is three projects in one folder under src/, plus five small edits outside it.
Every step below was run against a fresh checkout: the solution built, the module’s tests passed, Wolverine’s codegen test passed and the app applied the new migration on startup. CRM is the closest model, because it has one aggregate and no projections. The theory is on Modular monolith.
Directorysrc/Inventory/
DirectoryAppointMe.Inventory.Contracts/ # what other modules may reference
- AppointMe.Inventory.Contracts.csproj
- Infrastructure/InventoryContractsAssembly.cs
- Products/Events/ProductAddedEvent.cs
DirectoryAppointMe.Inventory/ # the module, private to everyone else
- AppointMe.Inventory.csproj
- GlobalUsings.cs
- ModuleAssembly.cs
- Configuration/InventoryModule.cs #
AddInventoryModule() - Database/InventoryDbContext.cs # schema
inventory DirectoryDatabase/Migrations/ # generated by
dotnet ef- …
- Authorization/InventoryDefaultGrantPolicy.cs
DirectoryProducts/ #
Product,ProductId,ProductPermissions,Database/- …
DirectoryProducts/AddProduct/ # the slice: factory, command, handler, endpoint
- …
DirectoryAppointMe.Inventory.Tests/
- AppointMe.Inventory.Tests.csproj
- Products/AddProduct/AddProductTests.cs
Create the projects
Section titled “Create the projects”Package versions live in Directory.Packages.props, so a module’s .csproj lists package names only.
<PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup>Every package used below is already pinned there. If your module needs a new one, add a <PackageVersion Include="…" Version="…" /> line to that file, then reference it without a version. Directory.Build.props sets net10.0, C# 14, implicit usings and nullable for every project, so the project files stay short.
-
Create the contracts project. It holds the events and interfaces other modules may use, and references only Shared.
src/Inventory/AppointMe.Inventory.Contracts/AppointMe.Inventory.Contracts.csproj <Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\..\AppointMe.Shared\AppointMe.Shared.csproj" /></ItemGroup><ItemGroup><PackageReference Include="WolverineFx" /></ItemGroup></Project>Add
Infrastructure/InventoryContractsAssembly.cscontainingusing Wolverine.Attributes;and[assembly: WolverineModule], as the CRM and Booking contracts do. Then add the event:src/Inventory/AppointMe.Inventory.Contracts/Products/Events/ProductAddedEvent.cs using AppointMe.Shared.Domain;namespace AppointMe.Inventory.Contracts.Products.Events;public sealed record ProductAddedEvent(Guid ProductId, Guid CompanyId, string Name) : IDomainEvent; -
Create the module project. It references Shared and its own contracts. Another module’s contracts go here too, when you consume its events.
src/Inventory/AppointMe.Inventory/AppointMe.Inventory.csproj <Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="Asp.Versioning.Http" /><PackageReference Include="Microsoft.EntityFrameworkCore" /><PackageReference Include="Microsoft.EntityFrameworkCore.Design"><PrivateAssets>all</PrivateAssets><IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets></PackageReference><PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /><PackageReference Include="WolverineFx" /></ItemGroup><ItemGroup><ProjectReference Include="..\..\AppointMe.Shared\AppointMe.Shared.csproj" /><ProjectReference Include="..\AppointMe.Inventory.Contracts\AppointMe.Inventory.Contracts.csproj" /></ItemGroup><ItemGroup><InternalsVisibleTo Include="AppointMe.Inventory.Tests" /></ItemGroup></Project>Add
DapperandDapper.SqlBuilderwhen you write your first read query, as CRM does. -
Create the tests project. Copy
src/CRM/AppointMe.Crm.Tests/AppointMe.Crm.Tests.csprojand its.editorconfig, which relaxes naming rules forsnake_casetest names. Then point the references at your module:src/Inventory/AppointMe.Inventory.Tests/AppointMe.Inventory.Tests.csproj <!-- … OutputType Exe, xunit.v3.mtp-off, Microsoft.NET.Test.Sdk, coverlet.collector … --><ItemGroup><ProjectReference Include="..\..\AppointMe.Shared\AppointMe.Shared.csproj" /><ProjectReference Include="..\AppointMe.Inventory.Contracts\AppointMe.Inventory.Contracts.csproj" /><ProjectReference Include="..\AppointMe.Inventory\AppointMe.Inventory.csproj" /></ItemGroup> -
Add the projects to both solution files. The repo keeps
AppointMe.slnandAppointMe.slnxside by side, anddotnet slnedits either one.Terminal window dotnet sln AppointMe.sln add --solution-folder Inventory src/Inventory/AppointMe.Inventory.Contracts/AppointMe.Inventory.Contracts.csproj src/Inventory/AppointMe.Inventory/AppointMe.Inventory.csproj src/Inventory/AppointMe.Inventory.Tests/AppointMe.Inventory.Tests.csprojdotnet sln AppointMe.slnx add --solution-folder Inventory src/Inventory/AppointMe.Inventory.Contracts/AppointMe.Inventory.Contracts.csproj src/Inventory/AppointMe.Inventory/AppointMe.Inventory.csproj src/Inventory/AppointMe.Inventory.Tests/AppointMe.Inventory.Tests.csprojCheck:
git diff AppointMe.slnxshows a new<Folder Name="/Inventory/">with three projects.
Write the module
Section titled “Write the module”The module owns its aggregate, its permissions, its DbContext and one Add*Module() method that registers all of it.
-
Add the plumbing files. Copy
src/CRM/AppointMe.Crm/GlobalUsings.csunchanged. Then add the assembly marker that the registration methods scan:src/Inventory/AppointMe.Inventory/ModuleAssembly.cs using System.Reflection;namespace AppointMe.Inventory;public static class ModuleAssembly{public static Assembly Instance => typeof(ModuleAssembly).Assembly;} -
Add the aggregate, its ID and its permissions.
ProductId.csis one line:public readonly record struct ProductId(Guid Value);.src/Inventory/AppointMe.Inventory/Products/Product.cs namespace AppointMe.Inventory.Products;public sealed record Product : AggregateRoot{public const int NameMaxLength = 200;public required ProductId Id { get; init; }public required CompanyId CompanyId { get; init; }public required string Name { get; set; }}src/Inventory/AppointMe.Inventory/Products/ProductPermissions.cs using AppointMe.Shared.Authorization.Permissions;namespace AppointMe.Inventory.Products;public static class ProductPermissions{private const string Products = "products";public static readonly Permission View = new(Products, "view");public static readonly Permission Create = new(Products, "create");}Grant them to roles in
Authorization/InventoryDefaultGrantPolicy.cs. That file and the rules for discovery are on Add a permission. -
Add the
DbContext. It sets the module’s schema and the tenant filter.ProductFilters.csholds one constant,public const string CompanyId = nameof(CompanyId);.src/Inventory/AppointMe.Inventory/Database/InventoryDbContext.cs using AppointMe.Inventory.Products;using AppointMe.Inventory.Products.Database;namespace AppointMe.Inventory.Database;public sealed class InventoryDbContext(DbContextOptions<InventoryDbContext> options, ICurrentCompany currentCompany): DbContext(options){public const string DefaultSchema = "inventory";public DbSet<Product> Products => Set<Product>();protected override void OnModelCreating(ModelBuilder modelBuilder){modelBuilder.HasDefaultSchema(DefaultSchema);modelBuilder.ApplyConfigurationsFromAssembly(ModuleAssembly.Instance);modelBuilder.Entity<Product>().HasQueryFilter(ProductFilters.CompanyId, product => product.CompanyId == currentCompany.CompanyId);}}The filter is what keeps one tenant’s rows away from another. See Multi-tenancy.
-
Map the entity. It goes in
Products/Database/ProductTypeConfiguration.cs, apublic sealed class ProductTypeConfiguration : IEntityTypeConfiguration<Product>:src/Inventory/AppointMe.Inventory/Products/Database/ProductTypeConfiguration.cs // …public void Configure(EntityTypeBuilder<Product> builder){builder.ToTable("Products");builder.HasKey(product => product.Id);builder.Property(product => product.Id).HasConversion(id => id.Value, value => new ProductId(value)).ValueGeneratedNever();builder.Property(product => product.CompanyId).HasConversion(companyId => companyId.Value, value => new CompanyId(value)).IsRequired();builder.Property(product => product.Name).HasMaxLength(Product.NameMaxLength).IsRequired();builder.HasIndex(product => product.CompanyId);builder.Property<byte[]>("Version").IsRowVersion().IsRequired();}The
Versionrow-version column is on every entity in the repo, and gives you optimistic concurrency. -
Write the slice. The factory raises the contract event, so Wolverine publishes it when the handler saves.
src/Inventory/AppointMe.Inventory/Products/AddProduct/AddProduct.cs using AppointMe.Inventory.Contracts.Products.Events;namespace AppointMe.Inventory.Products.AddProduct;public static class AddProduct{extension(Product){public static Product Add(CompanyId companyId, string name){if (string.IsNullOrWhiteSpace(name) || name.Length > Product.NameMaxLength){throw new ValidationException($"Product name must be 1-{Product.NameMaxLength} characters.",code: "product_name_invalid");}var product = new Product { Id = new ProductId(NewId()), CompanyId = companyId, Name = name.Trim() };product.Raise(new ProductAddedEvent(product.Id.Value, companyId.Value, product.Name));return product;}}}Three one-line records sit next to it, one per file:
AddProductCommand(string Name),AddProductRequest(string Name)andAddProductResponse(Guid Id). The handler takesCompanyIdandIPrincipal, and checks the permission first:src/Inventory/AppointMe.Inventory/Products/AddProduct/AddProductCommandHandler.cs using AppointMe.Inventory.Database;namespace AppointMe.Inventory.Products.AddProduct;public sealed class AddProductCommandHandler(InventoryDbContext dbContext){public async Task<AddProductResponse> HandleAsync(AddProductCommand command, CompanyId companyId,IPrincipal principal, CancellationToken cancellationToken){principal.Require(ProductPermissions.Create);var product = Product.Add(companyId, command.Name);await dbContext.Products.AddAsync(product, cancellationToken);await dbContext.SaveChangesAsync(cancellationToken);return new AddProductResponse(product.Id.Value);}}src/Inventory/AppointMe.Inventory/Products/AddProduct/AddProductEndpoint.cs using Microsoft.AspNetCore.Http.HttpResults;using Microsoft.AspNetCore.Mvc;namespace AppointMe.Inventory.Products.AddProduct;internal sealed class AddProductEndpoint : IEndpoint{public void MapEndpoint(IEndpointRouteBuilder builder){builder.MapPost("/products", AddProduct).WithName(nameof(AddProduct));}private static async Task<Created<AddProductResponse>> AddProduct([FromBody] AddProductRequest request, IMessageBus bus, CancellationToken cancellationToken){var result = await bus.InvokeAsync<AddProductResponse>(new AddProductCommand(request.Name), cancellationToken);return TypedResults.Created($"/products/{result.Id}", result);}}The route gets the
api/v1prefix fromMapEndpoints(). Slices in depth: Add an endpoint. -
Register everything in one method. The file needs
usingdirectives forAppointMe.Inventory.Database,AppointMe.Shared.Authorization.Permissions,AppointMe.Shared.Configuration,AppointMe.Shared.Database.Migrations,Microsoft.Extensions.DependencyInjectionandWolverine.Attributes.src/Inventory/AppointMe.Inventory/Configuration/InventoryModule.cs // …[assembly: WolverineModule]namespace AppointMe.Inventory.Configuration;public static class InventoryModule{extension(IServiceCollection services){public IServiceCollection AddInventoryModule(){return services.AddDbContext<InventoryDbContext>((serviceProvider, options) =>options.UseSqlServer(serviceProvider.GetRequiredService<ConnectionStrings>().AppointMeSql,sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", InventoryDbContext.DefaultSchema))).AddDatabaseMigration<InventoryDbContext>().AddEndpoints(ModuleAssembly.Instance).AddPermissions(ModuleAssembly.Instance);}}}Call Does [assembly: WolverineModule]Lets Wolverine find the handlers in this assembly. Without it, codegen previewlists noAddProductCommandHandler.AddDbContext+MigrationsHistoryTableOne shared connection string, with the history table in your schema: inventory.__EFMigrationsHistoryAddDatabaseMigration<InventoryDbContext>()Queues the context for the hosted service that runs MigrateAsyncon startupAddEndpointsScrutor registers every IEndpointin the assemblyAddPermissionsRegisters every Permissionfield andIDefaultGrantPolicyin the assemblyAdd
AddRecurringJobs(ModuleAssembly.Instance)when you add a Hangfire job, and anIDbConnectionFactorywhen you add Dapper reads.CrmModule.csshows both.
Wire it into the host
Section titled “Wire it into the host”The API host references the module, calls AddInventoryModule(), and applies the migration on startup. Aspire needs no change.
-
Reference the module from the API. Edit the file by hand.
dotnet add referenceworks, but it also rewrites theInvalidateOpenApiDocumentsOnReferenceChangetarget lower in the file onto one line with->escapes.src/AppointMe.Api/AppointMe.Api.csproj <ProjectReference Include="..\Organizations\AppointMe.Organizations\AppointMe.Organizations.csproj" /><ProjectReference Include="..\Booking\AppointMe.Booking\AppointMe.Booking.csproj" /><ProjectReference Include="..\Inventory\AppointMe.Inventory\AppointMe.Inventory.csproj" /></ItemGroup> -
Register the module in
Program.cs.src/AppointMe.Api/Program.cs using AppointMe.Booking.Configuration;using AppointMe.Crm.Configuration;using AppointMe.Identity.Configuration;using AppointMe.Inventory.Configuration;using AppointMe.Organizations.Configuration;// …builder.Services.AddSharedModule(builder.Configuration).AddIdentityModule(builder.Configuration).AddOrganizationsModule().AddCrmModule().AddBookingModule().AddInventoryModule();Migrations run in registration order, so a module that depends on another’s tables goes after it.
-
Add the first migration. Run it from the repo root, with the API as the startup project:
Terminal window dotnet ef migrations add Initial --project src/Inventory/AppointMe.Inventory --startup-project src/AppointMe.Api --context InventoryDbContext --output-dir Database/MigrationsIt writes
<timestamp>_Initial.cs, its designer file andInventoryDbContextModelSnapshot.cs. The migration creates theinventoryschema,inventory.Productsand theIX_Products_CompanyIdindex. More on the workflow: Database migrations. -
Leave Aspire as it is. Every module shares the one database that Aspire creates and hands to the API as
AppointMeSql:src/AppointMe.Aspire/Program.csView source ↗ var database = sqlServer.AddDatabase("AppointMeSql", "AppointMe"); -
Regenerate the frontend client. The new endpoint and permissions change the OpenAPI document, so run this from
src/AppointMe.Frontend:Terminal window npm run generate:apiThe client gains
useAddProductand two newPermissionvalues.npm run checkthen fails until you label them inpermission-labels.ts, as Add a permission shows. To build a screen, follow Add a page. -
Write a test. Domain factories are plain methods, so the first test needs no database:
src/Inventory/AppointMe.Inventory.Tests/Products/AddProduct/AddProductTests.cs // … usings: the contract event, Products, Products.AddProduct, Shared.Companies, Shared.Domain.Errorspublic class AddProductTests{[Fact]public void should_add_product_and_raise_event(){var companyId = new CompanyId(Guid.NewGuid());var product = Product.Add(companyId, "Shampoo");Assert.Equal("Shampoo", product.Name);var @event = product.Events.OfType<ProductAddedEvent>().Single();Assert.Equal(product.Id.Value, @event.ProductId);Assert.Equal(companyId.Value, @event.CompanyId);}[Fact]public void should_reject_an_empty_name(){Assert.Throws<ValidationException>(() => Product.Add(new CompanyId(Guid.NewGuid()), " "));}}
Verify it works
Section titled “Verify it works”Five checks, from fastest to slowest. None of the first four needs a database or Keycloak.
| Check | Command | Expect |
|---|---|---|
| It builds | dotnet build AppointMe.sln |
0 Error(s) |
| Tests pass | dotnet test src/Inventory/AppointMe.Inventory.Tests |
Passed!, with 2 tests and 0 failed |
| The endpoint and permissions are discovered | grep -o -e '"/api/v1/products"' -e '"products:create"' src/AppointMe.Api/obj/openapi.json |
Both strings. The build writes this file by booting the app in the Codegen environment. |
| Wolverine can wire the handler | dotnet build AppointMe.sln -c Release, then the codegen test command from Messaging |
Success! |
| The schema is created | Start the app with Aspire | Applying migrations for InventoryDbContext... then Migrations applied for InventoryDbContext. in the API log, and an inventory.Products table |
To call the endpoint, sign in to the SPA as an Owner or Manager and send POST /api/v1/products with {"name":"Shampoo"}, the X-Company-Id header and an X-XSRF-TOKEN header that echoes the XSRF-TOKEN cookie (without it, the antiforgery middleware returns 400). It returns 201 with the new id.
Troubleshooting
Section titled “Troubleshooting”Most problems are a registration line that’s missing, and the symptom tells you which one.
| Symptom | Cause | Fix |
|---|---|---|
/api/v1/products is missing from obj/openapi.json |
No .AddEndpoints(ModuleAssembly.Instance), or the endpoint class doesn’t implement IEndpoint |
Add the call, or implement the interface |
codegen preview prints no AddProductCommandHandler |
No [assembly: WolverineModule] in the module |
Add it to InventoryModule.cs |
products:* is missing from the permissions screen |
No .AddPermissions(...), or the class isn’t static or its name doesn’t end in Permissions |
See Add a permission |
403 The following permissions are required: products:create. |
The caller’s roles don’t grant it | Add the role to InventoryDefaultGrantPolicy, or override it for the company |
npm run check fails with TS2739 in permission-labels.ts |
The regenerated Permission type has codes with no label |
Add a label for each new code |
The API .csproj diff touches a <Target> you didn’t edit |
dotnet add reference reformatted the file |
Revert, and add the <ProjectReference> line by hand |
Checklist
Section titled “Checklist”Every file, in the order this page creates or edits it.
| # | File | New or edit | What |
|---|---|---|---|
| 1 | src/Inventory/AppointMe.Inventory.Contracts/AppointMe.Inventory.Contracts.csproj |
New | References Shared, WolverineFx |
| 2 | …Contracts/Infrastructure/InventoryContractsAssembly.cs |
New | [assembly: WolverineModule] |
| 3 | …Contracts/Products/Events/ProductAddedEvent.cs |
New | The contract event |
| 4 | src/Inventory/AppointMe.Inventory/AppointMe.Inventory.csproj |
New | Packages without versions, InternalsVisibleTo |
| 5 | src/Inventory/AppointMe.Inventory.Tests/AppointMe.Inventory.Tests.csproj and .editorconfig |
New | Copied from CRM, references changed |
| 6 | AppointMe.sln, AppointMe.slnx |
Edit | dotnet sln … add --solution-folder Inventory |
| 7 | …Inventory/GlobalUsings.cs, ModuleAssembly.cs |
New | Copied from CRM |
| 8 | …Inventory/Products/Product.cs, ProductId.cs |
New | The aggregate and its ID |
| 9 | …Inventory/Products/ProductPermissions.cs |
New | products:view, products:create |
| 10 | …Inventory/Authorization/InventoryDefaultGrantPolicy.cs |
New | Default grants per role |
| 11 | …Inventory/Database/InventoryDbContext.cs |
New | Schema inventory, company filter |
| 12 | …Inventory/Products/Database/ProductFilters.cs, ProductTypeConfiguration.cs |
New | Filter name, table mapping |
| 13 | …Inventory/Products/AddProduct/*.cs |
New | Factory, command, request, response, handler, endpoint |
| 14 | …Inventory/Configuration/InventoryModule.cs |
New | AddInventoryModule() |
| 15 | src/AppointMe.Api/AppointMe.Api.csproj |
Edit | <ProjectReference> to the module |
| 16 | src/AppointMe.Api/Program.cs |
Edit | using and .AddInventoryModule() |
| 17 | …Inventory/Database/Migrations/ |
New | dotnet ef migrations add Initial |
| 18 | src/AppointMe.Frontend/src/api/ |
Regenerated | npm run generate:api |
| 19 | src/AppointMe.Frontend/src/app/settings/permissions/permission-labels.ts |
Edit | Labels for the new codes |
| 20 | …Inventory.Tests/Products/AddProduct/AddProductTests.cs |
New | Factory tests |
Why a separate contracts project for every module?2 min · optional
Other modules reference AppointMe.Inventory.Contracts, never AppointMe.Inventory. Booking, for example, references AppointMe.Crm.Contracts and handles CRM’s events, but it can’t see Customer or CrmDbContext. The compiler enforces the boundary, because a type that isn’t in contracts isn’t reachable.
It also keeps a module removable. Nothing outside the folder depends on its internals, so taking it out is mostly deleting the folder and the host lines, as Remove a module shows. The cost is a third .csproj per module, even when the contracts project holds a single event.