Nati's Blog
Monolith vs Microservices vs Modular Monoliths:A Practical Guide
Introduction
Software architecture is a strategic choice that fundamentally shapes how teams build, scale, and maintain applications. This article explores three primary architectural patterns:
- Monolithic Architecture: The traditional, unified approach
- Microservices Architecture: Breaking the system into independent services
- Modular Monolithic Architecture: The balanced middle ground
We'll examine each pattern's characteristics, benefits, and drawbacks, with practical examples using TypeScript and Node.js.

Monolithic Architecture: The Traditional Approach
Monolithic architecture packages all components of an application into a single, unified codebase and deployment unit.

Key Characteristics
- Single Codebase and Deployment Unit: Everything built and deployed together
- Shared Database: All functionality uses the same database
- Direct Dependencies: Components can directly call each other
- Unified Technology Stack: The entire application uses the same programming language and framework
Real-World Example: Airbnb's Initial Architecture

Code Example: Monolithic Structure
A typical monolithic application with direct dependencies between components:
// OrderController.ts export class OrderController { private orderService: OrderService; private userService: UserService; // Direct dependency constructor() { this.orderService = new OrderService(); this.userService = new UserService(); } async createOrder(req: Request, res: Response): Promise<void> { try { // Direct call to user service const user = await this.userService.getUserById(req.body.userId); if (!user) { return res.status(404).json({ error: "User not found" }); } const order = await this.orderService.createOrder(req.body); res.status(201).json(order); } catch (error) { res.status(400).json({ error: error.message }); } } }
Benefits of Monoliths
- Simplicity: Straightforward to build and understand
- Testing Ease: Simple end-to-end testing with everything in one place
- Deployment Simplicity: One application, one deployment process
Downsides of Monoliths
- Scaling Inefficiency: Must scale the entire application, even when only one component needs it
- Maintainability Issues: Changes in one area can affect others unexpectedly
- Technology Lock-in: Difficult to adopt new technologies selectively
Microservices Architecture: The Distributed Approach
Microservices architecture divides an application into multiple independent services, each responsible for a specific functionality.

Key Characteristics
- Independent Services: Each functionality runs as a separate service
- Decentralized Data: Each service manages its own database
- API Communication: Services interact through well-defined APIs
- Technology Diversity: Different services can use different tech stacks
Real-World Example: Netflix Microservices

Code Example: Microservices Implementation
Here's how services communicate using HTTP in a microservices architecture:
// Order Service // order-service/src/app.ts import express from 'express'; import axios from 'axios'; const app = express(); app.use(express.json()); // Order controller with HTTP calls to user service app.post('/orders', async (req, res) => { try { // Call user service via HTTP try { await axios.get(`${process.env.USER_SERVICE_URL}/users/${req.body.userId}`); } catch (error) { if (error.response?.status === 404) { return res.status(404).json({ error: "User not found" }); } throw new Error(`Failed to validate user: ${error.message}`); } // Create the order // ... order creation logic res.status(201).json(order); } catch (error) { res.status(500).json({ error: error.message }); } }); app.listen(3002, () => { console.log('Order service is running on port 3002'); });
Benefits of Microservices
- Independent Scaling: Scale only the services that need it
- Technology Flexibility: Choose the best tech stack for each service
- Fault Isolation: A failure in one service doesn't bring down the entire system
Downsides of Microservices
- Operational Complexity: More moving parts to manage
- Infrastructure Costs: Multiple services require more resources
- Data Consistency Challenges: Maintaining consistency across services is difficult
Modular Monolith: The Balanced Approach
A modular monolith maintains a single codebase but organizes it into well-defined modules with clear boundaries.

Key Characteristics
- Module Boundaries: Clear separation between different functionalities
- Interface-Based Communication: Modules interact through defined interfaces
- Shared Deployment: Still deployed as a single unit
- Potential for Future Extraction: Well-defined modules can be extracted into microservices later
Code Example: Modular Monolith Structure
A modular monolith with clear boundaries and dependency injection:
// User module with clear interface // src/modules/user/index.ts export interface IUserService { getUserById(id: string): Promise<User | null>; } // Only export the interface and factory export const createUserService = (): IUserService => { return new UserService(); }; // Order module with clear dependencies // src/modules/order/OrderService.ts export class OrderService implements IOrderService { private userService: IUserService; constructor(userService: IUserService) { this.userService = userService; } async createOrder(orderData: OrderData): Promise<Order> { // Verify user through the interface const user = await this.userService.getUserById(orderData.userId); if (!user) { throw new Error('User not found'); } // Order creation logic return order; } } // Main application wiring // src/app.ts const userService = createUserService(); const orderService = createOrderService(userService);
Benefits of Modular Monoliths
- Simplicity with Structure: Organized code while maintaining deployment simplicity
- Adaptability: Easier refactoring and potential for future extraction
- Cost Efficiency: Lower infrastructure needs than microservices
Downsides of Modular Monoliths
- Limited Scalability: Still scales as a single unit
- Boundary Enforcement: Requires discipline to maintain module boundaries
Transitioning Between Architectures
As applications evolve, teams often transition between architectures to address specific challenges.
Monolith to Microservices: The Strangler Pattern

