A commerce backend built as a modular monolith in .NET: one deployable ASP.NET Core host, three business modules (Products, Baskets, Orders), each with its own onion layers, its own PostgreSQL schema and a small public contract. Inside every module, the application layer is organised as vertical slices: one folder per use case holding the command or query, its handler, its validator and its HTTP endpoint.
- Overview
- Architecture
- Module rules
- Project structure
- Getting started
- API
- Testing
- Design decisions and trade-offs
- Roadmap
- License
| Module | Responsibility | Publishes | Consumes |
|---|---|---|---|
| Products | Catalog: products, SKUs, list prices | IProductsModuleApi (in-process query API) |
– |
| Baskets | One shopping basket per customer, checkout | BasketCheckedOutIntegrationEvent |
IProductsModuleApi, OrderPlacedIntegrationEvent |
| Orders | Places orders from checked-out baskets, order history | OrderPlacedIntegrationEvent |
IProductsModuleApi, BasketCheckedOutIntegrationEvent |
What the solution demonstrates:
- Strict module boundaries. A module references another module's
*.Contractsproject only, never its domain, application or infrastructure. Architecture tests enforce this on both types and assembly references. - Data ownership. Each module has its own
DbContext, its own PostgreSQL schema (products,baskets,orders) and its own EF Core migrations history. No module reads another module's tables. - Two styles of cross-module communication.
- Synchronous queries through a contract interface (
IProductsModuleApi). Baskets uses it to validate products; Orders uses it to price order lines. - Asynchronous integration events through a transactional outbox and an in-process event bus (
BasketCheckedOut→ Orders,OrderPlaced→ Baskets).
- Synchronous queries through a contract interface (
- A custom CQRS dispatcher with a pipeline of behaviors (logging, FluentValidation, timing), with no MediatR dependency.
- Production concerns. RFC 9457 ProblemDetails for every error, per-module database health checks, OpenAPI with a Scalar UI, structured logging with Serilog, optimistic concurrency, idempotent consumers.
flowchart TB
Host["Commerce.API (host)<br/>AddXModule() · MapXEndpoints()<br/>ProblemDetails · health checks · OpenAPI"]
subgraph Products["Products module · schema products"]
PI[Products.Infrastructure] --> PA[Products.Application] --> PD[Products.Domain]
PA --> PC[[Products.Contracts]]
end
subgraph Baskets["Baskets module · schema baskets"]
BI[Baskets.Infrastructure] --> BA[Baskets.Application] --> BD[Baskets.Domain]
BA --> BC[[Baskets.Contracts]]
end
subgraph Orders["Orders module · schema orders"]
OI[Orders.Infrastructure] --> OA[Orders.Application] --> OD[Orders.Domain]
OA --> OC[[Orders.Contracts]]
end
Host --> PI & BI & OI
BA -. "IProductsModuleApi" .-> PC
OA -. "IProductsModuleApi" .-> PC
OA -. "BasketCheckedOut" .-> BC
BA -. "OrderPlaced" .-> OC
BB["BuildingBlocks<br/>Domain · Application · Infrastructure · Contracts"]
Products & Baskets & Orders --> BB
Dashed arrows are the only allowed cross-module dependencies: they point at a Contracts project. Each module's Infrastructure project is also its composition root, which is why the host references it and nothing else.
sequenceDiagram
autonumber
actor Client
participant B as Baskets
participant BDB as baskets schema
participant Bus as Outbox processor + in-process bus
participant O as Orders
participant P as Products (IProductsModuleApi)
participant ODB as orders schema
Client->>B: POST /baskets/{customerId}/checkout
B->>BDB: basket.Status = CheckedOut<br/>+ outbox row BasketCheckedOut (one transaction)
B-->>Client: 202 Accepted, Location: /orders?customerId=...
Bus->>BDB: claim pending rows (FOR UPDATE SKIP LOCKED)
Bus->>O: BasketCheckedOutIntegrationEvent
O->>O: dispatch PlaceOrderCommand (validation pipeline)
O->>ODB: order for this basket already exists? (idempotency)
O->>P: GetProductsAsync(productIds)
P-->>O: current names and prices
O->>ODB: insert order + outbox row OrderPlaced (one transaction)
Bus->>ODB: claim pending rows
Bus->>B: OrderPlacedIntegrationEvent
B->>BDB: delete the checked-out basket
Client->>O: GET /orders?customerId=...
O-->>Client: 200 OK, order priced from the catalog
Every endpoint sends its command or query through IDispatcher. The dispatcher resolves the handler for the request's concrete type and wraps it in the registered behaviors:
LoggingBehavior → ValidationBehavior (FluentValidation) → PerformanceBehavior → handler
Handlers return Result<T>. ResultExtensions.ToIResult() maps it to 200/201/202, or to a ProblemDetails response for 400/404/409/422. Exceptions that escape (domain rule violations, concurrency conflicts, malformed JSON) are mapped by the host's GlobalExceptionHandler.
These rules are enforced by tests/Commerce.ArchitectureTests:
- Contracts only across modules. Types in module A may not depend on
B.Domain,B.ApplicationorB.Infrastructure, and module A's assemblies may not reference them. - Contracts are lightweight. A
*.Contractsproject may not depend on any module's internals, on EF Core, on ASP.NET Core or on FluentValidation. It holds only integration events (records of primitives) and module API interfaces. - Onion layering inside a module. The domain depends on nothing but
BuildingBlocks.Domain. The application layer does not depend on infrastructure, EF Core or Npgsql. - Encapsulated aggregates. Domain entities expose no public setters; state changes go through methods that guard invariants (
Basket.Checkout(),Product.ChangePrice()). - Slice conventions. Handlers, validators, endpoints and integration event handlers are
sealed, and every handler sits in the feature folder of the command or query it handles. - Building blocks know no modules.
ModulerMonolith.sln
Directory.Build.props # net10.0, nullable, warnings as errors
Directory.Packages.props # central package management
docker-compose.yml # PostgreSQL for local development
src/
├── API/Commerce.API/ # host: composes modules, error handling, health, OpenAPI
├── BuildingBlocks/
│ ├── Domain/ # Entity<T>, DomainException, repository abstractions
│ ├── Application/ # IDispatcher, pipeline behaviors, Result<T>, IEndpoint,
│ │ # IEventBus, IOutbox, IIntegrationEventHandler<T>
│ ├── Infrastructure/ # EF Core repositories, outbox + processor, in-process bus,
│ │ # audit interceptor, per-module DbContext registration
│ └── Contracts/ # IIntegrationEvent / IntegrationEvent
└── Modules/
├── Products/
│ ├── Core/
│ │ ├── Domain/Products.Domain/ # Product aggregate
│ │ └── Application/Products.Application/
│ │ ├── Products/
│ │ │ ├── CreateProduct/ # command, handler, validator, endpoint, response
│ │ │ ├── GetProductById/
│ │ │ ├── ListProducts/
│ │ │ └── ChangeProductPrice/
│ │ └── ModuleApi/ProductsModuleApi.cs # implements IProductsModuleApi
│ ├── Infrastructure/Products.Infrastructure/ # ProductsDbContext, migrations, ProductsModule
│ └── Contracts/Products.Contracts/ # IProductsModuleApi, ProductSnapshot
├── Baskets/
│ ├── Core/Domain/Baskets.Domain/ # Basket, BasketItem
│ ├── Core/Application/Baskets.Application/
│ │ ├── Baskets/{AddItemToBasket, RemoveItemFromBasket, GetBasket, CheckoutBasket}/
│ │ └── IntegrationEvents/OrderPlacedIntegrationEventHandler.cs
│ ├── Infrastructure/Baskets.Infrastructure/ # BasketsDbContext (+ outbox), migrations
│ └── Contracts/Baskets.Contracts/ # BasketCheckedOutIntegrationEvent
└── Orders/
├── Core/Domain/Orders.Domain/ # Order, OrderLine
├── Core/Application/Orders.Application/
│ ├── Orders/{PlaceOrder, GetOrderById, ListCustomerOrders}/
│ └── IntegrationEvents/BasketCheckedOutIntegrationEventHandler.cs
├── Infrastructure/Orders.Infrastructure/ # OrdersDbContext (+ outbox), migrations
└── Contracts/Orders.Contracts/ # OrderPlacedIntegrationEvent
tests/
├── Commerce.UnitTests/ # aggregates, handlers, dispatcher pipeline
├── Commerce.ArchitectureTests/ # module isolation and layering (NetArchTest)
└── Commerce.IntegrationTests/ # real host + PostgreSQL via Testcontainers
- .NET 10 SDK
- Docker (for PostgreSQL and for the integration tests)
git clone https://github.com/gokhanertogan/ModulerMonolith.git
cd ModulerMonolith
# 1. Start PostgreSQL (set POSTGRES_PORT=55432 if 5432 is already taken)
docker compose up -d --wait
# 2. Run the API. In Development it applies every module's migrations on startup.
dotnet run --project src/API/Commerce.API --launch-profile httpThe API listens on http://localhost:5062:
- API reference (Scalar): http://localhost:5062/scalar
- OpenAPI document: http://localhost:5062/openapi/v1.json
- Health: http://localhost:5062/health
If PostgreSQL runs on another port, override the connection string without editing files:
ConnectionStrings__Database="Host=localhost;Port=55432;Database=commerce;Username=postgres;Password=Your_password123" \
dotnet run --project src/API/Commerce.API --launch-profile http
Your_password123is a local development placeholder used bydocker-compose.ymlandappsettings.Development.json. Outside Development, supplyConnectionStrings__Databasethrough environment variables or a secret store;appsettings.jsondeliberately ships an empty connection string.
Stop and remove the database:
docker compose down -vEach module has its own migrations in Infrastructure/<Module>.Infrastructure/Persistence/Migrations. The dotnet-ef tool is pinned in .config/dotnet-tools.json:
dotnet tool restore
dotnet ef migrations add <Name> \
-p src/Modules/Orders/Infrastructure/Orders.Infrastructure \
-s src/Modules/Orders/Infrastructure/Orders.Infrastructure \
-o Persistence/MigrationsA design-time factory per module means the host does not need to start to create a migration.
| Method | Route | Description |
|---|---|---|
POST |
/products |
Create a product (201, Location header) |
GET |
/products?pageIndex=0&pageSize=20 |
List the catalog |
GET |
/products/{productId} |
Get a product |
PUT |
/products/{productId}/price |
Change the list price |
POST |
/baskets/{customerId}/items |
Add a product to the customer's basket |
DELETE |
/baskets/{customerId}/items/{productId} |
Remove a product line |
GET |
/baskets/{customerId} |
Get the basket |
POST |
/baskets/{customerId}/checkout |
Check out (202 Accepted); the order is placed asynchronously |
GET |
/orders?customerId={customerId} |
List a customer's orders |
GET |
/orders/{orderId} |
Get an order |
GET |
/health |
Health of every module's database |
src/API/Commerce.API/Commerce.API.http contains the whole flow for the VS Code, Rider or Visual Studio HTTP client.
API=http://localhost:5062
CUSTOMER=3f2b8e8a-0c1d-4c56-9a43-7d9f5e2a1b10
# Create a product
curl -s -X POST $API/products -H 'Content-Type: application/json' \
-d '{"name":"Mechanical Keyboard","description":"75% layout","sku":"KB-75-HS","price":129.90}'
# {"productId":"01a16763-4165-7c5c-abfe-de513b4c89fa","name":"Mechanical Keyboard"}
PRODUCT=01a16763-4165-7c5c-abfe-de513b4c89fa # use the id returned above
# Put two of them in the basket
curl -s -X POST $API/baskets/$CUSTOMER/items -H 'Content-Type: application/json' \
-d "{\"productId\":\"$PRODUCT\",\"quantity\":2}"
# The catalog price changes before checkout
curl -s -X PUT $API/products/$PRODUCT/price -H 'Content-Type: application/json' -d '{"price":119.90}'
# Check out: 202 Accepted
curl -s -i -X POST $API/baskets/$CUSTOMER/checkout
# A moment later the order exists, priced from the catalog (2 x 119.90), and the basket is gone
curl -s "$API/orders?customerId=$CUSTOMER"
curl -s -o /dev/null -w '%{http_code}\n' $API/baskets/$CUSTOMER # 404Every error is an RFC 9457 ProblemDetails document with a traceId. Validation errors also list the individual messages:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "'Name' must not be empty. 'Sku' must not be empty. 'Price' must be greater than '0'.",
"errors": ["'Name' must not be empty.", "'Sku' must not be empty.", "'Price' must be greater than '0'."],
"traceId": "0HNPGPI2OAMIR:00000001"
}| Status | When |
|---|---|
400 |
Validation failure or malformed request body |
404 |
Unknown product, basket or order |
409 |
Duplicate SKU, basket already being checked out, concurrent update |
422 |
Domain rule violated (for example, checking out an empty basket) |
dotnet test ModulerMonolith.sln| Project | What it covers |
|---|---|
Commerce.UnitTests |
Aggregate invariants (Product, Basket, Order); command and integration event handlers with NSubstitute; dispatcher and validation pipeline behavior |
Commerce.ArchitectureTests |
The module rules, written with NetArchTest |
Commerce.IntegrationTests |
The real host (WebApplicationFactory<Program>) against PostgreSQL 17 in Testcontainers: the full checkout → order → basket cleanup flow, catalog re-pricing, idempotent redelivery of BasketCheckedOut, ProblemDetails for 400/404/422, health checks |
The integration tests need a running Docker daemon. CI (.github/workflows/ci.yml) runs build and all tests on every push and pull request.
- Modular monolith first. One process and one deployment keep operations simple, while the module boundaries (separate schemas, contracts, integration events) keep the option of extracting a module into a service later. Extracting a module would mostly mean replacing
InProcessEventBuswith a broker andIProductsModuleApiwith an HTTP/gRPC client. - One database, one schema per module. Simpler to run than several databases, and still enforces ownership: every
DbContextmaps only its own schema. Splitting into separate databases later is a connection-string change per module. - Transactional outbox instead of publishing directly. The integration event is written in the same transaction as the aggregate change, so a crash cannot leave a checked-out basket without its event. The processor claims rows with
FOR UPDATE SKIP LOCKED, so several host instances can run side by side. Delivery is at-least-once: consumers are idempotent (Order.BasketIdis unique, and the handler returns the existing order on redelivery). - Orders re-prices from the catalog. The basket's price is a display snapshot only; the event carries product ids and quantities, and Orders asks Products for the authoritative price. Prices stay owned by one module.
- Synchronous module API for reads. Reading catalog data through
IProductsModuleApiis simple and consistent. The cost is runtime coupling: if Products is unavailable, placing an order fails and the outbox retries. A local read model fed byProductPriceChangedevents would remove that coupling at the cost of eventual consistency. - Asynchronous checkout. Checkout answers
202 Acceptedand the order shows up shortly afterwards. This mirrors how the flow would behave across services and keeps the basket transaction small. - Custom dispatcher instead of MediatR. Small, explicit and free of licensing concerns; the cost is a little reflection, cached per request type.
- Endpoints live in the application layer, inside their slice. This keeps a use case in one folder, which is the point of vertical slices. The trade-off is that
*.Applicationreferences ASP.NET Core routing abstractions. - Generic read/write repositories. They keep handlers testable with plain mocks. For complex queries, a module could add a dedicated query service without changing the rest.
- Optimistic concurrency on baskets through PostgreSQL's
xminsystem column: two concurrent checkouts cannot both succeed. - .NET 10 (LTS). The project moved from
net9.0tonet10.0because .NET 9 (STS) reaches end of support in November 2026.
-
ProductPriceChangedevent and a local price read model in Orders (removes the synchronous dependency) - Inbox table per consumer for exactly-once processing per handler, and dead-letter visibility for messages that exceed
Outbox:MaxAttempts - Stock reservation in Products, with compensation when an order cannot be fulfilled
- Order lifecycle (paid, shipped, cancelled) and a basket "restore" path when order placement fails
- Authentication; take the customer id from the token instead of the route
- Product variants (size, colour), which were started in an earlier draft of the Products module
- OpenTelemetry traces across modules, following each integration event
- Dockerfile for the API and a full
docker compose upexperience
Released under the MIT License.