TechLead
Lesson 5 of 27
5 min read
Software Architecture

Monolith vs Microservices

Compare monolithic and microservices architectures with decision frameworks and migration strategies

The Great Architecture Debate

Choosing between a monolith and microservices is one of the most consequential architectural decisions. There is no universally correct answer — the right choice depends on your team size, domain complexity, operational maturity, and growth trajectory. This topic provides a structured framework for making that decision.

Side-by-Side Comparison

Dimension Monolith Microservices
DeploymentSingle unit, all-or-nothingIndependent per service
ComplexityIn-process, straightforwardDistributed, operationally complex
Data ConsistencyACID transactions easyEventual consistency, sagas required
Team ScalingContention as team growsTeams own services independently
TechnologySingle stackPolyglot possible
PerformanceIn-process calls, fastNetwork overhead per call
TestingIntegration tests simplerContract tests, E2E harder
DebuggingStack traces, single processDistributed tracing required

The Monolith is Not the Enemy

Many successful companies run monoliths at scale. Shopify, Stack Overflow, and Basecamp all use monolithic architectures effectively. A well-structured monolith with clear module boundaries (a modular monolith) can deliver many benefits attributed to microservices while avoiding the distributed systems tax.

Monolith Advantages

  • Simplicity: One codebase, one deployment, one database — dramatically simpler to develop, test, and debug
  • Refactoring: Changing internal boundaries is an IDE refactor, not a distributed migration
  • Transactions: ACID transactions span the entire application with no extra complexity
  • Performance: In-process function calls are orders of magnitude faster than network calls
  • Operational overhead: One service to monitor, deploy, and scale

Decision Framework

Use these questions to guide your architecture decision. If most answers point to microservices, consider it. If not, start with a well-structured monolith.

// Architecture decision scoring
interface DecisionFactor {
  question: string;
  monolithScore: number; // 1-5
  microservicesScore: number; // 1-5
}

const decisionFactors: DecisionFactor[] = [
  {
    question: "Team size: How many developers?",
    monolithScore: 5,      // < 10 developers
    microservicesScore: 2,  // > 30 developers
  },
  {
    question: "Domain complexity: How many distinct business areas?",
    monolithScore: 4,      // 1-3 areas
    microservicesScore: 3,  // 5+ distinct areas
  },
  {
    question: "Deployment frequency: How often do you release?",
    monolithScore: 3,      // Weekly or less
    microservicesScore: 4,  // Multiple times daily, different cadences
  },
  {
    question: "Scaling needs: Do components need different scaling?",
    monolithScore: 4,      // Uniform scaling is fine
    microservicesScore: 2,  // Components have very different resource needs
  },
  {
    question: "Operational maturity: CI/CD, monitoring, IaC?",
    monolithScore: 2,      // Basic operations
    microservicesScore: 5,  // Advanced DevOps practices in place
  },
];

function recommendArchitecture(scores: DecisionFactor[]): string {
  const monoTotal = scores.reduce((s, f) => s + f.monolithScore, 0);
  const microTotal = scores.reduce((s, f) => s + f.microservicesScore, 0);
  if (monoTotal > microTotal + 5) return "Strong monolith recommendation";
  if (microTotal > monoTotal + 5) return "Consider microservices";
  return "Modular monolith — best of both worlds";
}

Migration Strategies

Most organizations do not start with microservices — they migrate to them. The key principle is incremental migration. Never attempt a big-bang rewrite. Instead, extract services one at a time from the monolith, starting with the parts that would benefit most from independence.

// Step 1: Identify the seam in the monolith
// Before: Everything in one module
class MonolithOrderService {
  async placeOrder(order: OrderDTO): Promise {
    // Validate inventory (tightly coupled)
    const inventory = await this.inventoryRepo.check(order.items);
    // Process payment (tightly coupled)
    const payment = await this.paymentProcessor.charge(order.total);
    // Save order
    await this.orderRepo.save(order);
    // Send notification (tightly coupled)
    await this.emailService.sendConfirmation(order);
    return { success: true, orderId: order.id };
  }
}

// Step 2: Introduce an interface at the seam
interface PaymentPort {
  charge(amount: number, customerId: string): Promise;
}

// Step 3: Create an internal adapter (still in monolith)
class InternalPaymentAdapter implements PaymentPort {
  constructor(private readonly paymentProcessor: PaymentProcessor) {}
  async charge(amount: number, customerId: string): Promise {
    return this.paymentProcessor.charge(amount); // Calls existing code
  }
}

// Step 4: Extract to a service and create an external adapter
class ExternalPaymentAdapter implements PaymentPort {
  constructor(private readonly httpClient: HttpClient) {}
  async charge(amount: number, customerId: string): Promise {
    const response = await this.httpClient.post(
      "http://payment-service/api/charges",
      { amount, customerId }
    );
    return response.data;
  }
}

// Step 5: Switch adapters (feature flag or config)
function createPaymentPort(config: AppConfig): PaymentPort {
  if (config.useExternalPaymentService) {
    return new ExternalPaymentAdapter(new HttpClient());
  }
  return new InternalPaymentAdapter(new PaymentProcessor());
}

Migration Anti-Patterns

  • Big bang rewrite: Attempting to rewrite the entire monolith as microservices at once — this almost always fails
  • Shared database: Keeping a shared database between the monolith and extracted services creates tight coupling
  • Distributed monolith: Extracting services that are still tightly coupled through synchronous chains
  • Premature extraction: Extracting services before understanding the domain boundaries, leading to wrong splits

The Monolith-First Approach

Martin Fowler advocates starting with a monolith and extracting microservices as needed. This approach lets you discover the right boundaries through experience rather than guessing upfront. The key is to build the monolith with clean module boundaries so extraction is feasible when the time comes.

// Well-structured monolith module boundaries
// src/modules/orders/
//   ├── OrderModule.ts       (public API)
//   ├── domain/              (internal)
//   ├── services/            (internal)
//   └── repositories/        (internal)

// OrderModule.ts - The only public API of the module
export class OrderModule {
  constructor(
    private readonly orderService: OrderService,
    private readonly queryService: OrderQueryService
  ) {}

  // Public methods other modules can call
  async placeOrder(command: PlaceOrderCommand): Promise {
    return this.orderService.placeOrder(command);
  }

  async getOrderSummary(orderId: OrderId): Promise {
    return this.queryService.getSummary(orderId);
  }

  // Event handlers for inter-module communication
  async onPaymentReceived(event: PaymentReceivedEvent): Promise {
    await this.orderService.confirmPayment(event.orderId);
  }
}

// Modules communicate through well-defined APIs,
// making future extraction to microservices straightforward

Key Takeaways

  • Start simple: Begin with a modular monolith unless you have strong reasons not to
  • Migrate incrementally: Extract services one at a time using the Strangler Fig pattern
  • Invest in operations first: Before going micro, ensure you have CI/CD, monitoring, and container orchestration
  • Revisit regularly: The right architecture evolves with your team, domain, and scale

Continue Learning