Many projects start with microservices because it looks like the "right" architecture for growth. Months later, the team spends more time debugging calls between services, incompatible contract versions and half-finished transactions than building features.
Microservices solve real problems. But they are specific problems, and the bill arrives before the benefits. For most new systems we start with a modular monolith: a single deployable application, internally divided into modules with boundaries as strict as those between separate services.
What microservices cost
Splitting a system into services turns function calls into network calls. With that come:
- Partial failures. One service answers, another doesn't. Every call needs a timeout, a retry policy and a plan for when it fails.
- Distributed consistency. An operation that used to fit in one database transaction now spans services and requires sagas, outboxes and compensations.
- Versioned contracts. Changing the shape of data means coordinating versions across teams and deployments.
- Operations. More pipelines, more monitoring, distributed tracing just to understand a single request.
Those costs pay off when there's a concrete reason: parts of the system with very different scaling needs, independent teams that must deploy without coordination, or requirements for fault and security isolation.
What a modular monolith is
A modular monolith is an application organized into domain modules (orders, billing, catalog, identity) with three rules:
- Each module exposes a public interface and hides everything else. Other modules only call that interface.
- Each module owns its data. No module reads or writes another module's tables directly, even in the same database.
- Dependencies between modules are explicit and tool-checked, not left to convention.
src/modules/
orders/
index.ts ← the module's public interface
internal/ ← rules, repositories, tables: invisible to other modules
billing/
index.ts
internal/
catalog/
index.ts
internal/
Boundaries enforced by tooling
Conventions erode with time and deadline pressure. A CI rule that fails when one module imports another module's internals keeps the boundary real.
module.exports = {
forbidden: [
{
name: "no-reaching-into-other-modules",
comment: "Modules talk to each other only through their public index.ts",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: {
path: "^src/modules/([^/]+)/internal/",
pathNot: "^src/modules/$1/internal/",
},
},
],
};The same applies to the database: each module uses its own schema (for example, orders.* and billing.* in PostgreSQL), and each module's credentials can be restricted to its own schema.
Communication between modules
Inside the monolith, modules can communicate in two ways, and choosing well makes a future extraction easier:
- Synchronous calls through the public interface, when the caller needs the answer to continue.
- Domain events published by one module and consumed by others, when the reaction can happen later. For example,
order.createdmakes billing issue a charge.
If events already go through an outbox and are handled idempotently, turning them into broker messages on extraction day is a change of transport, not of architecture.
When to extract a service
With well-kept boundaries, extracting a module becomes a technical decision rather than a rewrite. Good reasons:
- The module's load profile differs sharply from the rest (heavy batch processing, isolated traffic spikes).
- A team needs to deploy at a pace the rest of the system can't match.
- The module requires isolation: sensitive data, regulatory requirements, dependencies with their own risks.
Bad reasons: "microservices scale better" without a measurement showing the bottleneck, or "the code is messy". A messy monolith becomes messy microservices, just with a network in between.
Summary
A modular monolith delivers most of the organizational benefits of microservices, such as clear boundaries, data ownership and low coupling, without the network and distributed-consistency costs. With tool-enforced boundaries and communication through interfaces and events, extracting a service later is a planned operation, done when there is a measurable reason.
Related reading
- Software Architecture · 3 min read
- Backend Engineering · 3 min read
- Backend Engineering · 3 min read