Today I work with both architecture styles at the same time. A delivery platform, which I will call Alfa here, has more than ten NestJS services. A gaming platform, Beta, has services per domain in a monorepo. And a poker community, Gama, has more than ten microservices. At the same time, I maintain several modular monoliths, and this portfolio is one of them: a NestJS API with 14 modules and a single deploy. I use it as the example because it is the one I can show from the inside. Seeing both side by side changed the question I ask. It is not "which one is better", but "which cost do I want to pay right now".
flowchart LR
subgraph mono["Modular monolith: one process, one deploy"]
direction TB
proj["projects module"]
art["articles module"]
agent["content agent module"]
agent -->|in-memory call| proj
agent -->|in-memory call| art
subgraph pg["PostgreSQL, or one database per module"]
dbp[("projects data")]
dba[("articles data")]
end
proj --> dbp
art --> dba
end
subgraph micro["Microservices: one deploy per service"]
direction TB
checkout["orders service"]
mq["RabbitMQ"]
pay["payments service"]
notif["notifications service"]
checkout -->|message| mq
mq --> pay
mq --> notif
d1[("orders database")]
d2[("payments database")]
checkout --> d1
pay --> d2
end
What each one is, plainly
- Modular monolith: one deploy, with the code split into modules per domain and explicit boundaries between them. Modules call each other in memory, and each one owns its data: the tables can live in a shared database, in a schema per module or in a database per module, as needed.
- Microservices: each domain becomes a service with its own deploy and, ideally, its own database. They talk over the network, through HTTP or messaging.
The difference that matters is not the size of the code, it is where the boundary lives. In a modular monolith it is a code rule. In microservices it is the network. And a modular monolith does not mean it cannot scale: the same process runs as several replicas behind a load balancer, and a module that grows can get its own database without becoming a service.
What microservices gave me
- Isolated deploys, data and failures: at Alfa, each service has its own container image and, in production, its own PostgreSQL database, and is deployed separately. Changing the tax issuer does not mean shipping the orders service. Locally, a single PostgreSQL serves them all, which keeps development light.
- Scaling per part: on Beta, the casino and gaming services can run in two roles, web and worker. The worker runs crons and queues without opening an HTTP port, and each role scales its own way.
- Boundaries nobody crosses by accident: with one database per service, a domain cannot read another one's table as a shortcut. The contract is the message.
- Independent teams: in a product with more people, like Gama, each service has a clear owner and evolves at its own pace.
What they cost me
Everything that used to be a function call becomes a distributed systems problem.
- Messaging has traps: at Gama, a single transactions queue was consumed by four services. RabbitMQ delivers round-robin without looking at the message type, so payment confirmations landed on the wrong service and were silently dropped. The fix was one queue per service.
- Eventual consistency and idempotency: on Beta, the same deposit can arrive twice, resent by the provider or by the queue. Every credit carries a unique key, and a resend returns the original receipt. In a monolith, much of that would be a transaction.
- Jobs across replicas: crons running on more than one instance need coordination. On Beta, that is a Redis lease per brand, service and job.
- Observability stops being optional: with more than ten services, the question "why was this sign-up slow?" only has an answer with distributed tracing. At Gama, I set up OpenTelemetry in the services and in the browser for exactly that.
- More to operate: a broker, a telemetry collector, dozens of containers and pipelines. Every piece is one more thing to monitor, update and understand when it breaks.
The modular monolith behind this portfolio
For the portfolio I chose the opposite path, on purpose. It is a small domain, maintained by one person, and what I want most here is simple operations.
- One module per domain: projects, articles, case studies, technologies, media, authentication, analytics and the content agent, each with its controller, service, DTOs and its own Prisma queries.
- Data per module: today the modules share one PostgreSQL database because the volume fits, and each one works with its own tables. If a module needs it, it gets its own schema or database without becoming a service.
- Boundaries through the service: when the content agent turns a draft into a project or an article, it calls those modules' services instead of building the records itself.
- A deliberate exception: on publication, the agent updates the project, article and case study translations in the same SERIALIZABLE transaction that writes the job. It is the only place where one module writes to another one's tables, and I accepted it for atomicity. It is exactly the coupling I would have to redesign, with a saga or an outbox, if those modules became services.
- A single deploy: the API image applies migrations on start, and a push to main ships everything.
- Rules that protect the boundaries: no internal barrel exports, imports always straight from the file, and an extra abstraction layer only when the business rules justify it.
The risk of a modular monolith is the boundaries rotting, because nothing stops a wrong import. The defense is discipline and code review, not the network.
Side by side
- Deploy: one, versus one per service.
- Data: each module owns its data, in a shared database or one per module, with transactions across modules while the data lives together, versus one database per service with eventual consistency.
- Failures: a serious bug can take the whole process down, versus a failure contained in one service, as long as calls have timeouts and a fallback.
- Scaling: replicas of the whole application, versus each part scaling on its own.
- Debugging: a local stack trace, versus distributed tracing and correlated logs.
- Operations: low cost, versus a high cost that grows with the number of services.
- Boundaries: kept by code rules, versus enforced by the network.
What if Alfa were a modular monolith?
I ran this exercise with Alfa, the architecture I like most among the ones I maintain. It would become a NestJS API with the same domains as modules: auth, catalog, commerce, payment, subscription, affiliates, procurement, scheduling and communication, with the same code also running in the worker role for the Bull queues.
- What would change: checkout would ask the payments module for the payment link directly, with no round trip through the queue, and the paid order, receipt printing and customer notice flow would fit in a local transaction with an outbox. The data would live in one PostgreSQL with a schema per module.
- What I would keep outside: the tax issuer would stay a separate service: it has a digital certificate, its own tax rules and depends on SEFAZ, and a slow SEFAZ must never hold checkout back. The WhatsApp gateway is already a third-party system and would stay separate.
- What I would lose: a spike of payment webhooks would scale the whole application, a serious bug in one module would take everyone's process down, and a fix in the catalog would ship the payment code along with it.
The exercise confirmed the choice. Alfa has money, tax invoices and real time in the same flow, and giving each service its own deploy and its own PostgreSQL in production is what keeps a problem in one part away from the others. There, the cost of microservices pays for itself.
What the industry has been saying
I am not the only one reaching this conclusion. Martin Fowler argues for starting with a monolith and extracting services only once the boundaries are clear, because microservices charge an extra cost that only pays off in systems that are complex enough. Shopify chose to organize its monolith into modules instead of breaking it into services. Segment merged more than a hundred services back into one after years of paying to operate them. And the Prime Video team moved a distributed monitoring flow into a single process and cut its infrastructure cost by more than 90%.
How I decide today
I start with a modular monolith with well-drawn domain boundaries, and I only extract a service when one of these signs shows up:
- one part needs to scale very differently from the rest;
- a failure in that part must not take the rest down, as in a money flow;
- a team needs to ship without coordinating with the others;
- the part has its own security, compliance or technology requirements.
If none of these signs shows up, a new service is just latency, eventual consistency and one more thing to operate. And when one does, a module with a clean boundary becomes a service with far less pain than a tangled monolith.
