System Design: Modular Monoliths Architecture Before Microservices

There’s a moment in many software projects when someone says:
“Our application is getting too big. We should move to microservices.”
It sounds reasonable. Split the application into smaller services, give each service a responsibility, and let teams develop independently.
But suddenly, you have network failures, service discovery, distributed tracing, retries, multiple deployments, and data consistency problems.
You wanted to simplify your codebase. Instead, you created several new systems to manage.
There’s a middle ground that many teams overlook: the modular monolith.
It’s one of the most useful architectural patterns to understand, whether you’re building a real application or preparing for a system design interview.
1. What exactly is a modular monolith?
A monolith is an application deployed as one unit.
A modular monolith takes that idea and adds something important: strict boundaries between parts of the application.
Imagine you’re building an e-commerce platform.
You might have modules for:
- Users and authentication
- Products and inventory
- Orders
- Payments
- Notifications
In a poorly structured monolith, all these parts can directly access each other’s code and database tables. Over time, changing one feature becomes risky because dependencies are everywhere.
In a modular monolith, each module owns a specific business responsibility and exposes a well-defined interface.
The application still runs and deploys as one unit, but its internal structure resembles a collection of independent components.
Here’s the difference:

The critical distinction is not how many folders you create. It’s whether the boundaries are actually enforced. You can have 50 folders and still have a tightly coupled monolith.
2. Let’s design one
Suppose we’re building an online store using TypeScript and Node.js. A reasonable project structure could look like this:
src/
modules/
orders/
api/
application/
domain/
infrastructure/
index.ts
inventory/
api/
application/
domain/
infrastructure/
index.ts
payments/
api/
application/
domain/
infrastructure/
index.ts
users/
api/
application/
domain/
infrastructure/
index.ts
shared/
logging/
database/
configuration/
app.ts
The exact folders depend on your framework and team’s conventions. The important part is what each layer does.
- API: Handles incoming HTTP requests and validates input.
- Application: Coordinates use cases, such as placing an order.
- Domain: Contains business rules, such as whether an order can be cancelled.
- Infrastructure: Implements database access and external integrations.
index.ts: Exposes the module’s supported public interface.
Notice that the Orders module does not need to know how Payments stores its data internally. It only needs to know how to request a payment through an agreed interface.
3. The most important rule: protect module boundaries
Consider this implementation:
// orders/createOrder.ts
import { db } from "../../shared/database";
async function createOrder(userId: string) {
const user = await db.user.findUnique({
where: { id: userId }
});
const inventory = await db.inventory.findMany();
// More database queries and business logic...
}
This looks simple, but the Orders module now knows about the database structure of other parts of the application.
As the codebase grows, developers start accessing tables and internal functions wherever convenient. Eventually, changing the inventory schema can break order creation, user management, and other features.
A better approach is to expose a contract:
// inventory/index.ts
export interface InventoryService {
reserve(
productId: string,
quantity: number
): Promise<{
reservationId: string;
}>;
}
Orders can depend on that interface rather than reaching into Inventory’s internal implementation.
// orders/createOrder.ts
import type { InventoryService } from "../inventory";
export class CreateOrder {
constructor(private inventory: InventoryService) {}
async execute(productId: string) {
const reservation = await this.inventory.reserve(productId, 1);
// Continue the order workflow.
return reservation;
}
}
This example illustrates the boundary, not a complete checkout implementation. A real order workflow would also handle payment failures, transaction boundaries, idempotency, and reservation expiry.
The important architectural idea is that the caller depends on a stable contract, not the internal details of another module.
In TypeScript, interfaces help express that contract, but they do not enforce architectural boundaries by themselves. You may also need package boundaries, import restrictions, dependency rules, or architecture tests.
4. Who owns the data?
This is where modular monoliths often succeed or fail.
Suppose both Orders and Inventory directly update the inventory table. Even if their source code is in separate folders, they aren’t truly independent modules. Both own part of the same business rule.
A stronger design gives Inventory responsibility for inventory operations. Orders requests a reservation. Inventory validates stock and performs the relevant update.
A module should ideally own:
- Its business rules
- Its data access
- Its internal domain objects
- Its supported operations
This doesn’t mean every module must have its own database server. A modular monolith can use one relational database while maintaining logical ownership of tables and writes.
For example:

