Architecture: the modular monolith

Layered monolith, modular monolith, microservices or serverless - how each is built, which one {{brand}} chose and why, and the techniques that make it work: modules with a schema each, a shared kernel with seams, dependency injection and its lifetimes, vertical slices, CQRS with a small mediator, module switches, architecture tests and the path to move a module out later.

In short

{{brand}} is a modular monolith. The backend is built and deployed as one .NET application, but inside it fifteen product modules are walled off from each other as if they were separate services. Each module owns its code and its own database schema, and depends on little more than a small shared kernel of contracts. Modules talk through those contracts - seams - connected by dependency injection. Each product can also be switched on or off. Inside a module, code is cut into vertical slices, one folder per use case, each reached through a small mediator.

After this lesson you can 1. Compare a layered monolith, a modular monolith, microservices and serverless, and defend a choice. 2. Draw a module with its schema, kernel and seams. 3. Explain DI lifetimes, vertical slices, CQRS and the mediator. 4. Enforce the rules with tests and plan how a module could move out.

Words you need

Four ways to shape a backend

Every backend answers three questions: how many things are deployed, who owns the data, and how the parts talk to each other. The four common shapes answer them differently, and the last column is the one interviews dig into: what actually stops one part from reaching into another.

Layers, clean architecture and modules are not rivals Layered, hexagonal and clean architecture say how to arrange code inside one unit: keep business rules free of web and database code. A modular monolith says where the units are. You can, and usually do, use both: modules outside, a clean arrangement inside each.

Finding the walls

Knowing that modules need walls is the easy part. The hard part is where to put them. Domain-driven design calls a well-placed wall a bounded context: an area in which every word has one meaning. A wall in the wrong place costs more than no wall.

Too small is as bad as too big A module that cannot change without three others is a layer in disguise. If one small feature always touches several modules, merge them or move the wall.

How a modular monolith works, step by step

A modular monolith is not a folder convention. It is a set of mechanisms that start with the application and run on every request. This is the path in {{brand}}, and in most well-built modular monoliths.

The options in the market

What {{brand}} chose, and why

{{brand}} is built by a small team, ships a family of products that work together, and is also sold as a Private edition that customers install in their own network. Those three facts pointed to a modular monolith. The choice is written down as an architecture decision record - a short page saying what was decided and why - so later work cannot quietly undo it.

The trade-off accepted Everything ships together and shares one process. {{brand}} accepts that in exchange for simple operations, and keeps the door open: the walls inside are built as if the modules were already services.

Say it in one sentence We built a modular monolith: one deployable with real module walls - own project, own schema, own switch, contracts in a shared kernel - so we get simple operations today and a clean path to move a module out if it ever needs its own scale.

Modules, and a schema for each

A module is the unit of ownership: one product area, its code, its data and its routes. The rule that makes it real is simple to say - nothing outside a module touches its tables or its classes - and each part below is one way that rule is enforced.

Modules refer to each other's records by id only. A note linked to a card stores the card's kind and id - never a foreign key into another schema. To show that card, the Notes module asks the card's module through a contract, which also checks that the reader may see it.

The schema name is set once, so the context cannot even name another module's table.

A bonus of clear data ownership Because each module's data sits behind its own context, a context can be pointed at a different database later. Some products use this to offer customers a database of their own.

The shared kernel and seams

The shared kernel is the one project every module may use. It holds contracts (interfaces and the small records they pass), the error types, the mediator and helpers - and it references no module. A seam is one of those contracts: the module that has the data implements it, and any other code asks dependency injection for it. Neither side knows the other exists.

Events come in two kinds. An inline event runs its handlers inside the publisher's request, so a handler can veto the work - for example, refusing to remove the last owner of a group. A fact about saved rows is written to an outbox table in the same transaction and delivered at least once by a worker. The realtime lesson (/help/technology/realtime-events) goes deeper.

This is dependency inversion No module depends on another module; both depend on an abstraction in the kernel. It is the D of SOLID - depend on abstractions, not on concrete classes - applied between modules instead of between classes.

Dependency injection and lifetimes

