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

Source page: https://appointme.dev/docs/guides/add-a-module/

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

## 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](/docs/concepts/modular-monolith/).

* src/Inventory/

  * AppointMe.Inventory.Contracts/ # what other modules may reference

    * AppointMe.Inventory.Contracts.csproj
    * Infrastructure/InventoryContractsAssembly.cs
    * Products/Events/ProductAddedEvent.cs

  * AppointMe.Inventory/ # the module, private to everyone else

    * AppointMe.Inventory.csproj

    * GlobalUsings.cs

    * ModuleAssembly.cs

    * Configuration/InventoryModule.cs # `AddInventoryModule()`

    * Database/InventoryDbContext.cs # schema `inventory`

    * Database/Migrations/ # generated by `dotnet ef`

      * …

    * Authorization/InventoryDefaultGrantPolicy.cs

    * Products/ # `Product`, `ProductId`, `ProductPermissions`, `Database/`

      * …

    * Products/AddProduct/ # the slice: factory, command, handler, endpoint

      * …

  * AppointMe.Inventory.Tests/

    * AppointMe.Inventory.Tests.csproj
    * Products/AddProduct/AddProductTests.cs

## Create the projects

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

Directory.Packages.props

```xml
  <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

   ```xml
   <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

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

   ```xml
   <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

   ```xml
   <!-- … 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.

   ```bash
   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.

## Write the module

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

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

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

   ```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](/docs/guides/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

   ```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](/docs/concepts/multi-tenancy/#make-your-own-entity-tenant-scoped).

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

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

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

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

   ```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](/docs/guides/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

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

## Wire it into the host

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

   ```xml
       <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

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

   ```bash
   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](/docs/guides/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.cs

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

   ```bash
   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](/docs/guides/add-a-permission/) shows. To build a screen, follow [Add a page](/docs/frontend/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

   ```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()), " "));
       }
   }
   ```

Gotcha

Leave out `[assembly: WolverineModule]` and everything still compiles, and `codegen test` still passes, but Wolverine never discovers the module’s handlers. The endpoint’s `InvokeAsync` then has nothing to call. Run `dotnet run --project src/AppointMe.Api --no-build -- codegen preview` and check that `AddProductCommandHandler` appears in the output.

## 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](/docs/concepts/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

**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](/docs/guides/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

**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?

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](/docs/guides/remove-a-module/) shows. The cost is a third `.csproj` per module, even when the contracts project holds a single event.

## Next

* [Add a permission](/docs/guides/add-a-permission/) (Guide)
* [Cross-module events](/docs/guides/cross-module-events/) (Guide)
* [Modular monolith](/docs/concepts/modular-monolith/) (Concept)
