Designing a Modular Monolith Before You Need Microservices
The team ships in month three. The codebase has six services, a service mesh nobody fully understands, three Kafka topics that may or may not have consumers, and a distributed tracing setup that is perpetually "almost working." Nobody has written a single line of actual product differentiating logic yet — they have been busy wiring infrastructure.
This pattern is far more common than it should be. Microservices are a scaling solution dressed up as an architectural philosophy, and adopting them at the wrong moment is one of the most expensive mistakes an early-stage SaaS team can make. The modular monolith is the deliberate, defensible alternative — and it is not a fallback. It is an architecture that earns its place.
What a Modular Monolith Actually Is
A modular monolith is a single deployable unit whose internal structure is divided into well-defined, loosely coupled modules with enforced boundaries. It is not a "big ball of mud" with good intentions. The key distinction is that modules communicate through explicit interfaces — not by reaching directly into each other's database tables or internal functions.
Think of it as microservices-level discipline applied to a single process. You get the organizational clarity of service boundaries without the operational overhead of distributed systems: no network latency between your own modules, no need for service discovery, no serialization tax on every internal call, and a dramatically simpler local development experience.
A Concrete Folder Structure
Structure is where good intentions either hold or collapse. Here is a folder layout that has worked well for SaaS backends built with Node.js or Python, though the principle applies universally:
src/
├── modules/
│ ├── billing/
│ │ ├── billing.controller.ts
│ │ ├── billing.service.ts
│ │ ├── billing.repository.ts
│ │ └── billing.types.ts
│ ├── notifications/
│ │ ├── notifications.service.ts
│ │ ├── notifications.types.ts
│ │ └── providers/
│ │ ├── email.provider.ts
│ │ └── sms.provider.ts
│ ├── auth/
│ └── workspace/
├── shared/
│ ├── database/
│ ├── events/ ← internal event bus
│ ├── config/
│ └── utils/
└── main.ts
Each module owns its own controller, service, and repository layer. The shared/ directory is not a dumping ground — it holds only genuinely cross-cutting concerns like database connection setup, configuration loading, and the internal event bus. If a utility is only used by one module, it lives inside that module.
Enforcing Module Boundaries
A folder structure alone is not enforcement. Engineers under deadline pressure will reach across boundaries if nothing stops them. Here is how to make the boundaries real:
Use an Internal Event Bus for Cross-Module Communication
When the billing module needs to notify notifications that a payment failed, it should not import NotificationsService directly. Instead, it emits an internal domain event:
billingemits →payment.failednotificationssubscribes →payment.failed
This keeps the dependency graph acyclic and mirrors exactly the pattern you would use if these modules were ever extracted into separate services — making future extraction nearly mechanical.
Establish a Public API Per Module
Each module exposes a single index file that defines its public surface area. Anything not exported from that file is private. In TypeScript projects, ESLint rules like import/no-internal-modules can enforce this automatically. In Python, __init__.py files serve the same gating function.
Separate Databases by Module — Logically, If Not Physically
You do not need separate database instances per module at this stage. What you do need is that each module only queries its own tables. The billing module never joins directly against workspace tables — it calls the workspace module's service method and gets back a typed response. This is the single rule that prevents your schema from becoming an unmaintainable tangle and keeps future service extraction viable.
The Operational Advantages Are Real
Teams that make this choice early report a few consistent benefits worth naming explicitly:
- Atomic transactions are trivial. When billing and workspace operations need to succeed together, a database transaction wraps both. In a distributed system, you are writing saga patterns and compensating transactions instead.
- Debugging is linear. A stack trace in a monolith tells you exactly what happened. A distributed trace tells you approximately what probably happened across services that may have logged at different verbosity levels.
- Deployment is a solved problem. One artifact, one deployment pipeline, one rollback command. Your DevOps complexity stays proportional to your actual team size.
- Onboarding is faster. A new engineer can clone one repo, run one command, and have the entire system running locally in minutes.
The Exact Signals That Tell You to Extract a Service
The modular monolith is not a permanent destination for every team. These are the genuine signals — not hype-driven ones — that indicate a module is ready to become its own service:
- Independent scaling requirements. If your video processing module is consuming resources that degrade response times for your API layer, extraction and independent scaling become economically justified.
- Separate deployment cadence driven by team structure. Once a dedicated team owns a module and their releases are being blocked by another team's deployment schedule, Conway's Law is telling you something.
- A clear, stable interface already exists. If the module's internal event contracts and public API have not changed in three months, extraction carries low risk. Extracting a module with a moving interface is an expensive mistake.
- Compliance or security isolation mandates it. Payment processing modules that must meet PCI-DSS requirements sometimes need hard network-level isolation, not just logical separation.
- The monolith deploy time is materially affecting developer productivity. This is a late signal, but a legitimate one.
Notice what is not on this list: "because Netflix does it," "because we want to use Kubernetes," or "because microservices are more professional." Those are not engineering reasons.
Building the Bridge Between Both Worlds
The most underrated property of a well-designed modular monolith is that it makes eventual microservice extraction low-risk and low-drama. When module boundaries are clean, the event contracts are already defined, the data ownership is already scoped, and the public APIs are already stable — extracting a service is largely a deployment and infrastructure problem, not an architectural redesign.
The teams that go straight to microservices often end up building a distributed monolith: all the operational complexity of distributed systems with none of the clean boundaries that make them worthwhile. The teams that start with a disciplined modular monolith tend to extract services surgically, at the right time, with high confidence.
Why This Matters for Your Project
Whether you are building a new SaaS product or inheriting a growing backend, the architecture you choose in the first six months will determine how fast you can move in month eighteen. A modular monolith designed with real boundary discipline gives your team the speed of simplicity today and the optionality to scale tomorrow — without paying the distributed systems tax before you have earned the need for it. If you are starting fresh, this is the architecture worth defaulting to.




