Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 202611 min read

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 Inventory module with POST /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

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

Package versions live in Directory.Packages.props, so a module’s .csproj lists package names only.

Directory.Packages.propsView source ↗
<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.

  1. 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.cs containing using 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;
  2. 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 Dapper and Dapper.SqlBuilder when you write your first read query, as CRM does.

  3. Create the tests project. Copy src/CRM/AppointMe.Crm.Tests/AppointMe.Crm.Tests.csproj and its .editorconfig, which relaxes naming rules for snake_case test 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>
  4. Add the projects to both solution files. The repo keeps AppointMe.sln and AppointMe.slnx side by side, and dotnet sln edits 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.csproj
    dotnet 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.csproj

    Check: git diff AppointMe.slnx shows a new <Folder Name="/Inventory/"> with three projects.

The module owns its aggregate, its permissions, its DbContext and one Add*Module() method that registers all of it.

  1. Add the plumbing files. Copy src/CRM/AppointMe.Crm/GlobalUsings.cs unchanged. 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;
    }
  2. Add the aggregate, its ID and its permissions. ProductId.cs is 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.

  3. Add the DbContext. It sets the module’s schema and the tenant filter. ProductFilters.cs holds 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.

  4. Map the entity. It goes in Products/Database/ProductTypeConfiguration.cs, a public 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 Version row-version column is on every entity in the repo, and gives you optimistic concurrency.

  5. 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) and AddProductResponse(Guid Id). The handler takes CompanyId and IPrincipal, 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/v1 prefix from MapEndpoints(). Slices in depth: Add an endpoint.

  6. Register everything in one method. The file needs using directives for AppointMe.Inventory.Database, AppointMe.Shared.Authorization.Permissions, AppointMe.Shared.Configuration, AppointMe.Shared.Database.Migrations, Microsoft.Extensions.DependencyInjection and Wolverine.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 preview lists no AddProductCommandHandler.
    AddDbContext + MigrationsHistoryTable One shared connection string, with the history table in your schema: inventory.__EFMigrationsHistory
    AddDatabaseMigration<InventoryDbContext>() Queues the context for the hosted service that runs MigrateAsync on startup
    AddEndpoints Scrutor registers every IEndpoint in the assembly
    AddPermissions Registers every Permission field and IDefaultGrantPolicy in the assembly

    Add AddRecurringJobs(ModuleAssembly.Instance) when you add a Hangfire job, and an IDbConnectionFactory when you add Dapper reads. CrmModule.cs shows both.

The API host references the module, calls AddInventoryModule(), and applies the migration on startup. Aspire needs no change.

  1. Reference the module from the API. Edit the file by hand. dotnet add reference works, but it also rewrites the InvalidateOpenApiDocumentsOnReferenceChange target lower in the file onto one line with -&gt; 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>
  2. 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.

  3. 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/Migrations

    It writes <timestamp>_Initial.cs, its designer file and InventoryDbContextModelSnapshot.cs. The migration creates the inventory schema, inventory.Products and the IX_Products_CompanyId index. More on the workflow: Database migrations.

  4. 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");
  5. 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:api

    The client gains useAddProduct and two new Permission values. npm run check then fails until you label them in permission-labels.ts, as Add a permission shows. To build a screen, follow Add a page.

  6. 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.Errors
    public 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()), " "));
    }
    }

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.

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

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.