Architecture
The modular monolith and how to keep its boundaries
A modular monolith is one deployment with modules per domain, each with a public interface and its own tables. Checks in CI keep those boundaries in place.

A modular monolith is one application with one deployment, split internally into modules by domain. Each module has a public interface and its own tables, and other modules only use that interface. You guard the boundaries with tooling in CI, because without automated checks they wear away.
Why a modular monolith
You keep the simplicity of a single deployment. There is one pipeline, one database server and one place to read logs, and a transaction across two modules is a plain database transaction. At the same time you get the boundaries people look for in microservices: a team or developer can work on a module without knowing the rest.
Shopify described this approach in 2019. The company kept all code in one codebase and enforced boundaries between components, without turning them into separate services. Erik covers the wider trade-off in monolith or microservices.
Modules by domain rather than by layer
Many codebases are split by technical layer: a folder of controllers, a folder of services and a folder of repositories. A change to billing then touches three folders, and nothing stops the orders code from calling a billing repository directly.
So split by domain instead, with one folder per module: orders, billing, catalogue, customers. Inside a module you can still have layers. Shopify did the same and reorganised its code into domains such as orders, shipping, inventory and billing.
Start with few modules. Four to six large domains is enough for most products. You can split later; merging is more work.
Every module has a public interface
Other modules talk to a module through a small set of public classes or functions. Everything else is internal. Use what your language already offers: internal in C#, package-private classes in Java, and in TypeScript you export only through the module's index.ts.
Do not share entities between modules. Orders does not get an Invoice object from billing, it gets an id or a simple DTO. That way billing can change its own model without breaking orders.
Modules talk to each other in two ways:
- A direct call through the public interface, when you need an answer.
- An in-process event, for side effects. Orders publishes that an order was placed, and billing creates an invoice in response.
Do not use events for everything. When every step is an event, nobody can follow what happens after a click any more.
Separate tables per module
In my view separate tables make the biggest difference, and they are also the easiest step to skip. Give each module its own schema in the database, for example orders and billing in Postgres. A module reads and writes only in its own schema.
The rules I stick to:
- No joins across schemas in application code. If you need another module's data, ask for it through its interface.
- No foreign keys between modules. Store the id and let the other module own the truth.
- Migrations per module, in that module's folder.
Reporting is the exception you handle on purpose. Build a separate read model or views owned by a reporting module, so that not every module reads everywhere.
Enforce boundaries in CI
Rules that live only in a wiki do not last long. Put them in code and run them on every pull request.
| Stack | Tool | What it does |
|---|---|---|
| Java | ArchUnit | Architecture rules as unit tests, such as package dependencies and cycles |
| Spring Boot | Spring Modulith | Verifies the module structure and generates documentation of the modules |
| .NET | ArchUnitNET | Architecture rules in C# as automated tests |
| JavaScript and TypeScript | dependency-cruiser | Checks imports against your own rules and reports violations |
| Nx monorepo | @nx/enforce-module-boundaries | Lint rule that restricts imports based on project tags |
Start with two rules: no cycles between modules, and no imports of another module's internal code. That catches most problems.
Add a CODEOWNERS entry per module folder as well, so the owner of a module sees every change to it. Erik describes how to run reviews well across locations in code review in a remote team.
Where the boundaries still blur
A shared folder called common, shared or utils grows into a second monolith. Keep only technical helper code there, never domain logic.
A module called "core" that everything depends on is, in my view, usually a domain that has not been split yet. Look at what is in it and move it to the real modules.
Creating too many modules too early is another trap. Twenty modules of a few files each cost more coordination than they give back.
Pulling a module out
Sometimes a module does need to become a service of its own, for example because it has to scale separately, gets its own team or needs a different release rhythm. With its own interface and tables, that is quite doable.
You move the module into its own service, replace the implementation behind the interface with an HTTP client or a queue, and move the schema to its own database. In-process events become messages on a queue. The other modules hardly notice, because they were already talking through the interface. What does change is that a call can now fail or be slow, and a transaction across two modules is no longer a plain database transaction. Whether the new service then stays in the same repository is a separate decision. Erik wrote about that in monorepo or separate repositories.
Sources
Frequently asked questions
What is a modular monolith?
An application deployed as a single unit but split internally into modules by domain. Each module has a public interface and its own tables, and other modules may only talk to it through that interface.
How do you enforce module boundaries?
With architecture tests or lint rules that run in CI, such as ArchUnit for Java, ArchUnitNET for .NET, and dependency-cruiser or the Nx enforce-module-boundaries rule for TypeScript. A pull request that crosses a boundary then fails.
Can you deploy a module as a separate service later?
Yes, if the module has its own interface and its own tables. You replace the in-process calls with network calls or messages and move the tables to a database of their own.
About the author

Aleksa
Tech lead, Belgrade
Aleksa is tech lead in Belgrade. He leads the development team, oversees the architecture and reports to Erik.
More from Aleksa





