Introduction
- Should we build a monolith?
- Should we jump straight into microservices?
- Or is there a better middle ground?
“"Most teams don’t fail because of monoliths. They fail because of poorly structured monoliths."”
Understanding the Problem with Traditional Monoliths
What is a Traditional Monolith?
- 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.
““Big Ball of Mud””
The solution Modular Monolith?
“"Microservices discipline inside one codebase."”
- Clear module boundaries
- Explicit communication contracts
- Internal event-driven communication
- Feature ownership separation
Why Modular Monolith Is the Best Starting Point
- 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
- How modules talk to each other
- When to use sync vs async communication
- Immediate response is required
- Data must be returned instantly
// billing.service.ts
import { userService } from "../users";
const user = await userService.getUserById(userId);- Never access another module’s repository directly
- Only import from its index.ts
- Side effects happen
- Immediate response isn’t required
eventBus.publish("UserRegistered", {
userId,
email
});eventBus.subscribe("UserRegistered", async (payload) => {
await createTrialSubscription(payload.userId);
});- You decouple modules
- You prepare for microservices extraction
| Event-Driven Within Monolith | In Microservices |
|---|---|
| In-memory event bus | Kafka / RabbitMQ |
| Direct function calls | HTTP / gRPC |
| Shared DB | Independent DBs |
Recommended Feature-Based Folder Structure (Stack-Agnostic)
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.ts- 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
src/
modules/
users/
user.controller.ts
user.service.ts
user.repository.ts
events/
user.events.ts
user.listeners.ts
index.ts- Validates business rules
- Coordinates repository calls
- Publishes events
- Does NOT know about HTTP
// 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);
}
}“Service contains business rules + orchestrates module behavior.”
- No business logic
- No database logic
- Only validation + calling service layer
// 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);
};“"Controller talks only to Service, nothing else."”
- Talks directly to the database
- No business logic
- No event publishing
// 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 });
}
}“Repository should ONLY interact with database.”
// Note: Defines event names clearly.
// events/user.events.ts
export const USER_REGISTERED = "UserRegistered";// 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)
});“The “gateway” to the module”
// 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
import { userRepository } from "../users/user.repository"; ❌import { userService } from "../users"; ✅Example: Billing Using Users (Proper Way)
// billing.service.ts
// Note: This is from the users/index.ts
import { userService } from "../users";
const user = await userService.getUserById(userId);import { userRepository } from "../users/user.repository"; ❌- Clear responsibility separation
- Replaceable data layer
- Replaceable event bus
- Replaceable communication layer
How Modular Monolith Scales to Microservices
- Identify heavy module (e.g., billing)
- Replace internal calls with HTTP
- Replace event bus with message broker
- Move module into independent repo
import { userService } from "../users";await axios.get("users-service/api/users/:id");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
Final Thoughts
- Start simple.
- Design with boundaries.
- Communicate intentionally.
- Scale when necessary.
“Monolith or Microservices?”
“How do we design today so we can scale tomorrow?”
Mike Kanu
Author
AI Software Engineer | Technical Adviser | Writter
Comments (0)
Sign in to join the conversation
