Intermediate8 min readSeries: Modular Monolith in Practice

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

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 boundariesStrong boundaries
One deployableBig ball of mudModular monolith
Several deployablesDistributed 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:

  1. 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.
  2. 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#

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 + TypeScript
packages/
device-protocol/ MQTT topics and message schemas
contracts/ OpenAPI + generated TypeScript types
infra/ docker compose and service configs
docs/adr/ architecture decisions

ingestor 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#

Terminal window
git clone https://github.com/dev-danilocordeiro/smarthome-hub
cd smarthome-hub
make up # starts everything and waits for the healthchecks
make ps # api, ingestor and worker show up as separate containers

The 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:

Terminal window
make simulate # 3 homes x 20 devices
docker compose --env-file .env -f infra/docker-compose.yml restart ingestor # the API keeps responding
curl localhost:8000/health/ready

The 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.
  • domain imports 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:

apps/api/.importlinter (excerpt)
[importlinter]
root_package = smarthome
[importlinter:contract:module-layers]
name = Each module is layered api -> infrastructure -> application -> domain
type = layers
layers =
api
infrastructure
application
domain
containers =
smarthome.modules.automations
smarthome.modules.commands
smarthome.modules.devices
[importlinter:contract:boundary-automations]
name = automations reaches other modules only through their public.py
type = forbidden
source_modules =
smarthome.modules.automations
forbidden_modules =
smarthome.modules.devices.domain
smarthome.modules.devices.application
smarthome.modules.devices.infrastructure
allow_indirect_imports = True

To 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 shortcut

And run the lint:

Terminal window
cd apps/api
poetry run lint-imports

The 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.

Terminal window
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_id

That 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#

TermAnswersExample
MonorepoWhere the code livesGoogle, smarthome-hub
Modular monolithCode and data with strong boundaries, one systemsmarthome-hub
Several entrypointsSeparate processes on the same codeapi, ingestor, worker
MicroservicesUnits that own their code and data, deployed independentlyone repo (or folder) and one database per service
Distributed monolithSeveral deploys, weak boundariesservices 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-hub

References

  1. MonolithFirst — Martin Fowler martinfowler.com
  2. Don't start with a monolith — Stefan Tilkov martinfowler.com
  3. Monolith to Microservices — Sam Newman samnewman.io
  4. Why Google Stores Billions of Lines of Code in a Single Repository cacm.acm.org
  5. import-linter import-linter.readthedocs.io

Found a mistake? Let me know by e-mail