Skip to content

// Architecture Styles · 03

Modular Monolith

Big ball of mudleads toModules with hard borders

One deployable, split inside into modules that keep their distance.

How it fits together

Modular Monolith structureCatalog moduleCatalogApi (public)internals hiddencatalog schemaShop.Hostone deployableone processOrdering moduleOrderingApi (public)internals hiddenordering schemaBilling moduleBillingApi (public)internals hiddenbilling schemaCatalogApiOrderPlacedown contextown contextown contextno cross-joins
wraps / holdsuses / callspublishes / notifiesfails / error path
  • Shop.Host wraps / holds Catalog module
  • Shop.Host wraps / holds Ordering module
  • Shop.Host wraps / holds Billing module
  • Ordering module uses / calls Catalog module (CatalogApi)
  • Ordering module publishes / notifies Billing module (OrderPlaced)
  • Catalog module uses / calls catalog schema (own context)
  • Ordering module uses / calls ordering schema (own context)
  • Billing module uses / calls billing schema (own context)
  • Ordering module fails / error path billing schema (no cross-joins)

Spot it

  • Every feature touches every folder
  • Teams step on each other in one codebase
  • Microservices tempt, but ops is not ready

It costs

Boundaries are held by discipline and tests, not the network, so they can erode.

In code

// Shop.Catalog.Contracts: the only Catalog project other modules may referencepublic interface ICatalogApi{    Task<ProductPrice?> GetPriceAsync(Guid productId, CancellationToken ct);}​public sealed record ProductPrice(Guid ProductId, decimal Amount, string Currency);​// Shop.Cataloginternal sealed class CatalogApi(CatalogDbContext db) : ICatalogApi{    public Task<ProductPrice?> GetPriceAsync(Guid productId, CancellationToken ct) =>        db.Products            .Where(p => p.Id == productId)            .Select(p => new ProductPrice(p.Id, p.Price, p.Currency))            .SingleOrDefaultAsync(ct);}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Code is grouped by business capability, not technical layer. Modules talk only through explicit contracts or in-process events, and each owns its data, ideally in its own schema with no cross-module joins.

It keeps one deploy, one process and real transactions, which removes most of the operational cost of microservices. It does not give independent scaling or deploys per module, and a shared database tempts people to reach across boundaries.

Modules usually map to bounded contexts. Done well, a module can later be extracted into a service with little rework, which makes it a sensible default before you know where the seams are.

Ask for it

  • module boundary
  • public module API
  • schema per module
  • in-process events
  • architecture tests

Reorganise this monolith into Ordering, Billing and Catalog modules. Each gets its own project, a small public contract, and its own data context and database schema. Add architecture tests that fail when one module references another's internals.