A Modular Monolith that keeps its boundaries
Four isolated modules, public contracts between them, in-process events and one PostgreSQL schema each. Architecture tests fail the build the moment a module reaches into another module's internals. JWT auth, EF Core, OpenTelemetry, Aspire, Docker and 100+ tests are already wired.
users.*shipments.*carriers.*stocks.*
Owns the shipment lifecycle. Calls Stocks and Carriers only through their public contracts.
IStockModuleApiICarrierModuleApi- Modules
- 4
- Projects
- 25
- Tests
- 118
- Endpoints
- 20
- Framework
- .NET 10
Modules
Projects
Tests
Endpoints
Framework
One use case, five files you can read
Every feature is a vertical slice inside its module. Open the folder and the whole use case is in front of you, from the route to the event it raises.
public class CreateShipmentApiEndpoint : IApiEndpoint
{
public void MapEndpoint(WebApplication app)
{
app.MapPost(RouteConsts.BaseRoute, Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IValidator<CreateShipmentRequest> validator,
ICreateShipmentHandler handler,
CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var response = await handler.HandleAsync(request, cancellationToken);
if (response.IsError)
{
return response.Errors.ToProblem();
}
return Results.Ok(response.Value);
}
}internal sealed class CreateShipmentHandler(
ShipmentsDbContext context,
IStockModuleApi stockApi,
IEventPublisher eventPublisher,
ILogger<CreateShipmentHandler> logger)
: ICreateShipmentHandler
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentRequest request,
CancellationToken cancellationToken)
{
var shipmentExists = await context.Shipments
.AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);
if (shipmentExists)
{
return ShipmentErrors.AlreadyExists(request.OrderId);
}
var stockResponse = await stockApi.CheckStockAsync(
CreateCheckStockRequest(request), cancellationToken);
if (!stockResponse.IsSuccess)
{
return stockResponse.Errors;
}
var shipment = request.MapToShipment(new Faker().Commerce.Ean8());
await context.Shipments.AddAsync(shipment, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await eventPublisher.PublishAsync(
new ShipmentCreatedEvent(shipment), cancellationToken);
return shipment.MapToResponse();
}
}namespace Modules.Stocks.PublicApi;
public interface IStockModuleApi
{
Task<Result<Success>> CheckStockAsync(
CheckStockRequest request,
CancellationToken cancellationToken);
Task<Result<Success>> DecreaseStockAsync(
DecreaseStockRequest request,
CancellationToken cancellationToken);
}
public record CheckStockRequest(List<ProductStock> Products);
public record DecreaseStockRequest(List<ProductStock> Products);
public record ProductStock(string ProductName, int Quantity);public sealed record ShipmentCreatedEvent(Shipment Shipment) : IEvent;
public sealed class UpdateStockEventHandler(
IStockModuleApi stockApi,
ILogger<UpdateStockEventHandler> logger)
: IEventHandler<ShipmentCreatedEvent>
{
public async Task HandleAsync(
ShipmentCreatedEvent @event,
CancellationToken cancellationToken)
{
var updateRequest = CreateDecreaseStockRequest(@event.Shipment);
var response = await stockApi.DecreaseStockAsync(updateRequest, cancellationToken);
if (!response.IsSuccess)
{
logger.LogError("Failed to update stock for order {OrderId}: {@Errors}",
@event.Shipment.OrderId, response.Errors);
throw new Exception($"Failed to update stock: {response.Errors}");
}
}
}public class CreateShipmentRequestValidator : AbstractValidator<CreateShipmentRequest>
{
public CreateShipmentRequestValidator()
{
RuleFor(shipment => shipment.OrderId).NotEmpty();
RuleFor(shipment => shipment.Carrier).NotEmpty();
RuleFor(shipment => shipment.ReceiverEmail).NotEmpty();
RuleFor(shipment => shipment.Items).NotEmpty();
RuleFor(shipment => shipment.Address)
.Cascade(CascadeMode.Stop)
.NotNull()
.WithMessage("Address must not be null")
.SetValidator(new AddressValidator());
}
}The route
- A Minimal API endpoint that implements IApiEndpoint and is discovered by assembly scanning at startup.
- Validation runs first and returns a ValidationProblem, so the handler never sees a bad request.
- Errors become ProblemDetails through ToProblem(). No exception is thrown for business failures.
- No controllers, no [FromServices], no MediatR sitting between the route and the logic.
The use case
- DbContext is injected straight into the handler. No repository and no unit of work in between.
- Cross-module work goes through IStockModuleApi, the Stocks module's public contract.
- Returns Result<ShipmentResponse>, so success and failure are both values the caller must handle.
- Publishing ShipmentCreatedEvent decouples the write path from everything that reacts to it.
The only way in
- Each module that others call ships a separate PublicApi project holding just the interface and its records.
- Shipments references Modules.Stocks.PublicApi and nothing else from the Stocks module.
- CheckStock and DecreaseStock have handlers but no HTTP endpoint. They exist for other modules only.
- Swapping a module for a real service later means reimplementing this interface, not rewriting callers.
What happens next
- ShipmentCreatedEvent is a record implementing IEvent. No message broker, no outbox, no configuration.
- Two handlers react to it: one decreases stock, the other registers the shipment with the carrier.
- IEventPublisher resolves handlers from the container and runs them, so adding a reaction is adding a class.
- Failures are logged with structured Serilog properties and surface in Seq.
The rules
- FluentValidation validators live next to the request they validate, not in a shared validation project.
- Nested value objects get their own validator and are composed with SetValidator.
- Validators are registered by assembly scanning alongside endpoints and handlers.
- Thirteen validators ship with the template as working examples.
How modules talk, and how they can't
A modular monolith only stays modular if something stops the shortcuts. Here are the three things that connect modules, and the test that fails when anything else does.
Public contracts
A module that others need exposes one interface in its own PublicApi project. Internals stay invisible to every caller.
IStockModuleApiICarrierModuleApiIn-process events
Side effects travel as events instead of direct calls. Creating a shipment raises one event and two handlers pick it up.
IEventIEventPublisherIEventHandler<T>A schema per module
Each module owns its DbContext, its PostgreSQL schema and its own migration history table. No shared tables, no cross-module joins.
users.*shipments.*carriers.*stocks.*[Fact]
public void ShipmentsModule_ShouldOnlyReference_PublicApiProjects()
{
var shipmentsAssemblies = GetShipmentsModuleAssemblies();
var stocksInternalResult = Types.InAssemblies(shipmentsAssemblies)
.Should()
.NotHaveDependencyOnAny(
StocksDomainNamespace,
StocksInfraNamespace,
StocksFeaturesNamespace)
.GetResult();
Assert.True(stocksInternalResult.IsSuccessful,
$"Shipments module should not reference internal Stocks projects: " +
string.Join(", ", stocksInternalResult.FailingTypeNames ?? []));
}This is the part most modular monoliths skip. Five NetArchTest rules run with the rest of the suite: every module is checked for dependencies on other modules, and Shipments is checked specifically for reaching past a PublicApi project. The day someone adds the wrong project reference, the build tells them, not a code reviewer six weeks later.
The boring parts, already decided
Everything a new .NET service needs before it can do anything useful, configured and running.
Minimal APIs, no MediatR
Endpoints, handlers and validators are found by assembly scanning. Handlers are plain classes behind a small interface, so there is no dispatcher to debug.
EF Core and PostgreSQL
A DbContext per module, migrations, entity configurations and an auditing interceptor. No repository ceremony.
Auth that survives production
ASP.NET Identity with JWT, refresh tokens and revocation, plus policy-based authorization defined per module.
Result pattern and validation
A Result type with typed errors, FluentValidation per use case and a global exception handler returning ProblemDetails.
Serilog, Seq and OpenTelemetry
Structured logs land in Seq, traces and metrics in Jaeger. Every module has its own activity source and tracing middleware.
Aspire and Docker Compose
Run it with .NET Aspire for local development or bring the whole stack up with Docker Compose. Both paths are configured.
Tests you can run today
100+ tests: unit tests, integration tests on Testcontainers with Respawn cleanup, and NetArchTest rules for module isolation.
Quality gates in the build
Meziantou, Sonar and Roslynator analyzers with warnings as errors, central package management and .editorconfig.
Swagger and 21 ready requests
Swagger UI in Development plus 21 .http files with an environment file, so you can call every endpoint from your IDE.
Four modules, one deployment, one database
One .NET application in Docker, four modules that never touch each other's tables, and one PostgreSQL instance holding a schema per module.