Key steps when extracting a service:
// 1. Create a new service // payment-service/src/app.ts app.post('/api/payments', async (req, res) => { // Payment processing logic }); // 2. Update the monolith to call this service // monolith/src/controllers/OrderController.ts async processPayment(req: Request, res: Response): Promise<void> { // Call the new payment microservice const response = await axios.post( `${process.env.PAYMENT_SERVICE_URL}/api/payments`, { orderId: req.body.orderId, amount: req.body.amount } ); res.status(200).json(response.data); } // 3. Set up an API Gateway // api-gateway/src/app.ts app.use('/api/payments', createProxyMiddleware({ target: process.env.PAYMENT_SERVICE_URL, changeOrigin: true })); // Route everything else to the monolith app.use('/', createProxyMiddleware({ target: process.env.MONOLITH_URL, changeOrigin: true }));
Monolith to Modular Monolith: Refactoring
The key to this transition is establishing clear module boundaries:
// Before: Tangled dependencies class ContentService { async createContent(content: Content): Promise<Content> { // Direct access to user data and mixed concerns const user = await this.userRepository.findById(content.authorId); if (user.role !== 'author' && user.role !== 'admin') { throw new Error('User not authorized to create content'); } return this.contentRepository.save(content); } } // After: Clear module boundaries // src/modules/user/UserService.ts export interface IUserService { isUserAuthorized(userId: string, permission: string): Promise<boolean>; } // src/modules/content/ContentService.ts export class ContentService { constructor( private contentRepository: ContentRepository, private userService: IUserService // Only depends on the interface ) {} async createContent(content: Content): Promise<Content> { // Access user data through the interface if (!await this.userService.isUserAuthorized(content.authorId, 'create_content')) { throw new Error('User not authorized to create content'); } return this.contentRepository.save(content); } }
Modular Monolith to Microservices: Extraction
When a specific module needs independent scaling, it can be extracted:
// 1. Create a standalone service // routing-service/src/app.ts app.post('/routes/optimize', async (req, res) => { const { stops, trafficCondition } = req.body; const route = await routingService.optimizeRoute(stops, trafficCondition); res.json(route); }); // 2. Update the monolith to use a proxy // src/modules/routing/RoutingServiceProxy.ts export class RoutingServiceProxy implements IRoutingService { async optimizeRoute(stops: Location[], traffic: TrafficCondition): Promise<Route> { const response = await axios.post(`${this.apiUrl}/routes/optimize`, { stops, trafficCondition: traffic }); return response.data; } } // 3. Add resilience patterns this.circuitBreaker = new CircuitBreaker(this.callService.bind(this), { failureThreshold: 3, resetTimeout: 10000 });
Choosing the Right Architecture
The right architecture depends on various factors:
- Team Size and Experience: Smaller teams often benefit from monoliths or modular monoliths
- Project Complexity: More complex projects may need microservices' flexibility
- Scaling Requirements: High-traffic components might need independent scaling
- Deployment Frequency: Frequent changes may benefit from microservices' independence
Decision Framework
A simple decision framework approach:
function recommendArchitecture(params) { const { teamSize, complexity, scalingNeeds } = params; if (teamSize < 5 && complexity === 'low') { return 'Monolith'; } if (teamSize < 10 && ['low', 'medium'].includes(complexity)) { return 'Modular Monolith'; } if (complexity === 'high' || scalingNeeds === 'critical') { return 'Microservices'; } return 'Start with Modular Monolith, prepare for future extraction'; }
Summary
Each architectural pattern offers distinct advantages:
- Monoliths: Simple development and deployment, ideal for smaller projects
- Microservices: Independent scaling and technology diversity, great for complex systems
- Modular Monoliths: Balanced approach with organized code but simpler deployment
The right architecture isn't about following trends but finding the best fit for your specific context, team capabilities, and business requirements.
References
- Newman, S. (2019). Building Microservices (2nd Edition). O'Reilly Media.
- Richards, M. (2015). Software Architecture Patterns. O'Reilly Media.
- Fowler, M. (2004). "Strangler Fig Application." https://martinfowler.com/bliki/StranglerFigApplication.html
- Evans, E. (2003). Domain-Driven Design. Addison-Wesley.
- Fowler, M. (2015). "MonolithFirst." https://martinfowler.com/bliki/MonolithFirst.html
- Richardson, C. (2022). Microservices Patterns (2nd Edition). Manning Publications.
- Kleppmann, M. (2017). Designing Data-Intensive Applications. O'Reilly Media.
- Microsoft. "Microservices architecture style." https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/microservices