The pragmatic default: modular monolith architecture
Hot take: microservices are overprescribed. For most teams under ~50 engineers, a disciplined modular monolith is cheaper to run, far faster to iterate on, and the smarter default. The key word is disciplined: a modular monolith is not a throwback monolith, it’s a single deployable with enforced module boundaries, schema-per-module, and an internal event bus.
In practice teams pay a "distributed systems tax" long before they need to. Network latency, brittle inter-service contracts, dozens of CI pipelines, and distributed-debug hunts turn predictable work into constant firefighting. Several real-world migrations back to a modular monolith report dramatic wins — 90% reductions in infrastructure spend in some cases and CI runs collapsing from 45 minutes to under 5.
What a modular monolith actually is
A modular monolith is one deployable unit organized into clearly separated modules. Each module owns its domain logic, its data schema (schema-per-module), and exposes a minimal public API. Modules communicate through explicit interfaces and an in-process event bus rather than random imports or direct schema hacks.
Why this matters:
- Single deployment simplifies operations and CI/CD.
- In-process calls remove network latency and failure modes.
- Schema-per-module preserves data ownership while keeping a simple single-database operational model.
- An internal event bus lets modules decouple without full external messaging complexity.
Enforcing the discipline
Boundaries must be enforced mechanically. Conventions erode. Use tools and tests to make module violations a CI failure:
- Packwerk (Ruby) or Spring Modulith (Java) to declare and enforce packs/modules
- ArchUnit or lint rules for package-level restrictions in Java
- Deptrac or depguard equivalents for PHP/other languages
- Small architecture tests (eg. go list checks) that fail the build when a module imports internal types from another
When boundaries are automatic CI gates, the project stays extractable. If they’re just a comment in a README, they will break.
Internal event bus and the extractability seam
An in-process event bus is the cheapest rehearsal for async boundaries. It decouples producers and consumers without the overhead of a broker. Example (pseudo-JS):
// publish domain event on internal bus
bus.publish("OrderCreated", { id: orderId, total: amount })
// each module owns its schema: orders_schema.v1
Important caveat: moving to an external broker later is not a no-op. At-least-once delivery, idempotency, serialization/versioning, and the transactional outbox are real costs. Treat the internal bus as a low-friction seam—design handlers idempotent and keep event schemas versioned so extraction later is a smaller migration.
Schema-per-module: data ownership without distributed joins
Treat the schema as part of the module boundary. Practically this means:
- Each module owns tables or a schema namespace (orders., billing.)
- No cross-module foreign keys or JOINs
- Cross-module reads go through the module’s API or via published read models
This keeps data coupling from becoming the one irreversible glue that prevents extraction later.
When should you extract to microservices?
Extraction should be driven by measurable pain, not fashion. Four signals matter:
1) Sustained, measurable independent load. A bounded context consuming the majority of CPU/IO is a good candidate to isolate. If a module truly needs different scaling, split it.
2) Data-model divergence that schema-per-module can’t contain. If a domain’s data shape or storage technology diverges (e.g., needs a graph DB or GPU-backed model), extraction makes sense.
3) Team and org alignment for independent deploys. If you have roughly >50 engineers organized into autonomous teams with separate ownership and on-call, the organizational scale justifies services.
4) Clear SLA, compliance, or fault-isolation requirements that demand physical separation.
If you don't see these signals, early distribution is a tax, not an advantage.
Operational wins you can expect
Real migrations back to a modular monolith often report:
- Large infrastructure cost reductions (some teams cite an order-of-magnitude savings)
- Much faster CI/CD and local dev startup (one test context vs orchestrating many containers)
- Simpler debugging and faster mean time to repair — single logs and breakpoints beat distributed trace hunts
- Fewer integration test flakiness and faster feedback loops
Those are not hypothetical benefits; practitioners across stacks (Rails, Spring, Go, Node) have documented them.
Common pitfalls and how to avoid them
- Weak enforcement: add architecture tests to CI, not just a doc.
- Shared common kernel: don’t let a giant Common or Utils package become the monolith hiding place. Keep shared code minimal and focused.
- Ignoring idempotency: when you later add durable queues, handlers must be idempotent. Build that habit early.
- Cross-module joins: avoid them. They’re the hardest coupling to break.
Designing for extractability, not early extraction
The goal is to make extraction possible and low-cost if it ever becomes necessary—design boundaries, own schemas, publish events, and keep adapters at the composition root. A common migration path is the strangler fig: route specific endpoints to a new service while keeping the monolith as a fallback.
Be deliberate about transactions. Being in-process gives you the superpower to atomically update multiple modules when that atomicity is a real business requirement—use it judiciously and confine it to the composition layer.
Conclusion: avoid the premature tax
Modular monolith architecture gives most teams the best balance between velocity and long-term flexibility. It provides clear ownership, strong local guarantees, and an inexpensive path to async decoupling while avoiding the operational overhead of hundreds of deployable services.
When the four signals above show up, you’ll be in a safer place to extract. Until then, prioritize disciplined modularity, automated enforcement, and the internal event bus. You’ll ship faster, pay less, and still be able to split cleanly if the data and traffic demand it.
What was the biggest signal that made your team extract — or decide to stay monolith-first? Share the metric or moment that mattered.
Top comments (0)