A folder layout that explains itself
Modules on the outside, shared plumbing in Common, one host that composes them. You can point at any folder and say what it owns.
- Each module keeps its Domain, Infrastructure and Features projects, and registers itself with one AddXModule call.
- Only Carriers and Stocks ship a PublicApi project, because only they are called by another module.
- Common holds the Result type, the event publisher, endpoint discovery and the global exception handler.
- Adding a module means copying a folder, adding four project references and one registration line.
- Outgrown the monolith? A module already has its own contract, schema and migrations, so extracting it is a small move, not a rewrite.
Shipments/ # one folder per moduleModules.Shipments.Domain/ # entities, value objectsModules.Shipments.Infrastructure/ # DbContext, migrationsModules.Shipments.Features/ # vertical slicesModules.Shipments.Tests.Unit/Modules.Shipments.Tests.Integration/ # TestcontainersStocks/Modules.Stocks.PublicApi/ # the only entry pointCarriers/Users/Common/ # Result, events, error handlingModules.Common.Tests.Architecture/ # boundary rulesModularMonolith.Host/ # Program.cs, seeding, .http filesModularMonolithTemplate.AppHost/ # .NET Aspiredocker-compose.yml # api, postgres, seq, jaeger
Free Project Template, .NET 10.
Clone it, run it, and start on your own domain instead of your own plumbing.
Running before your coffee gets cold
Three steps from the email in your inbox to a request hitting a real database.
- 1
Drop your email
The template arrives in 2-3 minutes as ready-to-run solution.
- 2
Start the stack
Aspire provisions PostgreSQL for you, or Docker Compose brings up the API, PostgreSQL, Seq and Jaeger together.
docker compose up -d
- 3
Add your own feature
Copy a feature folder and rename it. Add your code. Run the solution. Swagger is on localhost:5000, Seq on 8081, Jaeger on 16686. Open a .http file, fire a request and you're ready.
Which one should you start from?
Both templates build the same shipping application with the same stack. They differ in how hard the boundaries are.
Projects
- Modular Monolith
- 25 projects, three per module
- .NET Backend
- 14 projects, one Core
Boundaries
- Modular Monolith
- Separate projects plus PublicApi contracts
- .NET Backend
- Namespaces plus layer architecture tests
Cross-domain calls
- Modular Monolith
- Contract interfaces in dedicated PublicApi projects
- .NET Backend
- Contract interfaces with tracing decorators inside Core
Best for
- Modular Monolith
- Teams that may extract a module into a service later
- .NET Backend
- Teams that want the smallest solution to navigate
Stack
- Modular Monolith
- .NET 10, EF Core, Postgres, JWT, OTel, Aspire
- .NET Backend
- .NET 10, EF Core, Postgres, JWT, OTel, Aspire
Want the same stack with fewer projects and every feature in one folder? Start from the Vertical Slice template instead.
See the .NET Backend templateQuestions people ask
Is a modular monolith better than microservices?
It is better for most teams starting out. You get the module boundaries, the separate schemas and the contracts, without the network calls, the distributed transactions and the deployment pipeline that microservices demand. When one module genuinely needs to scale on its own, it already has a contract and its own data, so extracting it is a small move rather than a rewrite.
Which .NET version does the template target?
.NET 10. The patterns work on .NET 8 and 9 as well, though you will need to change the target framework and step back a few package versions.
Why is there no MediatR?
Because a handler is just a class. The template registers handlers by assembly scanning and the endpoint injects the interface it needs, so a stack trace goes straight from the route to your code. There is no pipeline to configure, no behaviour ordering to reason about and one less licensed dependency.
Why are there no repositories?
DbContext is already a unit of work and DbSet is already a repository. Wrapping them again costs you LINQ, change tracking and half of EF Core's query capability in exchange for an abstraction most teams never swap out. Integration tests run against a real PostgreSQL container instead.
Why are there no service classes?
A ShipmentService starts with one method and ends up with fifteen, and every caller drags in all of them. Here each use case is its own handler class: the Shipments domain has eight slices, from CreateShipment to CancelShipment. The constructor injects only what that one use case needs. If you need to write unit tests, you will need only 2 mocked dependencies instead of 10.
How do modules communicate?
Two ways. A synchronous call goes through the target module's public contract interface, which lives in its own PublicApi project. Anything that can happen after the fact travels as an in-process domain event, so the module raising it does not know who reacts.
How do I add my own module?
Copy one of the existing module folders, rename the projects, add its registration call in the host and give it a PostgreSQL schema. If other modules need to call it, add a PublicApi project with one interface. The architecture tests will tell you if you wire anything the wrong way.
Is it really free?
Yes, no credit card required. You will also get my .NET newsletter, and you can unsubscribe whenever you want.
Should I use this or the Vertical Slice template?
Pick this one if you want hard boundaries enforced by separate projects and expect to extract a service one day. Pick the .NET Backend template if you want the same stack in fourteen projects with every feature in one folder.
Start on your domain, not your plumbing
The full Modular Monolith template, free, in your inbox. Clone it, run it, and spend day one on the part that is actually yours.
Free Project Template. No credit card.