A class should say what it needs, not build it. With constructor injection a handler lists a database context and an access checker as parameters; the DI container - in .NET, the built-in one from Microsoft.Extensions.DependencyInjection - creates them and passes them in. All the wiring lives in one place, the composition root, and a test can pass in fakes instead.

Why bother? Three reasons. A class stops caring how its helpers are built, which is loose coupling. A test passes a fake instead of the real database or clock. And changing a helper means editing the one registration, not every class that uses it.

The captive dependency A singleton that takes a scoped service in its constructor keeps that one object forever - for example a database context shared by every request and thread, which EF Core does not support. ASP.NET Core validates scopes in the Development environment and fails at start-up; elsewhere it does not check, so run in Development and let a singleton take a scope factory instead.

Vertical slices: cut by use case, not by layer

A layered app groups code by technical role: all controllers here, all services there. One feature is then spread over four folders, and services grow into thousand-line classes. A vertical slice groups code by use case: one folder holds the endpoint, the request, the command and the handler of one action. A change to a feature touches one folder.

Slices still have layers Inside one slice there is still a web part (the endpoint), a logic part (the handler) and a data part (the context). What changes is the axis of grouping: layers live inside a feature instead of features living inside layers.

CQRS and the mediator pattern

CQRS - Command Query Responsibility Segregation - says a command changes state and a query only reads it, and they are handled separately. That idea can be taken a little way or very far. Knowing the levels is the difference between a good and a great interview answer.

CQRS is not event sourcing CQRS does not need two databases or an event log. Most teams need only level 1: separate handlers on the same model.

The mediator pattern comes from the classic design-patterns book: objects talk through one go-between instead of to each other. In .NET it usually means an in-process dispatcher: the endpoint hands a message to the mediator, and the mediator finds the one handler for that message type. The endpoint then never changes when its handler's dependencies do.

This version uses reflection and dynamic calls on every send. A faster version builds one small generic dispatcher per message type the first time it is needed and caches it, so later sends skip reflection. Handlers are usually scoped, because they use the request's database context.

Without pipeline behaviours, cross-cutting work goes where ASP.NET Core already has a place for it: middleware for every request (sign-in, the module switch, logging), endpoint filters for a group of routes, and one exception handler that turns typed errors into answers.

Module switches: a feature flag the size of a product

A feature flag turns code on or off without a deploy. A module switch is the same idea at the size of a whole product: a plan, an organization, a team or a person can turn a module off. The rule that matters is that the server enforces it - hiding a menu item is not a wall.

Make the safe path the default Because the tag sits on the group, a new route is covered by its module's switch the moment it is written. A design that asks every endpoint to call a check itself fails the day someone forgets one line. Test it: if the tag is missing (the endpoint is null), the middleware lets the request pass, so check that a tagged route really returns 403 when its module is off.

Read switches fresh, so a change takes effect on the next request, and decide what an unset switch means before you ship it - on or off.

Enforcing the architecture with tests

An architecture rule that lives only in a document erodes with every busy week. A team can turn each rule into an architecture test that runs with the normal test suite, so breaking a wall fails the build with a message that explains the rule. These are the rules worth testing.

A reference the compiler drops A compiled assembly lists only the references whose types it actually uses. A project reference that nothing uses yet is invisible to a reflection test. So a team also checks the project files themselves - a second test that reads them, or an editor or CI check - or an unused reference slips through until someone uses it.

Why tests and not a wiki page A rule people must remember fades with every new contributor - AI coding agents included. A rule a test checks holds for good, and its failure message teaches it to whoever breaks it.

Extracting a module later

Moving one module out of a monolith is usually done with the strangler fig pattern: the new service grows beside the old code and takes over one piece at a time, until the old path can be removed. A modular monolith makes that move small, because the walls the new service needs already exist.

Split for a reason Move a module out when it needs a different scale or a different release pace from the rest - measured, not guessed. Every split turns method calls into network calls, and those can fail in ways a method call never does.

What a modular monolith costs

Try it yourself: two modules and an architecture test

With the free .NET SDK you can build the skeleton of a modular monolith in half an hour, then watch an architecture test catch a broken wall. This is the exercise that turns the words above into muscle memory.

Common mistakes

Interview questions

Related