A modular monolith ships as one deployable, but inside it has hard internal walls — explicit modules, defined interfaces, no cross-imports. You get monolith simplicity with the option to extract any module into its own service later, without rewriting it. For most companies, most of the time, this is the right default.
← Back to ArchitectureYou get clear bounded contexts, team ownership, swappable internals — and skip the service mesh, distributed tracing, schema registry, contract tests, and on-call rotations per service. The complexity stays inside one process where it's cheap.
Need to move a concept from one module to another? It's a code refactor in one repo, atomic in one PR. The same change across microservices is a multi-team, multi-deploy migration that takes weeks.
If a module never needs separate scaling or independent deploys, it stays in. You've paid no premium for keeping it. The architecture lets you defer the "should this be a service?" question until you have evidence — instead of guessing on day one.
Organize the codebase by bounded context, not by technical layer. Top-level folders are billing/, orders/, inventory/ — each containing its own controllers, services, repositories, and domain.
Anti-pattern: top-level controllers/, services/, repositories/. That's a layered monolith — modules end up smeared across all three.
Each module exposes a small public surface — a few interfaces, a few DTOs. Everything else is internal. Other modules call only the public surface; they have no idea what's behind it.
.api package; the rest in .internal.internal/ — the compiler refuses cross-module imports.index.ts per module exporting the public API; ESLint no-restricted-imports for the rest.public types reachable across project references.Without automation, modules drift back into a ball of mud within months. Tools that fail builds on boundary violations:
internal/ — built into the compiler.Same database, but each module owns its tables. No module reads or writes another module's tables directly. To get billing data, the orders module calls BillingFacade, not SELECT * FROM invoices.
This is the rule that pays off most when extracting. Modules with shared SQL joins are nearly impossible to split; modules that already talk via APIs are a one-week extraction.
Modules can publish events on an in-process bus (Spring's ApplicationEventPublisher, MediatR, simple pub/sub). Consumers subscribe without coupling to the producer. When you extract a module later, the in-process bus becomes Kafka or RabbitMQ — the producer/consumer code barely changes.
Without CI enforcement, the modules will quietly merge. A "quick fix" that imports across boundaries to ship a feature is the first crack; six months later, it's a regular monolith again. The architecture only works if the rules are mechanical.
Drawing module boundaries requires understanding the domain. Get it wrong and modules talk to each other constantly through awkward APIs. Bounded-context modeling (DDD) helps but takes time and senior judgment.
Still one process, still scales as a unit. If one module has a fundamentally different load profile — say a CPU-heavy ML inference module versus a CRUD admin module — you'll eventually need to extract it.
Default to this when:
When a module's scaling profile, team ownership, or release cadence diverges sharply from the rest, extract that one module. Do not extract everything; do not preemptively split based on speculation. Distillation, not big-bang.