Nati's Blog

Monolith vs Microservices vs Modular Monoliths:A Practical Guide

November 17, 2024Software Architecture

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.

Software Architecture Patterns Overview
Software Architecture Patterns Overview

Monolithic Architecture: The Traditional Approach

Monolithic architecture packages all components of an application into a single, unified codebase and deployment unit.

Monolithic Architecture Diagram
Monolithic Architecture Diagram

Key Characteristics

  1. Single Codebase and Deployment Unit: Everything built and deployed together
  2. Shared Database: All functionality uses the same database
  3. Direct Dependencies: Components can directly call each other
  4. Unified Technology Stack: The entire application uses the same programming language and framework

Real-World Example: Airbnb's Initial Architecture

Airbnb's Monolithic Architecture
Airbnb's Monolithic 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.

Microservices Architecture Diagram
Microservices Architecture Diagram

Key Characteristics

  1. Independent Services: Each functionality runs as a separate service
  2. Decentralized Data: Each service manages its own database
  3. API Communication: Services interact through well-defined APIs
  4. Technology Diversity: Different services can use different tech stacks

Real-World Example: Netflix Microservices

Netflix Microservices Architecture
Netflix Microservices Architecture

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.

Modular Monolith Architecture
Modular Monolith Architecture

Key Characteristics

  1. Module Boundaries: Clear separation between different functionalities
  2. Interface-Based Communication: Modules interact through defined interfaces
  3. Shared Deployment: Still deployed as a single unit
  4. 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

Strangler Fig Pattern
Strangler Fig 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:

  1. Team Size and Experience: Smaller teams often benefit from monoliths or modular monoliths
  2. Project Complexity: More complex projects may need microservices' flexibility
  3. Scaling Requirements: High-traffic components might need independent scaling
  4. 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

  1. Newman, S. (2019). Building Microservices (2nd Edition). O'Reilly Media.
  2. Richards, M. (2015). Software Architecture Patterns. O'Reilly Media.
  3. Fowler, M. (2004). "Strangler Fig Application." https://martinfowler.com/bliki/StranglerFigApplication.html
  4. Evans, E. (2003). Domain-Driven Design. Addison-Wesley.
  5. Fowler, M. (2015). "MonolithFirst." https://martinfowler.com/bliki/MonolithFirst.html
  6. Richardson, C. (2022). Microservices Patterns (2nd Edition). Manning Publications.
  7. Kleppmann, M. (2017). Designing Data-Intensive Applications. O'Reilly Media.
  8. Microsoft. "Microservices architecture style." https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/microservices

Comments