Monorepo, modular monolith and microservices: three axes everyone mixes up
Where the code lives, how many processes you deploy and how coupled the code is are separate decisions. A real project with one codebase, three deployables and boundaries checked in CI.
Published · Ler em português
- #architecture
- #modular-monolith
- #python
In this post
- 1Introduction
- 2Prerequisites
- 3Concept: three axes, not one
- 3.1Axis 1 — where the code lives
- 3.2Axis 2 — how many processes you deploy
- 3.3Axis 3 — how independent the code and the data are
- 3.4Is “a monorepo where each app has its own deploy” a thing?
- 3.5What the literature says (and what it doesn’t)
- 4Hands-on: one codebase, three processes
- 4.1The decision (ADR 0001)
- 4.2The monorepo layout
- 4.3Bringing it up and seeing the three processes
- 4.4The rules that make it a modular monolith
- 4.5A boundary that breaks the build
- 5Testing: what this design buys you
- 6Pitfalls and trade-offs
- 7Summary
- 8Conclusion
- 9Code on GitHub
TL;DR
- Monorepo answers where the code lives. Monolith vs. microservices answers how many units you deploy and whether each one owns its code and its data. They are independent axes.
- You can have a modular monolith with several deployables: one codebase, one process for the API, another for ingestion and another for jobs. That gets you scaling and failure isolation without distributing the domain.
- A module boundary that only exists on the wiki doesn’t exist. It has to break the build, and that matters even more when part of the code is written by AI agents.
Introduction#
In almost every architecture discussion I take part in, someone answers “monolith or microservices?” with “we’re going monorepo”. The two questions sound alike, but they aren’t. And mixing them up is expensive: teams put everything in one repo and think they got a modular monolith, or spread ten services across ten repos and end up with a distributed monolith.
At the same time, the pendulum has swung back. After a decade of “microservices from day one”, more and more teams start with a modular monolith, and AI tooling made the monorepo more appealing: an agent with the whole repository in context can make a change that spans the API, the worker and the contract in a single PR.
This post pulls the axes apart and shows, with a real project you can run, what a system looks like with one codebase, three processes and boundaries checked in CI.
Prerequisites#
- Familiarity with the idea of microservices and container deployments
- Docker with Compose v2, if you want to run the example
- Python 3.12+ and Poetry 2.2+ to run the boundary lint locally
Concept: three axes, not one#
Axis 1 — where the code lives#
- Monorepo: all apps and libraries in one repository, with one history, one CI and atomic changes across them.
- Polyrepo: one repository per app or library, with published versions between them.
This is an organization and tooling decision. Google runs thousands of independent services out of a monorepo. And there are monoliths spread across several repos, with the “core” published as a library.
Axis 2 — how many processes you deploy#
One artifact or several. Careful: several deployables doesn’t mean several services. Three processes running the same code against the same database are still one system.
Axis 3 — how independent the code and the data are#
This is the axis that matters, and the one that comes up least. Does each part own its model and its data, and talk to the others only through an explicit interface? Or can any class import any class and any query join any table?
Crossing axes 2 and 3:
| Weak boundaries | Strong boundaries | |
|---|---|---|
| One deployable | Big ball of mud | Modular monolith |
| Several deployables | Distributed monolith (the worst of both worlds) | Microservices, or a multi-process modular monolith |
And axis 1 is orthogonal to all of it: any cell in the table can live in a monorepo or across several repos.
Is “a monorepo where each app has its own deploy” a thing?#
Very much so, in two flavors worth keeping apart:
- Microservices in a monorepo. Each app has its own code and data. The monorepo just makes refactoring and shared contracts easier. Tools like Nx, Turborepo, Bazel or a Gradle multi-project build and deploy only what the change affected.
- A modular monolith with several entrypoints. One codebase, packaged into different images, each one wiring up only the modules it needs. A change to one module can produce a new version of every image, because they all carry the same code.
The second flavor is the one in the project I’ll use as the example.
What the literature says (and what it doesn’t)#
Martin Fowler, in MonolithFirst, and Sam Newman, in Monolith to Microservices, argue that microservices shouldn’t be the starting point: boundaries drawn before you understand the domain are expensive to move, and every rule that crosses contexts turns into a network call or a saga. But Fowler’s own site published the counterpoint by Stefan Tilkov, Don’t start with a monolith: when you already know the domain well, starting split can be cheaper than taking a monolith apart later.
So it’s not “microservices from day one is wrong”. It’s: you should only distribute boundaries you’ve already proven. The modular monolith is the cheap place to prove them.
Hands-on: one codebase, three processes#
The example is smarthome-hub, a multi-tenant smart home hub in Python (FastAPI) and React. It has three very different runtime profiles:
- HTTP/WebSocket API: latency-sensitive, scales with users.
- Telemetry ingestion: a long-lived MQTT consumer that scales with the number of devices and can’t stall when the API is busy.
- Background work: outbox relay, scheduled automations, notifications and command timeouts.
The business rules behind them overlap: ingesting telemetry updates the device’s digital twin (devices), which can trigger an automation (automations), which sends commands (commands). Splitting that into three services would turn one function call into three network calls.
The decision (ADR 0001)#
The repository records the choice in an ADR. Of the three options considered (single process, microservices, modular monolith with several entrypoints), the third one won:
Notice the simulator: it does not depend on the monolith, only on the protocol package, exactly like a real ESP32.
The monorepo layout#
apps/ api/ FastAPI + all the domain modules (the monolith) ingestor/ MQTT ingestion entrypoint on top of the same modules worker/ background entrypoint on top of the same modules simulator/ simulated homes and devices (depends only on device-protocol) web/ React + TypeScriptpackages/ device-protocol/ MQTT topics and message schemas contracts/ OpenAPI + generated TypeScript typesinfra/ docker compose and service configsdocs/adr/ architecture decisionsingestor and worker are thin packages: each has its own composition root and depends on apps/api as a Poetry path dependency. In other words, three images, one domain codebase.
Bringing it up and seeing the three processes#
git clone https://github.com/dev-danilocordeiro/smarthome-hubcd smarthome-hubmake up # starts everything and waits for the healthchecksmake ps # api, ingestor and worker show up as separate containersThe compose file lives in infra/ and reads the .env at the root, which is why the Makefile is the shortest path. To talk to compose directly, pass both: docker compose --env-file .env -f infra/docker-compose.yml.
With the simulator running, the ingestor absorbs telemetry from 60 devices without touching the API’s latency, and you can restart it without taking the API down:
make simulate # 3 homes x 20 devicesdocker compose --env-file .env -f infra/docker-compose.yml restart ingestor # the API keeps respondingcurl localhost:8000/health/readyThe rules that make it a modular monolith#
The ADR defines the rules, and there aren’t many:
- Each module has the layers
api → infrastructure → application → domain, and a layer only imports the ones below it. domainimports no framework at all (fastapi,sqlalchemy,pydantic,redis…).- A module only reaches another through
modules/<x>/public.py, or asynchronously through events that go through the outbox. - One Postgres schema per module, no foreign keys across schemas. References between modules are ids only.
The last rule is the one most people skip, and the one that makes a future extraction most expensive.
A boundary that breaks the build#
The rules become import-linter contracts, generated by a script so they don’t go stale. An excerpt:
[importlinter]root_package = smarthome
[importlinter:contract:module-layers]name = Each module is layered api -> infrastructure -> application -> domaintype = layerslayers = api infrastructure application domaincontainers = smarthome.modules.automations smarthome.modules.commands smarthome.modules.devices
[importlinter:contract:boundary-automations]name = automations reaches other modules only through their public.pytype = forbiddensource_modules = smarthome.modules.automationsforbidden_modules = smarthome.modules.devices.domain smarthome.modules.devices.application smarthome.modules.devices.infrastructureallow_indirect_imports = TrueTo see the contract at work, break a boundary on purpose. In any file under automations, import the devices domain directly:
from smarthome.modules.devices.domain import twin # forbidden shortcutAnd run the lint:
cd apps/apipoetry run lint-importsThe output points to the broken contract and the import chain that caused the violation. In CI, that fails the PR.
Testing: what this design buys you#
Follow a command from the click to the device. With the simulator running, every command has a trace_id, and Tempo shows a single trace across four processes: the request in the API, the worker’s publish, the device handling the command and the ingestor receiving the ack.
make simulate# in the UI (make web-dev), turn on a light and copy the trace_id from the response# Grafana → Explore → Tempo → paste the trace_idThat shows the core idea: processes are split where the runtime profile differs, and the code is one where the rules overlap.
Pitfalls and trade-offs#
Summary#
| Term | Answers | Example |
|---|---|---|
| Monorepo | Where the code lives | Google, smarthome-hub |
| Modular monolith | Code and data with strong boundaries, one system | smarthome-hub |
| Several entrypoints | Separate processes on the same code | api, ingestor, worker |
| Microservices | Units that own their code and data, deployed independently | one repo (or folder) and one database per service |
| Distributed monolith | Several deploys, weak boundaries | services that only come up together |
Conclusion#
“Monorepo” is a tooling choice. “Modular monolith” is a coupling choice. “Several deployables” is an operational choice. Treating the three as one leads to architectures with the cost of one and the benefit of none.
But all of this assumes you know where to draw the module boundaries. In the next post in the series, I get to the hardest part: splitting domains with strategic DDD, using Evans, Vernon and Khononov, in a domain richer than a smart home.
Code on GitHub#
The code for this post is on GitHubdev-danilocordeiro/smarthome-hubReferences
- MonolithFirst — Martin Fowler martinfowler.com
- Don't start with a monolith — Stefan Tilkov martinfowler.com
- Monolith to Microservices — Sam Newman samnewman.io
- Why Google Stores Billions of Lines of Code in a Single Repository cacm.acm.org
- import-linter import-linter.readthedocs.io
Found a mistake? Let me know by e-mail