Avoid letting modules casually update each other’s tables. Where cross-module reads are needed, use explicit query interfaces or well-defined read models.
This gives you many of the maintainability benefits of service boundaries without introducing a network call for every internal interaction.
5. How do modules communicate?
There are two common approaches.
Synchronous calls
Orders calls Inventory directly through its interface:
Orders → Inventory → Database
This is straightforward and useful when the caller needs an immediate result, such as knowing whether stock can be reserved.
Asynchronous events
A module publishes an event after something happens:
Orders
│
│ OrderPlaced
▼
Notifications
│
▼
Send confirmation email
The Notifications module reacts to OrderPlaced without Orders needing to know how notifications are implemented.
Inside a modular monolith, an event can initially be handled in-process. If the system later needs durable asynchronous processing, a message broker and transactional outbox can be introduced.
One caution: an in-memory event can be lost if the process crashes before the event is handled. For important workflows, use durable delivery and idempotent consumers rather than assuming that an event was processed successfully.
Don’t use events everywhere just to make the architecture look sophisticated. If a direct function call is clearer and sufficient, use it.
6. Modular monolith vs. microservices
This is a particularly useful comparison for system design interviews.

Neither architecture is automatically superior.
Microservices are useful when independent deployment, different scaling requirements, fault isolation, or team autonomy justify the additional complexity. A modular monolith is often a better starting point when a product is still evolving, the team is small, and the boundaries between domains are not yet well understood.
7. When should you actually split a module into a microservice?
Here’s a realistic scenario.
Your application starts with Orders, Inventory, Payments, and Notifications in one deployable unit. Over time, Notifications grows substantially. It processes millions of messages, needs independent scaling, and has different reliability requirements from the checkout application. Now you have a concrete reason to consider extracting Notifications.
The transition might look like this:
- Define the Notifications module’s public interface.
- Remove direct dependencies on other modules’ internal data.
- Introduce durable events for notification requests.
- Move the module behind a service API or message consumer.
- Deploy it independently.
- Add monitoring, retries, idempotency, and failure recovery.
If the original module boundaries were enforced, extraction is much easier. If every module directly accesses everyone else’s tables, splitting the application becomes a major rewrite. This is the real strategic advantage of modularity: you preserve the option to distribute the system later without paying the operational cost of microservices from day one.
Of course, extraction is never free. You still need to design network contracts, data ownership, deployment, observability, and distributed failure handling.
8. Interview question: how would you choose?
Imagine an interviewer asks:
“We’re building a new e-commerce platform. Would you use a monolith or microservices?”
A weak answer immediately chooses microservices because the system might eventually become large. A stronger answer starts by asking about the constraints:
- How large is the engineering team?
- How independently must features be deployed?
- Are workloads significantly different?
- Are there clear domain boundaries?
- What reliability and operational requirements exist?
- How much infrastructure can the team realistically maintain?
Then explain your starting point:
“I’d start with a modular monolith, using explicit domain boundaries around Orders, Inventory, Payments, and Users. I’d enforce module interfaces and data ownership, while keeping deployment and transactions relatively simple. If a module develops a concrete need for independent scaling, deployment, or fault isolation, I’d consider extracting it into a service. I’d make that decision based on measured bottlenecks and organizational needs rather than anticipated scale alone.”
That answer demonstrates trade-off reasoning instead of treating an architecture pattern as a universal solution.
Three follow-up questions worth preparing for
Q: Can a modular monolith scale?
Yes. You can scale its deployment horizontally, optimize database access, cache frequently accessed data, and move slow tasks to background workers. Independent scaling of individual modules is harder, but many applications don’t initially need it.
Q: What is the biggest risk of a modular monolith?
Boundaries eroding over time. Developers take shortcuts, modules access each other’s internals, and the application becomes a tightly coupled monolith again. Architecture tests, clear ownership, and code reviews help prevent this.
Q: Is a modular monolith just a monolith with folders?
No. Folders organize files. A modular monolith enforces boundaries around responsibilities, dependencies, and data ownership. The distinction is architectural, not cosmetic.
The takeaway
Microservices solve real problems, but they also introduce real costs. A modular monolith lets you structure a system around clear business boundaries while keeping deployment, communication, and data transactions comparatively simple.
If you’re preparing for system design interviews, remember this principle: start with the simplest architecture that preserves the boundaries your system needs. Add distributed complexity when a real requirement justifies it. The goal isn’t to avoid microservices forever. It’s to make sure you have a good reason before adopting them.
Thanks for reading my article. Follow me for more.
#ml #systemdesign #databases #interview #ai