Skip to content

About

Modular monolith in .NET 10: Products, Baskets and Orders modules with own schemas, contracts-only communication, outbox and architecture tests

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ModulerMonolith

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.

CI .NET PostgreSQL EF Core License

Contents

Overview

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 *.Contracts project 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).
  • 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.

Architecture

Modules and dependencies

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
Loading

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.

Checkout flow

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
Loading

Request pipeline inside a module

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.

Module rules

These rules are enforced by tests/Commerce.ArchitectureTests:

  1. Contracts only across modules. Types in module A may not depend on B.Domain, B.Application or B.Infrastructure, and module A's assemblies may not reference them.
  2. Contracts are lightweight. A *.Contracts project 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.
  3. 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.
  4. Encapsulated aggregates. Domain entities expose no public setters; state changes go through methods that guard invariants (Basket.Checkout(), Product.ChangePrice()).
  5. 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.
  6. Building blocks know no modules.

Project structure

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

Getting started

Prerequisites

  • .NET 10 SDK
  • Docker (for PostgreSQL and for the integration tests)

Run locally

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 http

The API listens on http://localhost:5062:

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_password123 is a local development placeholder used by docker-compose.yml and appsettings.Development.json. Outside Development, supply ConnectionStrings__Database through environment variables or a secret store; appsettings.json deliberately ships an empty connection string.

Stop and remove the database:

docker compose down -v

Migrations

Each 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/Migrations

A design-time factory per module means the host does not need to start to create a migration.

API

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.

Walkthrough with curl

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

Errors

Every 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)

Testing

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.

Design decisions and trade-offs

  • 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 InProcessEventBus with a broker and IProductsModuleApi with an HTTP/gRPC client.
  • One database, one schema per module. Simpler to run than several databases, and still enforces ownership: every DbContext maps 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.BasketId is 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 IProductsModuleApi is 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 by ProductPriceChanged events would remove that coupling at the cost of eventual consistency.
  • Asynchronous checkout. Checkout answers 202 Accepted and 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 *.Application references 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 xmin system column: two concurrent checkouts cannot both succeed.
  • .NET 10 (LTS). The project moved from net9.0 to net10.0 because .NET 9 (STS) reaches end of support in November 2026.

Roadmap

  • ProductPriceChanged event 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 up experience

License

Released under the MIT License.

About

Modular monolith in .NET 10: Products, Baskets and Orders modules with own schemas, contracts-only communication, outbox and architecture tests

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages