Module Anatomy
Every module generated by Modulus follows the same five-layer structure with three dedicated test projects. This consistency makes it easy to navigate any module in the solution, onboard new team members, and enforce architectural rules automatically.
Directory Structure
When you run modulus add-module Catalog, the following structure is generated (project names are {Module}.{Layer}; namespaces are EShop.Catalog.{Layer}). The tree below also shows in (italics) where later scaffolding commands (add-entity, add-command, add-query, add-event) place their output:
src/Modules/Catalog/
├── src/
│ ├── Catalog.Api/
│ │ ├── AssemblyReference.cs
│ │ └── Endpoints/
│ │ ├── CatalogEndpointRegistration.cs # maps the /api/catalog group
│ │ └── GetSample.cs # GET /api/catalog/sample
│ ├── Catalog.Application/
│ │ ├── AssemblyReference.cs
│ │ ├── Data/
│ │ │ └── IQueryDb.cs
│ │ ├── Samples/
│ │ │ ├── GetSampleQuery.cs
│ │ │ └── GetSampleQueryHandler.cs
│ │ ├── Commands/CreateProduct/ # (add-command: record, handler, validator)
│ │ └── Queries/GetProductById/ # (add-query: record, handler)
│ ├── Catalog.Domain/
│ │ ├── AssemblyReference.cs
│ │ ├── Entities/ # (add-entity: Product.cs)
│ │ ├── Repositories/ # (add-entity: IProductRepository.cs)
│ │ └── Identifiers/ # (add-entity --id-type ProductId)
│ ├── Catalog.Infrastructure/
│ │ ├── AssemblyReference.cs
│ │ ├── CatalogModule.cs # IModuleRegistration composition root
│ │ └── Persistence/
│ │ ├── CatalogDbContext.cs
│ │ ├── CatalogReadOnlyDbContext.cs
│ │ ├── Configurations/ # (add-entity: ProductConfiguration.cs)
│ │ └── Repositories/ # (add-entity: ProductRepository.cs)
│ └── Catalog.Integration/ # (add-event: OrderPlaced.cs etc.)
└── tests/
├── Catalog.Tests.Unit/
├── Catalog.Tests.Integration/
└── Catalog.Tests.Architecture/
└── LayerDependencyTests.csLayer Dependency Rules
Each layer has strict rules about what it can and cannot reference. These rules are enforced by architecture tests and by project reference constraints.
The arrows represent the project references the scaffold creates. Note the direction between Infrastructure and Api: Infrastructure references Api, not the other way round -- the module's composition root (CatalogModule in Infrastructure) needs to call the Api layer's endpoint registration. The architecture tests permit only the *Module class to use Api types; every other Infrastructure type must stay Api-free.
| Layer | References | Must Not Reference |
|---|---|---|
| Domain | BuildingBlocks.Domain | Application, Infrastructure, Api, any other module |
| Application | Domain, BuildingBlocks.Application | Infrastructure, Api, any other module |
| Infrastructure | Application, Domain, Api, BuildingBlocks.Infrastructure | Any other module (except its Integration project) |
| Api | Application, BuildingBlocks.Infrastructure (for IEndpoint/ApiResults) | Infrastructure, Domain (direct), any other module |
| Integration | BuildingBlocks.Integration | Domain, Application, Infrastructure, Api |
Layer Details
Domain
The Domain layer is the core of the module. It contains the business rules, entities, and domain events. It has zero framework dependencies -- no EF Core, no ASP.NET, no messaging or broker libraries.
Contains:
- Entities -- Classes extending
Entity<TId>that represent domain objects with identity. - Aggregate roots -- Classes extending
AggregateRoot<TId>that serve as consistency boundaries. Only aggregate roots can raise domain events. - Value objects -- Classes extending
ValueObjectwith equality defined by their properties, not identity. - Domain events -- Records implementing
IDomainEventthat represent something meaningful that happened within the domain. - Domain exceptions -- Custom exceptions extending
DomainExceptionfor invariant violations.
public class Product : AggregateRoot<Guid>
{
public ProductName Name { get; private set; }
public decimal Price { get; private set; }
private Product() { } // EF Core
public static Product Create(string name, decimal price)
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = ProductName.Create(name),
Price = price
};
product.RaiseDomainEvent(new ProductCreatedEvent(product.Id));
return product;
}
}Keep the Domain pure
The Domain layer should express business rules in plain C# with no dependencies on frameworks or infrastructure concerns. This makes it easy to unit test and resilient to technology changes.
Application
The Application layer orchestrates use cases. It defines the commands, queries, and their handlers that drive the module's behavior. It depends on the Domain layer for entities and business rules but knows nothing about how data is persisted or how HTTP requests arrive.
Contains:
- Commands -- Records implementing
ICommandorICommand<TResult>that represent intent to change state. - Queries -- Records implementing
IQuery<TResult>that represent intent to read state. - Handlers -- Classes implementing
ICommandHandlerorIQueryHandlerthat contain use-case logic. - Validators -- FluentValidation validators for commands and queries, executed automatically by the validation pipeline behavior.
- DTOs -- Data transfer objects returned by queries. DTOs are simple records with no behavior.
- Interfaces --
IUnitOfWorkfor transaction management,IQueryDbfor read-only database access, and custom repository interfaces.
// Command
public sealed record CreateProduct(string Name, decimal Price) : ICommand<Guid>;
// Validator
public sealed class CreateProductValidator : AbstractValidator<CreateProduct>
{
public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThan(0);
}
}
// Handler
public sealed class CreateProductHandler : ICommandHandler<CreateProduct, Guid>
{
private readonly IProductRepository _repository;
private readonly IUnitOfWork _unitOfWork;
public CreateProductHandler(IProductRepository repository, IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(
CreateProduct command,
CancellationToken cancellationToken = default)
{
var product = Product.Create(Guid.NewGuid(), command.Name, command.Price);
await _repository.AddAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result<Guid>.Success(product.Id);
}
}Infrastructure
The Infrastructure layer provides concrete implementations for the abstractions defined in Application and Domain. It is the only layer that knows about EF Core, external services, and the messaging framework.
Contains:
- DbContext -- A module-specific
DbContextextendingBaseDbContext, configured with its own schema. - Entity configurations -- EF Core
IEntityTypeConfiguration<T>classes for mapping entities to tables. - Repositories -- Concrete implementations of repository interfaces, typically extending
EfRepository<T>. - Module registration -- The
IModuleRegistrationimplementation that registers all module services into the DI container and maps endpoints. - External service clients -- HTTP clients, third-party SDK wrappers, and other infrastructure concerns.
public sealed class CatalogDbContext(
DbContextOptions<CatalogDbContext> options,
IMediator mediator) : BaseDbContext(options, mediator)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema("catalog");
// Apply entity configurations from this assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
}
}The scaffold also generates a CatalogReadOnlyDbContext registered behind the IQueryDb abstraction for the read side. BaseDbContext takes the mediator so it can dispatch collected domain events after SaveChangesAsync.
Api
The Api layer defines the module's HTTP surface. It contains minimal API endpoint definitions and the module's route registration logic. The Api layer is thin -- it delegates all business logic to the Application layer through the mediator.
Contains:
- Endpoints -- Classes implementing the
IEndpointinterface, each defining a single HTTP endpoint. - Route groups -- Logical groupings of endpoints under a shared prefix (e.g.,
/catalog).
public sealed class CreateProductEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
// Mapped inside the module's route group, so "/" means POST /api/catalog
app.MapPost("/", async (
CreateProduct command,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(command, ct);
return result.Match(
id => Results.Created($"/api/catalog/{id}", id),
ApiResults.Problem);
})
.WithName("CreateProduct")
.Produces<Guid>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status500InternalServerError);
}
}Each IEndpoint class is discovered by reflection in CatalogEndpointRegistration.MapCatalogEndpoints(), which maps every endpoint onto the module's /api/catalog route group.
Integration
The Integration layer is the module's public contract. It contains only integration event record types -- no handlers, no logic, no services. Other modules reference this project to consume events published by this module.
Contains:
- Integration events -- Simple record types that describe cross-module occurrences.
public sealed record CatalogItemCreatedEvent(
Guid ProductId,
string Name,
decimal Price) : IIntegrationEvent;Integration events are contracts
Treat integration events like a public API. Changing an event's shape is a breaking change for all consuming modules. Add new properties as optional (nullable or with defaults) and avoid removing existing properties.
Module Registration
Each module has a registration class in its Infrastructure layer -- {Module}Module -- that implements IModuleRegistration. The interface uses static abstract members, so registration is pure compile-time wiring with no instance creation:
public interface IModuleRegistration
{
static abstract IServiceCollection ConfigureServices(IServiceCollection services, IConfiguration configuration);
static abstract IEndpointRouteBuilder ConfigureEndpoints(IEndpointRouteBuilder endpoints);
}The scaffolded CatalogModule registers the module's DbContexts and services and hands endpoint mapping to the Api layer:
public sealed class CatalogModule : IModuleRegistration
{
public static IServiceCollection ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<CatalogDbContext>((sp, options) =>
{
options.UseSqlServer(configuration.GetConnectionString("Default"));
// Wakes the outbox processor immediately when outbox rows commit
// (no-op until ModulusKit.Messaging is registered).
var outboxInterceptor = sp.GetService<OutboxNotifyingInterceptor>();
if (outboxInterceptor is not null)
options.AddInterceptors(outboxInterceptor);
});
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<CatalogDbContext>());
services.AddDbContext<CatalogReadOnlyDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("Default")));
services.AddScoped<IQueryDb>(sp => sp.GetRequiredService<CatalogReadOnlyDbContext>());
return services;
}
public static IEndpointRouteBuilder ConfigureEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapCatalogEndpoints();
return endpoints;
}
}The host does not enumerate modules by hand -- the module auto-discovery source generator scans the host's referenced assemblies for IModuleRegistration implementations and generates AddAllModules / MapAllModuleEndpoints, which the scaffolded Program.cs already calls:
builder.Services.AddModulusHandlers(); // source-generated handler registrations
builder.Services.AddAllModules(builder.Configuration); // source-generated: CatalogModule.ConfigureServices(...), ...
var app = builder.Build();
app.MapAllModuleEndpoints(); // source-generated: CatalogModule.ConfigureEndpoints(app), ...
app.Run();modulus add-module adds the host-to-module ProjectReference that makes the new module visible to the generator, so adding a module changes no host code. Handlers and validators are registered the same way -- discovered at compile time by the handler-registration generator behind AddModulusHandlers() (there is no runtime AddValidatorsFromAssembly scanning).
Module Isolation Rules
The following rules are enforced by architecture tests using NetArchTest. Every module's Tests.Architecture project verifies these constraints:
- Domain has no outward dependencies -- Domain must not reference Application, Infrastructure, Api, or any other module.
- Application does not reference Infrastructure -- Application defines interfaces; Infrastructure implements them.
- No cross-module references -- A module must not reference another module's Domain, Application, Infrastructure, or Api projects. Only Integration projects may be referenced.
- Integration contains only event types -- Integration projects must not contain handlers, services, or any logic.
- Domain events stay internal --
IDomainEventimplementations are internal to the module. Cross-module communication usesIIntegrationEventtypes from the Integration project.
Learn more
See Architecture Tests for the complete test suite and how to customize the rules for your project.
See Also
- Building Blocks -- Base classes shared across all modules
- Extracting to Microservices -- How to break a module out of the monolith
- Mediator -- CQRS dispatch and pipeline behaviors
- Messaging -- Integration events and transport configuration