The Modular Monolith: How to Buy Yourself Two Years
The modular monolith is a single deployable application that is internally organized into well-defined modules with explicit interfaces between them. Each module owns its data and business logic; no module accesses another module's database tables directly. This structure gives teams the deployment simplicity of a monolith while building the internal boundaries that make future extraction to microservices or separate services tractable. The modular monolith is the right architecture for most products until they reach genuine scale, which is 10-50x more users than most teams expect.
Written by Yashveer Singh, founder of Yashveer Labs.
What you actually need to know
- The modular monolith is the right default architecture for most products until they reach genuine scale. Premature microservices is one of the most common architectural mistakes in startups.
- The difference between a modular monolith and a big-ball-of-mud is enforced boundaries: no direct cross-module database access, no shared mutable state, explicit public interfaces.
- Module boundaries should follow business domains (User, Billing, Notifications, Analytics), not technical layers (Models, Services, Controllers).
- Cross-module communication via events (publish/subscribe) is better than direct function calls for operations that do not require atomicity.
- A well-structured modular monolith on adequate infrastructure handles hundreds of thousands of users. Most teams extract services years later than they expect to.
| Architecture | Deployment Complexity | Internal Coupling Risk | Scaling Flexibility | Team Size |
|---|---|---|---|---|
| Big-ball-of-mud monolith | Low | Very High | Low | Any (short term) |
| Modular monolith | Low | Low (if enforced) | Medium | 1-10 engineers |
| Microservices (premature) | Very High | Low | High | 2-10 engineers (excessive) |
| Microservices (appropriate) | Very High | Low | Very High | 10+ engineers per service |
The core argument
The startup that starts with microservices has made a bet that they know their domain boundaries before shipping the first version. This bet is almost always wrong. Domain boundaries become clear from usage patterns, business evolution, and team structure -- not from architecture planning sessions. The microservices architecture that is extracted from a working monolith after two years of production usage is built on real knowledge of the domain. The microservices architecture built before the first users arrive is built on speculation.
The cost of this speculation is distributed system complexity: service-to-service communication, distributed tracing, independent deployments for each service, network failure handling, and the operational overhead of maintaining multiple independent services. For a team of 3-5 engineers building a product that needs to find product-market fit, this overhead consumes engineering capacity that should go into the product.
The modular monolith is the middle path: a single deployable application with enforced internal boundaries that make future extraction tractable. It avoids the operational complexity of microservices while avoiding the coupling of a traditional monolith. The boundaries built into the modular monolith are the same boundaries that would define service boundaries if the application were split -- so the split, if it ever becomes necessary, is guided by real domain knowledge and existing module structure.
The folder structure
A modular monolith organized by business domain:
``` src/ modules/ users/ users.model.ts -- User database schema users.service.ts -- User business logic users.repository.ts -- User database queries users.routes.ts -- User API routes users.types.ts -- User TypeScript types index.ts -- Public interface (what other modules can import)
billing/ billing.model.ts billing.service.ts billing.repository.ts billing.routes.ts billing.types.ts index.ts
notifications/ notifications.service.ts notifications.queue.ts notifications.templates.ts index.ts
shared/ database/ connection.ts migrations/ middleware/ utils/ types/ ```
The index.ts in each module exports only what other modules are allowed to use:
``typescript // modules/billing/index.ts export { BillingService } from './billing.service'; export type { Subscription, Plan } from './billing.types'; // Note: BillingRepository is NOT exported -- internal to billing module ``
An ESLint rule enforces that other modules import only from modules/billing/index.ts, not from internal files:
``json // .eslintrc.json { "rules": { "no-restricted-imports": ["error", { "patterns": [ { "group": ["*/modules/billing/*", "!*/modules/billing/index"], "message": "Import from modules/billing/index only" } ] }] } } ``
Cross-module communication
When a module needs information from another module, it calls the other module's public interface:
```typescript // modules/users/users.service.ts import { BillingService } from '../billing'; // only the public interface
export class UserService { constructor(private billing: BillingService) {}
async getCurrentPlan(userId: string): Promise<Plan> { // Calls billing module's public function, not the database directly return this.billing.getUserPlan(userId); } } ```
For operations that should propagate asynchronously across module boundaries, use domain events:
```typescript // modules/billing/billing.service.ts import { EventEmitter } from '../shared/events';
export class BillingService { async activateSubscription(userId: string, planId: string) { await this.repository.updateSubscription(userId, { status: 'active', planId });
// Publish event -- other modules listen and react independently EventEmitter.emit('subscription_activated', { userId, planId }); } }
// modules/users/users.service.ts EventEmitter.on('subscription_activated', async ({ userId, planId }) => { await this.grantPlanFeatures(userId, planId); }); ```
The Billing module's subscription activation is atomic within its own database transaction. The User module's feature grant is a separate operation triggered by the event. If the feature grant fails, it can be retried without affecting the subscription activation. This eventual consistency is acceptable for most feature-granting scenarios.
The database ownership model
Each module owns its database tables. The Billing module queries billing_subscriptions, billing_invoices, and billing_plans. The User module queries users and user_profiles. Neither module queries the other's tables directly.
```typescript // modules/billing/billing.repository.ts export class BillingRepository { async getUserSubscription(userId: string) { // Can query billing_subscriptions -- this is billing's table return db.select().from(billingSubscriptions) .where(eq(billingSubscriptions.userId, userId)) .limit(1); } }
// modules/users/users.repository.ts export class UserRepository { async getUser(userId: string) { // Can only query users table -- cannot join to billing_subscriptions return db.select().from(users) .where(eq(users.id, userId)) .limit(1); }
// To get subscription info, call BillingService, not query the table } ```
The enforcement mechanism is code review culture and ESLint rules. Some teams use database-level role permissions (each module has its own database role with access only to its tables) for stronger enforcement, but this is only necessary for regulated environments or very large teams.
When to extract a module into a separate service
Three signals that a specific module warrants extraction to a separate service:
Scaling requirements diverge. The notification delivery module processes 50,000 events per minute; the rest of the application handles 500 requests per minute. The notification module needs horizontal scaling; the application does not. Separate deployment allows independent scaling.
Deployment cadence diverges. A payments service has strict deployment processes (mandatory review, staging validation, explicit rollout). The rest of the application deploys continuously. Separating payments allows its conservative cadence without blocking the main application's continuous delivery.
Team ownership becomes clear. A module that one team always owns and others never touch is a natural service boundary. The team-service alignment is one of Conway's Law's most useful prescriptions: structure the architecture to match the team structure.
Common mistakes teams make with modular monolith structure
- Starting with modular structure but not enforcing the boundaries. A modular monolith with a well-designed folder structure but no import restrictions is a traditional monolith with extra steps. The ESLint rule that prevents cross-module internal imports is what makes the structure real.
- Putting shared business logic in the
shared/folder. Theshared/folder is for technical utilities (database connection, logging, HTTP middleware), not for business logic that multiple modules use. Business logic that multiple modules need is a signal that the module boundary is in the wrong place. - Using a global event bus for operations that require atomicity. Events are for eventual consistency; they are not a replacement for transactions. The payment that must atomically update the subscription and grant feature access cannot use events -- it needs a transaction, and the transaction must stay within a module.
- Naming modules after technical concepts instead of business domains. A
services/module, amodels/module, and acontrollers/module is a technical layer structure. Abilling/module, ausers/module, and anotifications/module is a domain structure. The domain structure produces modules that are stable as the team grows; the technical structure produces modules that are split based on file type rather than cohesion. - Planning to extract to microservices from day one. The team that plans for microservices from the start builds the modular structure but also builds the distributed system infrastructure "to be ready." This produces the operational complexity of microservices without the scaling benefit.
Where to start: a 3-step modular monolith setup
Step 1: Identify the four to six primary business domains. Billing, Users, Notifications, Search, Analytics, and Content are common examples. Each domain becomes a module. Resist adding more than six for the initial structure -- the granularity can be increased later.
Step 2: Create the module folder structure and write an `index.ts` for each module that exports only the public interface. This establishes the boundary immediately and makes it concrete. Add the ESLint import restriction rule before any cross-module code is written.
Step 3: Move existing code into the module structure and fix any cross-module database access. For an existing codebase, this is the most work. For a new project, this starts correctly from the beginning. Identify each case where one module's code queries another module's tables and replace with a service call.
The Monolith That Scales
Yashveer Singh. Founder of Yashveer Labs. I have built the same product with a modular monolith and later worked with a team that had chosen microservices for a product at a similar scale. The microservices team spent 30 percent of their engineering time on infrastructure and service coordination. The modular monolith team spent 5 percent. The microservices team had three engineers who specialized in the distributed system infrastructure; the modular monolith team had none. At 50,000 users, neither product had a scaling problem that the architecture was the bottleneck for. The modular monolith team had 30 percent more engineering capacity available for the product. That is the argument for the modular monolith at early to mid scale: not that it is architecturally superior, but that it preserves engineering capacity for the product, which is the scarce resource that actually determines startup outcomes.
Related reading
- The Five Architectural Failures That Killed Startups I Worked With
- The Multi-Tenant Database: One Schema or Many?
- The Hidden Cost of Eventual Consistency: A SaaS Postmortem
- The SaaS Architecture Checklist Before You Scale
Frequently asked
The engineer behind this page
This was written by Yashveer Singh. Full stack developer, founder of Yashveer Labs, currently in Class 12 in New Delhi, shipping production systems while most of my peers are still writing their first console app. I am pointing the work, on purpose, at machine learning, AI engineering, and cybersecurity. If you are reading this because you want to hire someone who will not waste your time or your money, that is the role I am built for.
Posts that line up with this one.
- SaaS Architecture and Scaling
Idempotency in API Design: Why It Matters More Than You Think
An idempotent API is one that handles repeated requests gracefully. Building it in from the start is far cheaper than retrofitting it after your first double-charge incident.
- SaaS Architecture and Scaling
Internal Admin Tools: Build vs Buy vs Retool
Every SaaS needs internal tools. The question is whether to build them, buy a platform like Retool, or use a lighter alternative. Here is the decision framework that saves engineering hours without creating tool debt.
- SaaS Architecture and Scaling
Job Failure Recovery: How Good SaaS Companies Sleep at Night
Every background job will fail eventually. The companies that sleep at night are the ones that built failure recovery into the system from day one, not as an afterthought when something broke in production.
- SaaS Architecture and Scaling
Monolith vs Microservices: Why Most Startups Get It Wrong
Microservices are the architecture that works at Netflix and fails at early-stage startups. Here is why the monolith is the right default, when microservices become rational, and how to make the transition without breaking everything.