Monolith, modular monolith, microservices
coreadvancedA **traditional monolith** is one deployable where any code can import any other code. A **modular monolith** is still one deployable, but the modules have declared boundaries and talk through published interfaces. **Microservices** split those modules into separately deployed services that talk over the network. The roadmap is blunt about the trade: "microservices are not automatically more scalable, more reliable, or easier to maintain".
Think of it as
The three are not a maturity ladder; they are three answers to one question — where do you enforce the boundary between parts of your system? In a traditional monolith the answer is "nowhere", so `orders/views.py` can import `billing.models` and reach into another team's tables, and over time it does, until every change touches everything. That coupling is the real problem people are trying to escape, and it is worth naming precisely, because the usual escape route treats a *code* problem with a *distribution* solution. The modular monolith enforces the boundary in code instead. Each module exposes a small public interface — a service function, a set of events — and everything else is private; the import graph is checked, cross-module foreign keys are avoided or made explicit, and calls between modules go through the front door. You keep the things a single process gives you for free: one `transaction.atomic()` block covering a multi-model workflow, a function call that cannot time out, one deploy, one place to look in a traceback, and refactoring across a boundary in an afternoon. Microservices move the boundary into the network, and the honest way to describe that is that you exchange a set of easy problems for a different set of hard ones. Every call that used to be a function call can now be slow, refused, duplicated or half-completed, so each one needs a timeout, a retry policy, an idempotency key and a fallback. A workflow that spanned two modules in one transaction now spans two databases and needs an outbox or a saga, because there is no distributed `atomic()` block. Debugging needs correlation ids and distributed tracing to reconstruct what one process used to show in a single traceback. And a schema change that used to be one migration becomes a coordinated release across services. In exchange you get independent deployment, independent scaling and independent failure — genuine benefits, and worth paying for when you have a component whose scaling or release cadence really is different, or when team boundaries have made a shared deployment a bottleneck. The practical guidance is the order: make the modular boundary first, inside the monolith, because it is cheap, reversible and the prerequisite for any split that could work. If you cannot draw a clean boundary in one process, extracting it over a network will not produce one — it will produce the same coupling with latency and partial failure added, which is the shape people mean when they say a system has become harder to change after the split, not easier.
What we're doing: Turn a traditional monolith into a modular one by making the boundary explicit and checkable — without deploying anything new.
- 1–7
- A named interface module is the whole technique. Once "import from `billing.api` only" is a rule, the boundary has a location — and a future extraction has a contract already written.
- 10–13
- Primitives across the boundary, not ORM objects. Passing an `Order` instance would make billing depend on orders' models, which is the coupling that makes an extraction a rewrite rather than a move.
- 26–33
- One `atomic()` block covering both modules. This is the concrete thing a service split costs: across a network there is no shared transaction, and the same workflow needs an outbox or a saga.
- 37–42
- The forbidden-import list makes the boundary enforceable rather than aspirational. Without a check, a boundary is a convention that decays at the first deadline.
- 44–50
- The failure message names the fix and the offending files. A boundary test that only says "assertion failed" gets deleted by whoever hits it at 17:00 on a Friday.
Why this works: The boundary exists in code, is checked automatically, passes only primitives, and still runs inside one transaction — so the module could be extracted later, and does not have to be today.
Extracting a service to solve a code-coupling problem
Wrong
Better
What you see: A release train: neither service can deploy without the other, every feature needs a coordinated change in two repositories, and the incident count goes up because failures that used to be exceptions are now timeouts.
Why: Distribution does not create a boundary; it relocates one. If `orders` reaches into `billing`'s tables today, extracting billing turns those reaches into HTTP calls with the same shape and the same assumptions, plus latency, partial failure and duplicate delivery. The coupling is unchanged and the cost of every interaction has gone up. Drawing the boundary in the monolith is the cheaper experiment and the honest test: if the interface can stay small and stable for a few months of real feature work, the split is a mechanical move afterwards. If it cannot, the split would have produced a distributed version of the same tangle — which is the outcome the roadmap is warning about when it says microservices are not automatically easier to maintain.
- Three columns, each showing the same workflow — orders, then billing, then notify.
- In the first column, "traditional monolith", the three parts sit inside one box with plain arrows between them, and a note says any module can import any other module directly.
- In the second column, "modular monolith", the same three parts sit inside one box but each is drawn as a separate bordered module, with arrows labelled "via the public interface", and a note says one transaction still covers all three.
- In the third column, "microservices", the three parts are three separate boxes with no enclosing box, joined by dashed red arrows labelled HTTP, and a note says each arrow can time out, retry or duplicate, and no single transaction spans them.
- A footer states the trade directly: the first two share one transaction, one deploy and one traceback, while the third gains independent deployment and scaling and pays for it with partial failure.
What changes when the boundary moves
Together
What actually justifies extracting a service
Together
Remember: The three styles are three places to enforce a boundary: nowhere, in code, or in the network. A modular monolith keeps one transaction, one deploy, one traceback and cheap refactoring while giving you the boundary — a public interface module, primitives across it, and a test that forbids everything else. Microservices trade those for independent deployment, scaling and failure, and charge for it in timeouts, retries, duplicates, sagas and correlated tracing. Extract when the reason survives "what would we gain that a module boundary cannot give us?" — different scaling axis, different release cadence, different team, different runtime — and draw the boundary in the monolith first either way.
See also: layered clean and hexagonal · domain driven design and event driven styles · what belongs in a service function · eventual consistency outbox and versioning

