A practical guide to decomposing a monolithic application into a modular architecture — when to do it, how to do it incrementally, and why jumping straight to microservices is usually a mistake.
What is a modular monolith? A modular monolith is a software architecture where the application is deployed as a single unit (like a traditional monolith) but internally organized into clearly separated, independent modules with defined boundaries and interfaces. It provides most of the organizational benefits of microservices — clear ownership, independent development, and easier testing — without the operational complexity of distributed systems. For most growing applications, a modular monolith is the ideal middle ground between a tangled monolith and premature microservices.
Every successful software product starts as a monolith. That's not a failure — it's efficient. When you're moving fast, proving a market, and figuring out what your product actually is, a single deployable unit makes perfect sense.
The problem isn't that you built a monolith. The problem is that you never stopped to organize it as it grew. Now you have a tangle — and every change is slower, riskier, and more frustrating than it should be.
This guide walks you through the practical process of turning that tangle into a well-organized modular architecture, without the risk and overhead of jumping to microservices.
A monolith isn't inherently bad. A tangled monolith is. Here's what that looks like:
If more than two of these resonate, your monolith has become a liability. But the answer isn't to burn it down and start over with microservices. The answer is to modularize.
Most discussions frame this as a binary: monolith vs. microservices. That's wrong. There's a spectrum:
| Architecture | Deployment | Internal Structure | Operational Complexity | Best For |
|---|---|---|---|---|
| Tangled monolith | Single unit | No clear boundaries | Low | Nothing — this is the problem state |
| Modular monolith | Single unit | Clear modules with defined interfaces | Low | Most applications (2–50 developers) |
| Modular monolith with separate data | Single unit, separate databases per module | Strong boundaries, isolated data | Medium | Applications needing strict data isolation |
| Microservices | Independent services | Fully independent | High | Large organizations (50+ developers), independent scaling needs |
The jump from "tangled monolith" to "modular monolith" gives you 80% of the benefits of microservices with 20% of the complexity. That's the move most teams should make.
We need to say this directly because the industry has been overselling microservices for a decade: microservices are an organizational scaling solution, not a technical one.
Microservices make sense when you have:
If you don't have all four, microservices will make your life harder, not easier. You'll trade one set of problems (tangled code) for another (distributed systems complexity):
We've seen startups with 8 developers running 25 microservices. They spent more time debugging infrastructure than building features. Don't be that team.
Before writing any code, map out the logical domains in your application. These are the business capabilities — not technical layers.
Wrong way to split (by technical layer):
Right way to split (by business domain):
Each domain should make sense as something a non-technical person can describe: "the billing system" or "the product catalog."
How to identify boundaries:
Create a physical folder structure that enforces your domain boundaries:
src/
modules/
users/
index.ts # Public API — the only thing other modules can import
users.service.ts
users.repository.ts
users.types.ts
__tests__/
billing/
index.ts
billing.service.ts
billing.repository.ts
billing.types.ts
__tests__/
orders/
index.ts
orders.service.ts
orders.repository.ts
orders.types.ts
__tests__/
The critical rule: modules can only communicate through their public API (the index.ts file). No reaching into another module's internals. If the billing module needs user data, it calls users.getById(id) — it never imports users.repository.ts directly.
Each module's public API should be a thin, stable interface:
// modules/users/index.ts
export interface UserModule {
getById(id: string): Promise<User>;
getByEmail(email: string): Promise<User | null>;
updateProfile(id: string, data: UpdateProfileInput): Promise<User>;
}This interface is a contract. Other modules depend on it. The implementation behind it can change freely — different database, different caching strategy, different validation — without affecting anything outside the module.
This is where the real work happens. You'll find circular dependencies, shared state, and unclear ownership. Tackle them methodically:
Circular dependencies: If Module A imports from Module B and Module B imports from Module A, you need to either:
Shared database tables: If both billing and orders write to the same transactions table, decide who owns it. The owner provides the interface; the other module calls it.
Utility dumping grounds: The utils/ or helpers/ folder that every module imports. Break these into specific utilities that belong to specific modules, or extract genuinely shared utilities into a shared/ module with a strict API.
Without enforcement, developers will take shortcuts and your modules will re-tangle within months. Use tooling:
eslint-plugin-import or eslint-plugin-boundaries to forbid cross-module internal importsThis isn't bureaucracy. It's the entire point. Without enforcement, you're just reorganizing folders.
Named after the strangler fig tree that grows around its host tree and eventually replaces it. Instead of rewriting all at once:
This pattern lets you ship working software throughout the entire migration. There's never a "big bang" cutover.
When you need to replace a deeply embedded component:
This is especially useful for replacing shared services like authentication, logging, or database access.
Be realistic about how long modularization takes:
| Application Size | Estimated Timeline | Approach |
|---|---|---|
| Small (10–30K lines) | 2–4 weeks | One focused developer can do it in a single pass |
| Medium (30–100K lines) | 1–3 months | Extract 1–2 modules per sprint alongside feature work |
| Large (100K+ lines) | 3–6 months | Dedicated team effort with phased rollout |
The 20% rule applies here: allocate 20% of development time to modularization work. This keeps feature development moving while steadily improving the architecture.
Critical success factors:
After your monolith is well-modularized, you might find that one or two modules genuinely need to be separate services. Common reasons:
When this happens, extracting a well-defined module into a service is straightforward — because the boundaries and interfaces already exist. That's the whole point. Modularize first, extract to services only when proven necessary.
If your monolith is causing pain, you don't need a grand architectural revolution. You need discipline, clear boundaries, and incremental improvement.
Start by mapping your domains. Extract one module. Enforce the boundaries. Repeat. In six months, you'll have a codebase that's dramatically easier to work in — without the operational nightmare of premature microservices.
If you're unsure where to start, a code audit can identify the highest-leverage module boundaries in your specific codebase — the places where extraction will have the biggest impact on development velocity.
For teams already dealing with the symptoms of a tangled codebase — slow deployments, cascading bugs, developer frustration — read our guide on recognizing technical debt and deciding whether to fix or rebuild.
Struggling with a monolith that's slowing your team down? Talk to us — we'll assess your architecture and give you an honest, practical modernization plan. Or get started with a technical audit.
If this guide resonated with your situation, let's talk. We offer a free 30-minute discovery call — no pitch, just honest advice on your specific project.
A no-nonsense guide to cloud migration — when it makes sense, the real costs, the different approaches, and how to move from on-premise or legacy hosting to modern cloud infrastructure without breaking everything.
11 min readguideA decision framework for businesses stuck with aging software — when incremental modernization works, when a full rebuild is the right call, and the hybrid approaches in between.
12 min read