Introduction
When building startup products, many engineers face the same architectural question:
- Should we build a monolith?
- Should we jump straight into microservices?
- Or is there a better middle ground?
After working across SaaS systems, multi-tenant platforms, and scalable cloud deployments, I’ve seen a recurring pattern:
“"Most teams don’t fail because of monoliths. They fail because of poorly structured monoliths."”
This is where Modular Monolithic Architecture becomes the smartest starting point.
Understanding the Problem with Traditional Monoliths
What is a Traditional Monolith?
A traditional monolith is a single deployable unit where:
- Business logic is tightly coupled: Changing one feature breaks another.
- Modules share direct database access: Changes in the database schema by one module can break others
- Dependencies are messy: When scaling is required, there are no clean module boundaries.
- Code boundaries are unclear: When scaling is required, there are no clean module boundaries.
Over time, it becomes what many engineers call a:
““Big Ball of Mud””
The solution Modular Monolith?
A Modular Monolith is still a single deployable application, but internally structured as isolated modules.
The best way to look at it is as:
“"Microservices discipline inside one codebase."”
It enforces:
- Clear module boundaries
- Explicit communication contracts
- Internal event-driven communication
- Feature ownership separation
Why Modular Monolith Is the Best Starting Point
For startups and early-stage products, this approach provides:
- Simplicity of Deployment
- Single deployment.
- Single database.
- No distributed complexity.
- Clear Architecture From Day One
- You design modules as if they were services.
- Easy Migration to Microservices Later
- Because boundaries already exist.
- Faster Development
- No DevOps overhead of managing multiple services early.
Communication Strategy Inside a Modular Monolith
You must define:
- How modules talk to each other
- When to use sync vs async communication
There are two valid patterns.
1. Synchronous Communication (Internal APIs)
Used when:
- Immediate response is required
- Data must be returned instantly
Example:
Code
// billing.service.ts
import { userService } from "../users";
const user = await userService.getUserById(userId);Rules:
- Never access another module’s repository directly
- Only import from its index.ts
2. Event-Driven Communication (Asynchronous)
Used when:
- Side effects happen
- Immediate response isn’t required
Example:
Code
eventBus.publish("UserRegistered", {
userId,
email
});Another module listens:
Code
eventBus.subscribe("UserRegistered", async (payload) => {
await createTrialSubscription(payload.userId);
});This is extremely important because:
- You decouple modules
- You prepare for microservices extraction
This concept aligns strongly with principles from Domain-Driven Design
| Event-Driven Within Monolith | In Microservices |
|---|---|
| In-memory event bus | Kafka / RabbitMQ |
| Direct function calls | HTTP / gRPC |
| Shared DB | Independent DBs |
The architectural pattern stays the same. Only the infrastructure changes, and that's the power.
Recommended Feature-Based Folder Structure (Stack-Agnostic)
Bash
src/
modules/
auth/
auth.controller.ts
auth.service.ts
auth.repository.ts
events/
index.ts
users/
user.controller.ts
user.service.ts
user.repository.ts
events/
index.ts
billing/
billing.controller.ts
billing.service.ts
billing.repository.ts
events/
index.ts
shared/
database/
event-bus/
utils/
app.tsKey Principles:
- Modules cannot import each other directly.
- Communication happens via:
- Events (async)
- Internal APIs (sync)
- The shared layer contains infrastructure only.
users Module – Full Example Breakdown
Let's walk through the users module so you understand responsibilities clearly.
Bash
src/
modules/
users/
user.controller.ts
user.service.ts
user.repository.ts
events/
user.events.ts
user.listeners.ts
index.ts1. Service: 🎯 Business Logic Layer
- Validates business rules
- Coordinates repository calls
- Publishes events
- Does NOT know about HTTP
Code
// user.service.ts
import { userRepository } from "./index";
import { eventBus } from "../../shared/event-bus";
export class UserService {
async createUser(data: { email: string; name: string }) {
const existingUser = await userRepository.findByEmail(data.email);
if (existingUser) {
throw new Error("User already exists");
}
const newUser = await userRepository.create(data);
// Publish domain event
eventBus.publish("UserRegistered", {
userId: newUser.id,
email: newUser.email,
});
return newUser;
}
async getUserById(id: string) {
return userRepository.findById(id);
}
}Key Rule:
“Service contains business rules + orchestrates module behavior.”
2. Controller: 🎯 Handle HTTP requests only
- No business logic
- No database logic
- Only validation + calling service layer
Code
// user.controller.ts
import { userService } from "./index";
export const createUserController = async (req, res) => {
try {
const { email, name } = req.body;
const user = await userService.createUser({ email, name });
res.status(201).json(user);
} catch (error) {
res.status(400).json({ message: error.message });
}
};
export const getUserController = async (req, res) => {
const user = await userService.getUserById(req.params.id);
res.json(user);
};Key Rule:
“"Controller talks only to Service, nothing else."”
3. Repository: 🎯 Data Access Layer
- Talks directly to the database
- No business logic
- No event publishing
Code
// user.repository.ts
import { db } from "../../shared/database";
export class UserRepository {
async create(data: { email: string; name: string }) {
return db.users.insert(data);
}
async findByEmail(email: string) {
return db.users.findOne({ email });
}
async findById(id: string) {
return db.users.findOne({ id });
}
}Key Rule:
“Repository should ONLY interact with database.”
4. Events Folder: 🎯 Domain Events & Event Listeners
This is what makes it “microservice-ready”.
Code
// Note: Defines event names clearly.
// events/user.events.ts
export const USER_REGISTERED = "UserRegistered";Code
// Note: Handles async side effects.
// events/user.listeners.ts
import { eventBus } from "../../../shared/event-bus";
import { USER_REGISTERED } from "./user.events";
eventBus.subscribe(USER_REGISTERED, async (payload) => {
console.log("Sending welcome email to:", payload.email);
// trigger email module (via API, not direct DB access)
});5 index.ts: 🎯 Module Boundary & Public API
This file is extremely important in a modular monolith. It acts like:
“The “gateway” to the module”
Code
// index.ts
import { UserService } from "./user.service";
import { UserRepository } from "./user.repository";
const userRepository = new UserRepository();
const userService = new UserService();
export { userService };
export { userRepository }; // optionalImportant Architectural Rule
Other modules should NOT do this:
Typescript
import { userRepository } from "../users/user.repository"; ❌They should only import from:
Typescript
import { userService } from "../users"; ✅This enforces module boundary discipline.
Example: Billing Using Users (Proper Way)
Code
// billing.service.ts
// Note: This is from the users/index.ts
import { userService } from "../users";
const user = await userService.getUserById(userId);Not:
Code
import { userRepository } from "../users/user.repository"; ❌This is how you prevent tight coupling, which helps you have.
- Clear responsibility separation
- Replaceable data layer
- Replaceable event bus
- Replaceable communication layer
How Modular Monolith Scales to Microservices
When traffic grows:
- Identify heavy module (e.g., billing)
- Replace internal calls with HTTP
- Replace event bus with message broker
- Move module into independent repo
Example migration: Before
Code
import { userService } from "../users";After extraction:
Code
await axios.get("users-service/api/users/:id");Because your boundaries already exist, refactor is minimal.
When Should You NOT Use Modular Monolith?
- When you have 30+ engineers from day one
- When independent scaling is required immediately
- When regulatory isolation requires separate deployments
Otherwise: It is almost always the smarter start.
Final Thoughts
Architecture should evolve with product maturity.
- Start simple.
- Design with boundaries.
- Communicate intentionally.
- Scale when necessary.
The question is not:
“Monolith or Microservices?”
The better question is:
“How do we design today so we can scale tomorrow?”
For most startups, the answer is: Modular Monolith.
MK
Mike Kanu
Author
AI Software Engineer | Technical Adviser | Writter
Comments (0)
Sign in to join the conversation
