Monolithic Architecture
Understanding monolithic architecture, its patterns, benefits, and when to use it
Monolithic Architecture
A monolithic architecture is a traditional unified model where all components of an application are interconnected and interdependent, deployed as a single unit. Despite the industry buzz around microservices, monoliths remain an excellent choice for many scenarios.
Types of Monolithic Architecture
Layered Architecture (N-Tier)
The most common monolithic pattern with horizontal layers.
// Layered Architecture Example
// Presentation Layer - Controller
const userController = {
getUser: async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
return res.json(UserDTO.fromEntity(user));
},
createUser: async (req: Request, res: Response) => {
const user = await userService.create(req.body);
return res.status(201).json(UserDTO.fromEntity(user));
}
};
// Business Layer - Service
const userService = {
findById: async (id: string) => {
const user = await userRepository.findById(id);
if (!user) throw new NotFoundError('User not found');
return user;
},
create: async (data: CreateUserInput) => {
const existing = await userRepository.findByEmail(data.email);
if (existing) throw new ConflictError('Email already exists');
const hashedPassword = await hashPassword(data.password);
return userRepository.create({ ...data, password: hashedPassword });
}
};
// Persistence Layer - Repository
const userRepository = {
findById: (id: string) => db.user.findUnique({ where: { id } }),
findByEmail: (email: string) => db.user.findUnique({ where: { email } }),
create: (data: UserCreateData) => db.user.create({ data })
};Modular Monolith
A monolith organized into loosely coupled modules with clear boundaries.
// Modular Monolith Structure
// src/
// ├── modules/
// │ ├── user/
// │ │ ├── user.module.ts
// │ │ ├── user.controller.ts
// │ │ ├── user.service.ts
// │ │ ├── user.repository.ts
// │ │ └── user.events.ts
// │ ├── order/
// │ │ ├── order.module.ts
// │ │ └── ...
// │ └── product/
// │ └── ...
// └── shared/
// ├── events/
// ├── types/
// └── utils/
// User Module - Encapsulated with public API
// modules/user/user.module.ts
import { userController } from './user.controller';
import { userService } from './user.service';
import { UserCreatedEvent } from './user.events';
export const UserModule = {
// Public API - only these are accessible from other modules
controllers: userController,
// Queries other modules can use
queries: {
findById: userService.findById,
findByEmail: userService.findByEmail,
},
// Commands other modules can use
commands: {
create: userService.create,
updateProfile: userService.updateProfile,
},
// Events this module emits
events: {
UserCreated: UserCreatedEvent,
}
};
// Order Module using User Module
// modules/order/order.service.ts
import { UserModule } from '../user/user.module';
import { eventBus } from '../../shared/events';
const orderService = {
create: async (userId: string, items: OrderItem[]) => {
// Use User module's public API
const user = await UserModule.queries.findById(userId);
if (!user) throw new NotFoundError('User not found');
const order = await orderRepository.create({
userId,
items,
total: calculateTotal(items)
});
// Emit event for other modules
await eventBus.publish(new OrderCreatedEvent(order));
return order;
}
};MVC / MVP / MVVM
Separation of concerns between Model, View, and Controller/Presenter/ViewModel.
// MVC Pattern Example (Backend)
// Model - Business logic and data
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
const UserModel = {
validate: (data: Partial<User>) => {
if (!data.email?.includes('@')) {
throw new ValidationError('Invalid email');
}
return true;
},
toJSON: (user: User) => ({
id: user.id,
email: user.email,
name: user.name
})
};
// Controller - Handles requests
const UserController = {
index: async (req: Request, res: Response) => {
const users = await userRepository.findAll();
return res.render('users/index', { users: users.map(UserModel.toJSON) });
},
create: async (req: Request, res: Response) => {
UserModel.validate(req.body);
const user = await userRepository.create(req.body);
return res.redirect(`/users/${user.id}`);
}
};
// View - Template (e.g., EJS, Handlebars, or React)
// views/users/index.ejs
// <ul>
// <% users.forEach(user => { %>
// <li><%= user.name %> - <%= user.email %></li>
// <% }) %>
// </ul>Project Structure
A well-organized monolith structure:
When to Use Monolithic Architecture
✅ Good For
- Small teams (1-10 developers)
- MVPs and prototypes - Fast time-to-market
- Simple domains - Limited business complexity
- Tight deadlines - Less infrastructure overhead
- Limited DevOps - Single deployment pipeline
❌ Avoid When
- Large teams - Deployment conflicts
- Different scaling needs - Can't scale components independently
- Technology diversity - Locked to one stack
- Frequent deployments - Risk affects entire system
- High availability - Single point of failure
Pros and Cons
Scaling Strategies
// Caching strategy for monolith scaling
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const withCache = <T>(
key: string,
ttlSeconds: number,
fn: () => Promise<T>
) => async (): Promise<T> => {
// Try cache first
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Execute and cache
const result = await fn();
await redis.setex(key, ttlSeconds, JSON.stringify(result));
return result;
};
// Usage
const getPopularProducts = withCache(
'products:popular',
300, // 5 minutes
() => productRepository.findPopular({ limit: 20 })
);Migration Path to Microservices
The Strangler Fig Pattern allows gradual migration from monolith to microservices without a complete rewrite.
Best Practices
Maintain Clear Boundaries
Even in a monolith, organize code into logical modules with defined interfaces.
// Define module boundaries with explicit exports
export { UserController } from './user.controller';
export { UserService } from './user.service';
export type { User, CreateUserDTO } from './user.types';
// Don't export internal implementationsUse Dependency Injection
Makes code testable and allows swapping implementations.
// Dependency injection without frameworks
type Dependencies = {
userRepository: UserRepository;
emailService: EmailService;
logger: Logger;
};
const createUserService = (deps: Dependencies) => ({
create: async (data: CreateUserInput) => {
const user = await deps.userRepository.create(data);
deps.logger.info('User created', { userId: user.id });
await deps.emailService.sendWelcome(user.email);
return user;
}
});
// Easy to test with mocks
const userService = createUserService({
userRepository: mockUserRepository,
emailService: mockEmailService,
logger: mockLogger
});Implement Health Checks
Essential for load balancers and container orchestration.
// Health check endpoint
app.get('/health', async (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
checks: {
database: await checkDatabase(),
redis: await checkRedis(),
memory: checkMemory()
}
};
const isHealthy = Object.values(health.checks)
.every(check => check.status === 'healthy');
res.status(isHealthy ? 200 : 503).json(health);
});Plan for Observability
Logging, metrics, and tracing from day one.
// Structured logging
const logger = {
info: (message: string, context?: object) => {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...context
}));
}
};
// Request tracing middleware
const traceMiddleware = (req: Request, res: Response, next: NextFunction) => {
req.traceId = req.headers['x-trace-id'] as string || generateId();
res.setHeader('x-trace-id', req.traceId);
next();
};Real-World Example
A complete e-commerce monolith setup:
// Application setup
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
// Import modules
import { userRoutes } from './api/routes/users.routes';
import { orderRoutes } from './api/routes/orders.routes';
import { productRoutes } from './api/routes/products.routes';
import { errorHandler } from './api/middlewares/errorHandler';
import { authMiddleware } from './api/middlewares/auth';
const app = new Hono();
// Global middleware
app.use('*', logger());
app.use('*', cors());
app.use('/api/*', authMiddleware);
// Routes
app.route('/api/users', userRoutes);
app.route('/api/orders', orderRoutes);
app.route('/api/products', productRoutes);
// Health check
app.get('/health', (c) => c.json({ status: 'healthy' }));
// Error handling
app.onError(errorHandler);
export default app;Summary
Monolithic architecture is not outdated—it's often the right choice for:
- Starting new projects
- Small to medium teams
- Applications with unclear requirements
- Projects with limited DevOps resources
The key is building a well-structured monolith that can evolve into microservices if needed. Focus on clean boundaries, dependency injection, and observability from the start.