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
- Monolith (often layered) One application built and deployed as one unit - usually cut into layers: controllers, services, repositories.
- Microservices Many small applications, each with its own deploy and its own database, talking over the network.
- Modular monolith One deployable, cut inside into modules with hard walls: own code, own data, contracts between them.
- Module (bounded context) One product area with its own words and data - Orders, Billing - that the rest of the code may not reach into.
- Schema A named group of tables inside one database, such as orders or billing. A module owns exactly one.
- Shared kernel The one small project every module may use: contracts, errors and helpers. It knows no module.
- Seam A contract in the kernel that one module implements and another uses, without either knowing the other.
- Dependency injection (DI) The framework builds a class's helpers for it: the class asks for an interface and receives whatever was registered.
- Composition root The one place at program start that lists the parts and wires them together.
- Service lifetime How long DI keeps an object: singleton for the whole app, scoped for one request, transient for every request for it.
- Vertical slice Everything one use case needs - endpoint, request, command, handler - kept together in one folder.
- CQRS and mediator Commands change data and queries read it, each with one handler; a mediator finds the handler for a message.
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.
- Start layered A small team ships fast with controllers, services and repositories in one app. This is the right start for most products.
- The ball of mud Two years later every service calls every other and any query joins any table. A change in billing breaks orders.
- Cut it into modules Draw walls inside the same app: each area gets its own project and schema, and talks to the others through contracts.
- Split what needs it Only a module that needs its own scale or release pace moves out into a service. The walls already exist, so it is a move, not a rewrite.
- Functions at the edges Small, bursty jobs - resize an image after upload - can become serverless functions beside everything else.
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.
- 1. List the business words Write down the nouns and verbs people use: "order", "invoice", "ship", "refund". Use their words, not table names.
- 2. Group words that mean the same thing If "customer" means a buyer in one place and an account holder in another, you have found two modules.
- 3. See what changes together Things that change in the same release belong together. Things that change alone can have their own wall.
- 4. Ask who owns each decision A different team, role or rulebook usually means a different module. Walls tend to follow team lines - this is Conway's law.
- 5. Check the data A table written by only one group of code is that group's table. A table written by three groups is a warning.
- 6. Draw the contracts List the few questions each group must ask the others. If the list is long, the wall is in the wrong place.
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.
- 1. The host lists the modules One small composition root - the program's start - names every module once. Adding a module is one line there; no module edits another.
- 2. Each module registers itself A module adds its own database context, its handlers and its implementations of kernel contracts to dependency injection.
- 3. Each module maps its routes Its endpoints are found by scanning its assembly and placed in one route group tagged with the module's name.
- 4. A request arrives Middleware reads the tag on the matched route and asks one central decision whether the module is switched on for this caller.
- 5. One use case runs The endpoint sends one command or query; the mediator finds its handler, which works only on its module's own schema.
- 6. Help from another module If the handler needs another module, it asks a contract in the kernel; DI hands it whatever implements that contract.
The options in the market
- Layered monolith One app cut into controllers, services and repositories.; Fastest to start; Easy for a newcomer to follow; One deploy, one database; Every feature touches every layer; Nothing stops any class calling any other: a ball of mud over time; A product area cannot be switched off or moved out
- Modular monolith One app cut into modules with their own data and contracts between them.; Real walls, checked by the compiler and by tests; One deploy, one database: simple to run, back up and install; Calls stay in-process: fast, no partial failures; A module can move out later; Everything ships together; A busy module shares CPU and memory with the rest; The walls need discipline and tests, or they erode
- Microservices Many apps, each deployed alone with its own database.; Independent deploys and scaling; Teams own a service end to end; A crash stays inside one service; Network calls fail, time out and need retries; No transaction across services; A pipeline, dashboards and on-call per service; Expensive for a small team
- Serverless functions Small functions the cloud runs on demand.; No servers to manage; Pay per call, scale to zero; Great for bursty events; Cold starts; Time limits on long work; Strong vendor lock-in; Hard to run and test locally
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.
- One deployable: one unit to install, upgrade and back up - easy for a Private edition customer and cheap to run in the cloud.
- Real walls anyway: each module is its own project that depends on the shared kernel and not on other modules, and architecture tests fail the build when a wall is used in code.
- Switchable products: because a module is a unit, a plan, an organization or a person can turn it off, and the server refuses its routes.
- Testable alone: a module's tests boot the host with only the in-memory databases they need.
- A way out: modules meet only through contracts, so one that needs its own scale can move out by swapping in-process calls for remote ones - not by a rewrite.
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.
- Many teams that must release independently, or one part with far more load than the rest: split that part into a service.
- A small app with one purpose and a short life: a layered monolith is enough.
- Rare, bursty jobs triggered by events: a serverless function is often the cheapest home.
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.
- Shared tables Every module reads and writes the same tables.; Joins anywhere; Simplest start; Any module can change any table; Moving a module out means untangling joins first
- A schema per module, one database One database, each module's tables under its own schema.; Clear ownership of every table; One connection, one backup, one migration tool; Moving a module out is moving one schema; The database alone does not stop a cross-schema query - code and tests must; Modules share the database's capacity
- A database per module Each module gets a database of its own.; The strongest isolation; Each database scales on its own; Many databases to run, back up and migrate; No transaction across modules
The schema name is set once, so the context cannot even name another module's table.
- 1. Store the kind and the id A note that points at a card keeps the card's kind and id only - no foreign key into another schema.
- 2. Ask the owner to show it To display the card, ask the owning module through a contract. It also checks that this reader may see it.
- 3. Survive a missing record If the card is gone, show a placeholder such as "deleted card" instead of failing the page.
- 4. Announce facts after the commit When other modules must follow a change, write an event to an outbox table in the same transaction. A worker delivers it after the commit.
- 5. Handle events twice safely Delivery is at least once, so a handler must be idempotent: running it a second time changes nothing. Each handler repairs its own module's data.
- 6. Build cross-module reports in memory For a report over two modules, ask each module for its slice and merge the answers in memory. There is no cross-module transaction and no join.
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.
- 1. Declare it in the kernel An interface with no module names and no tables - for example, find records that match some text.
- 2. Implement it where the data is The module answers from its own tables, with the asking person's own permissions.
- 3. Register it One line in the module's start-up: this class is an implementation of that contract.
- 4. Consume all of them The consumer asks DI for every implementation and loops over them. It never names a module.
- 5. Respect the switch Before asking a module, the consumer checks that it is on for the caller; a switched-off module is skipped.
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.
- Scope opens ASP.NET Core opens a DI scope when the request arrives.
- The endpoint gets its parameters The mediator is resolved from this request's scope.
- The handler is built With its database context, access checks and helpers - each created once for this scope.
- The work runs Every class in the request shares the same context, so one save covers the whole use case.
- Scope ends Scoped and transient objects that are disposable are disposed, and the database connection returns to the pool.
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.
- Asking for IEnumerable<T> returns every registration of T in registration order - that is how a consumer reaches every module's implementation of a seam.
- Asking for a single T returns the last registration. TryAdd registers only when nothing is registered yet, so many modules can call the same set-up line safely.
- One scoped object can serve several interfaces: register the class once, then map each interface to it with a factory, so all of them share one instance per request.
- A hosted service is a singleton. It opens a scope for each unit of work and never holds a scoped object itself.
- Prefer constructor injection over asking the container yourself (the service locator style): dependencies stay visible, and a test can see what to fake.
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.
- Endpoint Maps one route, binds the client's request and adds what the server knows: who is asking, the id from the route.
- Command One record carries the client's request beside the server-resolved context, so a client cannot pretend to be someone else.
- Mediator Finds the one handler registered for this command type.
- Handler Checks access, changes data in its module's schema and saves once. It never touches the HTTP context.
- Response Returns a response record built from the entity - never the entity - so only the fields the client reads leave the server.
- Errors The handler throws a typed error - not found, invalid, conflict; one exception handler turns it into the HTTP answer, so endpoints hold no try or catch.
- One type per file, named after it, and the folder equals the namespace - a newcomer finds a feature by its use case name.
- No god classes: logic several use cases share goes into small, single-purpose helpers in the area folder, never a module-wide service.
- HTTP stays in the endpoint: bind, send one message, shape the answer. The handler can then run from a hub or a worker too.
- Responses, not entities: an entity can carry fields the client must never see, and its shape would freeze the API.
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.
- A command carries intent and is validated and authorised; a query is safe to repeat and to cache.
- Each handler is small and named for one use case, so tests are short.
- Queries can skip change tracking and return only the columns a screen needs.
- You can add a read model later without touching callers.
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.
- Send The endpoint calls send with a command; it knows only the mediator and the command.
- Find the dispatcher The mediator looks up a small typed dispatcher for this command type in a cache; the first send builds it.
- Resolve the handler The dispatcher asks the request's DI scope for the one handler registered for this command.
- Run it The handler runs. The result type is checked by the compiler, because the command declares it.
- Return The result goes back to the endpoint, which shapes the HTTP answer.
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.
- MediatR The best-known .NET mediator library.; Widely known; Pipeline behaviours for logging, validation and transactions; Notifications to many handlers; A dependency for a few dozen lines of work; Moved to a commercial licence in 2025, with a free tier for small companies; Behaviours hide what runs around a handler
- Source-generated mediators Libraries that write the dispatch code at compile time.; No reflection at run time; The dispatch code is plain generated C# you can read; Another dependency and code generator to keep current; Less familiar to newcomers
- Own small mediator A few dozen lines in the shared kernel.; No dependency and no licence; Cached typed dispatch: no reflection after the first send; Nothing hidden: cross-cutting work is visible where it runs; No pipeline behaviours - build what you need elsewhere; Yours to maintain and test
- No mediator Each endpoint takes its handler directly.; Nothing to learn; Easy to follow in an editor; An endpoint's parameters change when its handler type does; Endpoints stop looking alike
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.
- Release flag: hides unfinished work; remove it when the work is done.
- Ops flag / kill switch: turns a risky part off at once.
- Permission or entitlement flag: what a plan or a customer may use. The module switch is this kind.
- Delete release flags after launch, or they pile up as dead branches.
- Tag the routes The module maps all its routes in one route group that carries the module's name as metadata.
- Match the route Routing picks the endpoint before the access middleware runs, so the tag is readable there.
- Decide once, centrally One function weighs the plan and the switches set by the account, its groups or the person.
- Refuse early An off module answers 403 Forbidden before any handler runs. Some designs add a middle state, read-only, where reads still work.
- Exceptions are explicit A route that must stay open is marked where a reviewer can see it.
- Other doors need the same check Any code that reads a module for a person on a path other than HTTP must ask the same decision.
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.
- NetArchTest Fluent rules over types and namespaces.; Rules read like sentences; Quick to start; Another package; Sees types only - not routes or DI registrations
- ArchUnitNET A port of Java's ArchUnit.; A rich rule language: layers, cycles, naming; Heavier to learn; Still type-level only
- Plain xUnit and reflection Rules written as ordinary tests.; No dependency; Can check anything the app knows: assemblies, routes, DI registrations, source text; Each rule is yours to write
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.
- 1. Prove it is ready The module uses only the kernel, owns its schema, and no query joins its tables with another's. The architecture tests already say so.
- 2. Give it its own host A new small program runs only this module and the kernel, with its own pipeline and health checks.
- 3. Move its data Copy its schema to a database of its own - a dump and restore of one schema - and point the module's context there.
- 4. Swap the seams In the main app, replace the module's contract implementations with remote adapters that call the new service. Callers do not change.
- 5. Move the events Facts the module announces leave through its outbox to a message broker instead of in-process handlers.
- 6. Route the traffic The gateway sends the module's routes to the new service, which checks the same sign-in tokens itself.
- 7. Remove the old path Delete the project reference once nothing calls the in-process code.
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.
- Create the projects A kernel, two modules, a web host and a test project.
- Draw the walls Each module references only the kernel; only the host references the modules.
- Add a seam Put an interface in the kernel, implement it in Billing, and use it from Orders through DI - so both modules really use the kernel. Register both in the host.
- Write the test Load each module's assembly and assert that it references only the kernel. Run the tests and see them pass.
- Break it on purpose Make Orders reference Billing and use one of its types - an unused reference is dropped by the compiler and the test cannot see it.
- Read the failure The test fails and the Actual list now also holds the Billing project. Read the message, then undo the change.
- Stretch goal Write the SQL schema script for each module, and a second test that reads each module's project file and fails on any reference but the kernel.
Common mistakes
- A kernel that grows business rules: once the shared project holds one module's logic, every module depends on it. Keep it to contracts, errors and small helpers.
- Reaching into another module's tables: a quick join across schemas is a hidden reference no compiler sees. Ask through a contract.
- A captive dependency: a singleton holding a database context. Give singletons a scope factory and open one scope per unit of work.
- Checks people must remember: a switch or permission check every endpoint must call will be forgotten, and a wall nobody tests erodes. Put the check on the route group by default and write each wall as a test with a clear failure message.
- Splitting too early: microservices for a small team buy network failures and distributed transactions with no gain. If they still share one database you get a distributed monolith - all the cost of microservices and none of the benefit. Modularise first; split for a measured reason.
- Leaky seams: a contract that returns another module's entity classes or table rows welds the modules together again. Return small records the contract owns.
Interview questions
- What is a modular monolith, and how is it different from a layered one? Both deploy as one application. A layered monolith is cut horizontally, so any class can reach any table; a modular monolith is cut vertically into modules that own their code and data and meet only through contracts, with the walls checked by tests.
- How do you keep data consistent across modules without joins? Modules refer to each other by id and ask the owner through a contract. Facts that others must follow travel as events through an outbox in the same transaction, handled idempotently; reports ask each module and merge in memory.
- How do you decide where a module's boundary goes? Follow the business language and ownership: words that change meaning mark a boundary, and data written by only one group is that group's. A bad boundary shows when one change needs edits in three modules.
- When would you not choose a modular monolith? When teams must deploy independently, one part has very different load, or the whole app is tiny. For a small team, microservices add network failures, distributed transactions and a pipeline per service; pick them only for the part that needs it, or pick a simple layered app.
- How do two modules talk without referencing each other? Through an interface in the shared kernel that one module implements and the other receives by dependency injection - or through an event that any module may handle. This is dependency inversion between modules.
- Explain the three DI lifetimes and a captive dependency. Singleton is one object for the app, scoped is one per request or scope, transient is new on every resolution. A captive dependency is a singleton holding a scoped object, such as a database context, which then lives forever and is shared across threads.
- What is CQRS, and how far did you take it? Commands change state, queries read it, each with its own handler. We use the light form - separate handlers on the same model and database - not separate read stores or event sourcing, which would cost more than they give here. The risk of going further is two stores that drift apart.
- Why write your own mediator instead of using MediatR? It is a few dozen lines: a dispatcher per message type, built once and cached, resolving the handler from the request's scope. That avoids a dependency and its licence, and cross-cutting work lives visibly in middleware, filters and one exception handler. The trade-off: no built-in pipeline behaviours, so anything cross-cutting is built where it runs.
- How is a module switch enforced? Every route of a module sits in a route group tagged with the module's name; one middleware reads the tag after routing and asks a central decision, answering 403 before any handler runs. Exceptions are declared on the route, where a reviewer sees them. A plan can also leave a module read-only instead of fully off.
- How would you extract a module into a service? Give it its own host, move its schema to its own database, replace the in-process implementations of its contracts with remote adapters, and send its events through the outbox to a broker - the strangler fig pattern, one route at a time.
Related
- /help/technology/tech-map
- /help/technology/request-path
- /help/technology/realtime-events