System Designintermediate

Monolith vs Microservices Architecture

Deep dive into monolithic and microservices architectures. Learn when to use each, how to migrate between them, and the real trade-offs teams face in production.

17 min readΒ·Published Apr 24, 2026
system-designmicroservicesmonolitharchitecture

The Architecture Decision That Shapes Everything

Choosing between a monolith and microservices is not a technology decision. It is an organizational decision. It determines how your teams communicate, how fast you ship, how you debug production issues, and how much infrastructure you manage.

Most teams get this wrong by defaulting to microservices too early or clinging to a monolith too long. This article walks through both architectures in depth, shows you when each makes sense, and gives you practical migration strategies for when you need to evolve.

Monolithic Architecture

A monolith is a single deployable unit. All your application logic, data access, business rules, and UI rendering live in one codebase and deploy as one artifact.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  MONOLITH                     β”‚
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚   Auth    β”‚  β”‚  Orders  β”‚  β”‚ Payments β”‚   β”‚
β”‚  β”‚  Module   β”‚  β”‚  Module  β”‚  β”‚  Module  β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜   β”‚
β”‚       β”‚              β”‚              β”‚         β”‚
β”‚  β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚           Shared Data Layer             β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                   β”‚                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚         Single Database                 β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Structure of a Typical Monolith

In a well-structured monolith, code is organized into modules or packages within the same repository. Here is a typical Node.js monolith structure:

my-app/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ modules/
β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”‚   β”œβ”€β”€ auth.controller.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ auth.service.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ auth.repository.ts
β”‚   β”‚   β”‚   └── auth.types.ts
β”‚   β”‚   β”œβ”€β”€ orders/
β”‚   β”‚   β”‚   β”œβ”€β”€ orders.controller.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ orders.service.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ orders.repository.ts
β”‚   β”‚   β”‚   └── orders.types.ts
β”‚   β”‚   └── payments/
β”‚   β”‚       β”œβ”€β”€ payments.controller.ts
β”‚   β”‚       β”œβ”€β”€ payments.service.ts
β”‚   β”‚       β”œβ”€β”€ payments.repository.ts
β”‚   β”‚       └── payments.types.ts
β”‚   β”œβ”€β”€ shared/
β”‚   β”‚   β”œβ”€β”€ database.ts
β”‚   β”‚   β”œβ”€β”€ middleware.ts
β”‚   β”‚   └── utils.ts
β”‚   └── app.ts
β”œβ”€β”€ package.json
└── Dockerfile

Modules communicate through direct function calls. There is no network boundary between them.

// orders.service.ts β€” direct import, no network call
import { PaymentService } from '../payments/payments.service';
import { AuthService } from '../auth/auth.service';

export class OrderService {
  constructor(
    private paymentService: PaymentService,
    private authService: AuthService,
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    // Direct function call β€” same process, same memory space
    const user = await this.authService.getUser(userId);
    const total = this.calculateTotal(items);

    const payment = await this.paymentService.charge(user, total);

    return this.orderRepository.save({
      userId,
      items,
      total,
      paymentId: payment.id,
    });
  }

  private calculateTotal(items: OrderItem[]): number {
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

Monolith Advantages

Simplicity in development. One repo, one build, one deploy. New developers clone a single repository and can run the entire application locally. There is no need to set up service meshes, configure inter-service authentication, or manage distributed tracing.

Transaction integrity. Because everything shares a single database, you get ACID transactions for free. Creating an order, charging a payment, and updating inventory can all happen in one database transaction.

async createOrderWithPayment(userId: string, items: OrderItem[]) {
  // Single transaction β€” all or nothing
  return this.db.transaction(async (trx) => {
    const order = await trx('orders').insert({ userId, items });
    const payment = await trx('payments').insert({
      orderId: order.id,
      amount: order.total,
    });
    await trx('inventory').decrement('stock', items);
    return { order, payment };
  });
}

Easy debugging. A stack trace shows you the entire call chain. No need to correlate logs across services or trace request IDs through multiple systems.

Lower operational overhead. One server, one deployment pipeline, one set of logs. You do not need Kubernetes, service discovery, or distributed monitoring on day one.

Monolith Disadvantages

Scaling is all-or-nothing. If your image processing module needs 10x more CPU, you have to scale the entire application. You cannot scale individual components independently.

Deployment risk. Every deployment ships the entire application. A bug in the payment module means rolling back the auth and orders modules too, even if they had no changes.

Technology lock-in. The entire application uses one language, one framework, one version of every dependency. Upgrading a major dependency requires testing the entire system.

Growing complexity. As the codebase grows past 100,000 lines, build times increase, test suites slow down, and module boundaries blur as developers take shortcuts.

Team bottlenecks. Multiple teams working on the same codebase leads to merge conflicts, coordinated releases, and the inability to ship independently.

Microservices Architecture

Microservices decompose your application into small, independently deployable services. Each service owns its data, runs in its own process, and communicates with other services over the network.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Auth Svc   β”‚     β”‚ Orders Svc  β”‚     β”‚ Payment Svc β”‚
β”‚             β”‚     β”‚             β”‚     β”‚             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”  β”‚     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”  β”‚     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ API   β”‚  β”‚     β”‚  β”‚ API   β”‚  β”‚     β”‚  β”‚ API   β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚     β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚     β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚ Logic β”‚  β”‚     β”‚  β”‚ Logic β”‚  β”‚     β”‚  β”‚ Logic β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚     β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚     β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚  DB   β”‚  β”‚     β”‚  β”‚  DB   β”‚  β”‚     β”‚  β”‚  DB   β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚     β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚     β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
       β”‚                   β”‚                   β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
           β”‚  API Gateway  β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
           β”‚    Client     β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Service Communication

Services communicate via HTTP/REST, gRPC, or asynchronous messaging. This is the fundamental difference from a monolith β€” calls that were once in-process function calls now cross a network boundary.

Synchronous communication (HTTP/gRPC):

// orders.service.ts β€” HTTP call to payment service
import axios from 'axios';

export class OrderService {
  private readonly paymentServiceUrl = process.env.PAYMENT_SERVICE_URL;

  async createOrder(userId: string, items: OrderItem[]) {
    // Network call β€” different process, potentially different machine
    const paymentResponse = await axios.post(
      `${this.paymentServiceUrl}/api/payments`,
      {
        userId,
        amount: this.calculateTotal(items),
      },
      {
        timeout: 5000,
        headers: { 'X-Request-Id': requestId },
      }
    );

    if (!paymentResponse.data.success) {
      throw new PaymentFailedError(paymentResponse.data.error);
    }

    return this.orderRepository.save({
      userId,
      items,
      paymentId: paymentResponse.data.paymentId,
    });
  }
}

Asynchronous communication (message queue):

// orders.service.ts β€” publish event instead of direct call
import { MessageBroker } from './messaging';

export class OrderService {
  async createOrder(userId: string, items: OrderItem[]) {
    const order = await this.orderRepository.save({ userId, items });

    // Publish event β€” payment service subscribes and processes
    await MessageBroker.publish('order.created', {
      orderId: order.id,
      userId,
      amount: order.total,
      items,
    });

    return order; // Payment happens asynchronously
  }
}

// payment.consumer.ts β€” in the payment service
MessageBroker.subscribe('order.created', async (event) => {
  await paymentService.processPayment({
    orderId: event.orderId,
    userId: event.userId,
    amount: event.amount,
  });
});

Microservices Advantages

Independent deployment. Ship the payment service without touching orders or auth. Teams deploy on their own schedule. A failed deployment affects only one service.

Independent scaling. Scale the search service to handle Black Friday traffic while keeping the user profile service at normal capacity.

Technology freedom. Write the real-time notification service in Go for performance, the recommendation engine in Python for ML libraries, and the CRUD API in Node.js for development speed.

Team autonomy. Each team owns a service end-to-end. They choose their tech stack, set their release cadence, and operate their service independently.

Fault isolation. If the recommendation service crashes, users can still browse, search, and purchase. The system degrades gracefully instead of going completely down.

Microservices Disadvantages

Distributed system complexity. Network calls fail, time out, and return stale data. You must handle retries, circuit breakers, timeouts, and partial failures that simply do not exist in a monolith.

// Circuit breaker pattern β€” necessary in microservices
import CircuitBreaker from 'opossum';

const paymentBreaker = new CircuitBreaker(callPaymentService, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

paymentBreaker.fallback(() => ({
  status: 'pending',
  message: 'Payment will be processed shortly',
}));

paymentBreaker.on('open', () => {
  logger.warn('Payment service circuit breaker opened');
  alerting.notify('payment-service-degraded');
});

Data consistency. Without shared transactions, you need sagas, eventual consistency, and compensating transactions. This is significantly harder to implement and reason about.

Operational overhead. You need container orchestration (Kubernetes), service discovery, distributed tracing, centralized logging, and health monitoring for every service.

Testing complexity. Integration tests require running multiple services. End-to-end tests are slower and flakier because they depend on network calls between services.

Debugging difficulty. A single user request might touch 5 services. You need distributed tracing (Jaeger, Zipkin) and correlated logging to follow a request through the system.

Comparison Table

AspectMonolithMicroservices
DeploymentSingle artifact, all-or-nothingIndependent per service
ScalingScale entire applicationScale individual services
Data consistencyACID transactionsEventual consistency, sagas
Development speed (small team)FasterSlower (infrastructure overhead)
Development speed (large team)Slower (merge conflicts, coordination)Faster (team autonomy)
DebuggingStack traces, single processDistributed tracing required
Technology choiceSingle stackPolyglot possible
Operational costLowerSignificantly higher
Fault isolationOne bug can crash everythingFailures contained per service
Team structureShared codebaseService ownership
LatencyIn-process calls (nanoseconds)Network calls (milliseconds)
TestingStraightforwardComplex (contract tests, integration)

When to Split a Monolith

Do not split a monolith because microservices are trendy. Split when you have specific, measurable pain points.

Strong Signals to Split

  1. Deployment frequency is blocked. Teams cannot ship because they are waiting on other teams to finish their changes, stabilize shared code, or coordinate release windows.

  2. Scaling requirements diverge. One module needs 20 instances to handle load while the rest need 2. You are paying 10x the infrastructure cost to scale one feature.

  3. Technology constraints. A specific module would benefit dramatically from a different language, framework, or database, but the monolith makes that impossible.

  4. Build and test times are painful. The CI pipeline takes 45 minutes. Running the full test suite takes an hour. Developers context-switch during builds.

  5. Team size exceeds 8-10 per module. At this point, communication overhead within the monolith starts to outweigh the simplicity benefit.

Weak Signals (Do Not Split Yet)

  • "Everyone else is doing microservices"
  • "We want to put Kubernetes on our resume"
  • "Our codebase is messy" (fix the monolith first)
  • Team size under 10 total developers
  • No clear domain boundaries identified

Migration Strategies

The Strangler Fig Pattern

Named after the strangler fig tree that grows around a host tree until the host dies, this pattern lets you gradually replace monolith functionality with microservices.

Phase 1: Route through proxy
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │────▢│   Proxy   │────▢│ Monolith β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 2: Extract first service
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │────▢│   Proxy   │────▢│ Monolith β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β”‚  /api/auth/*
                     β–Ό
               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
               β”‚  Auth Svc  β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 3: Extract more services
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │────▢│   Proxy   │────▢│ Monolith β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚  β”‚  β”‚          (shrinking)
           β”Œβ”€β”€β”€β”€β”€β”€β”˜  β”‚  └──────┐
           β–Ό         β–Ό         β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚  Auth  β”‚ β”‚ Orders β”‚ β”‚Payment β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 4: Monolith eliminated
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client │────▢│   Proxy   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”˜
           β”Œβ”€β”€β”€β”€β”€β”€β”˜  β”‚  └──────┐
           β–Ό         β–Ό         β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚  Auth  β”‚ β”‚ Orders β”‚ β”‚Payment β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Implementation using an API gateway (NGINX example):

# nginx.conf β€” strangler fig routing
upstream monolith {
    server monolith-app:3000;
}

upstream auth_service {
    server auth-service:3001;
}

upstream orders_service {
    server orders-service:3002;
}

server {
    listen 80;

    # Extracted: route to new auth service
    location /api/auth/ {
        proxy_pass http://auth_service;
    }

    # Extracted: route to new orders service
    location /api/orders/ {
        proxy_pass http://orders_service;
    }

    # Everything else: still goes to monolith
    location / {
        proxy_pass http://monolith;
    }
}

Branch by Abstraction

When you cannot easily route at the network level, use branch by abstraction within the monolith itself.

// Step 1: Create abstraction layer
type NotificationSender = {
  send(userId: string, message: string): Promise<void>;
};

// Step 2: Existing monolith implementation
class InternalNotificationSender implements NotificationSender {
  async send(userId: string, message: string) {
    await this.emailModule.send(userId, message);
    await this.pushModule.send(userId, message);
  }
}

// Step 3: New microservice implementation
class ExternalNotificationSender implements NotificationSender {
  async send(userId: string, message: string) {
    await axios.post(`${NOTIFICATION_SERVICE_URL}/send`, {
      userId,
      message,
    });
  }
}

// Step 4: Feature flag to switch
const notificationSender: NotificationSender =
  featureFlags.isEnabled('use-notification-service')
    ? new ExternalNotificationSender()
    : new InternalNotificationSender();

Database Decomposition

The hardest part of splitting a monolith is separating the shared database. This must be done incrementally.

Phase 1: Shared database (monolith)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Monolith DB          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”‚
β”‚  β”‚ users β”‚ β”‚orders β”‚ β”‚pay β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 2: Logical separation (same DB, separate schemas)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Monolith DB          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ auth_schemaβ”‚ β”‚orders_schβ”‚ β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”  β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”‚ β”‚
β”‚  β”‚  β”‚users β”‚  β”‚ β”‚ β”‚ordersβ”‚ β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”˜  β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 3: Physical separation (separate databases)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Auth DB  β”‚  β”‚Orders DB β”‚  β”‚ Pay DB   β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚users β”‚ β”‚  β”‚ β”‚ordersβ”‚ β”‚  β”‚ β”‚ pay  β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data Consistency Challenges

In a monolith, you wrap related operations in a database transaction. In microservices, each service has its own database. You cannot use a single transaction across databases.

The Saga Pattern

A saga is a sequence of local transactions where each step publishes an event that triggers the next step. If a step fails, compensating transactions undo previous steps.

// Choreography-based saga β€” each service reacts to events

// Order Service
async function handleOrderCreated(order: Order) {
  await orderRepo.save({ ...order, status: 'PENDING' });
  await publish('order.created', { orderId: order.id, amount: order.total });
}

// Payment Service β€” listens for order.created
async function handleOrderCreated(event: OrderCreatedEvent) {
  try {
    const payment = await processPayment(event.amount);
    await publish('payment.completed', {
      orderId: event.orderId,
      paymentId: payment.id,
    });
  } catch (error) {
    await publish('payment.failed', {
      orderId: event.orderId,
      reason: error.message,
    });
  }
}

// Inventory Service β€” listens for payment.completed
async function handlePaymentCompleted(event: PaymentCompletedEvent) {
  try {
    await reserveInventory(event.orderId);
    await publish('inventory.reserved', { orderId: event.orderId });
  } catch (error) {
    // Compensating transaction: refund payment
    await publish('inventory.failed', {
      orderId: event.orderId,
      paymentId: event.paymentId,
    });
  }
}

// Order Service β€” listens for inventory.reserved
async function handleInventoryReserved(event: InventoryReservedEvent) {
  await orderRepo.update(event.orderId, { status: 'CONFIRMED' });
}

// Order Service β€” listens for payment.failed
async function handlePaymentFailed(event: PaymentFailedEvent) {
  await orderRepo.update(event.orderId, { status: 'CANCELLED' });
}

Eventual Consistency

Microservices embrace eventual consistency. Data across services will converge to a consistent state, but there will be windows where services have stale or conflicting data.

Strategies to manage eventual consistency:

  1. Idempotent operations β€” Processing the same message twice produces the same result
  2. Correlation IDs β€” Track a request across services for debugging
  3. Read-your-own-writes β€” After a write, read from the same service, not a replica
  4. Compensating transactions β€” Undo actions when a downstream step fails
// Idempotent payment processing
async function processPayment(event: PaymentRequestEvent) {
  // Check if already processed using idempotency key
  const existing = await paymentRepo.findByIdempotencyKey(event.requestId);
  if (existing) {
    return existing; // Already processed, return existing result
  }

  const payment = await chargeCard(event.amount, event.cardToken);
  await paymentRepo.save({ ...payment, idempotencyKey: event.requestId });
  return payment;
}

Team Structure and Conway's Law

"Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure." β€” Melvin Conway

Conway's Law is not just an observation β€” it is a force. Your architecture will mirror your team structure whether you plan for it or not.

Monolith Team Structure

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             Engineering Team             β”‚
β”‚                                         β”‚
β”‚  Frontend  Backend  DBA  QA  DevOps     β”‚
β”‚  Devs      Devs                         β”‚
β”‚                                         β”‚
β”‚  Everyone works on the same codebase    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Works well for small teams (under 15 engineers). Communication is easy, shared context is high, and cross-functional work happens naturally.

Microservices Team Structure

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Auth Team   β”‚ β”‚ Orders Team  β”‚ β”‚ Payment Team β”‚
β”‚              β”‚ β”‚              β”‚ β”‚              β”‚
β”‚ FE + BE +   β”‚ β”‚ FE + BE +   β”‚ β”‚ FE + BE +   β”‚
β”‚ QA + DevOps β”‚ β”‚ QA + DevOps β”‚ β”‚ QA + DevOps β”‚
β”‚              β”‚ β”‚              β”‚ β”‚              β”‚
β”‚ Owns: Auth  β”‚ β”‚ Owns: Ordersβ”‚ β”‚ Owns: Pay   β”‚
β”‚ service,    β”‚ β”‚ service,    β”‚ β”‚ service,    β”‚
β”‚ auth DB,    β”‚ β”‚ orders DB,  β”‚ β”‚ pay DB,     β”‚
β”‚ auth tests  β”‚ β”‚ order tests β”‚ β”‚ pay tests   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each team is cross-functional and owns their service end-to-end: development, testing, deployment, monitoring, and on-call. This is the "you build it, you run it" model.

The Inverse Conway Maneuver

If you want microservices, restructure your teams first to match the desired architecture. Do not split the monolith and then try to reorganize teams β€” the team structure will fight the architecture.

Step 1: Identify domain boundaries
Step 2: Form cross-functional teams around those domains
Step 3: Let each team own their domain in the monolith
Step 4: When ready, extract each domain into a service

The Modular Monolith β€” The Middle Ground

Before jumping to microservices, consider a modular monolith. It gives you clear module boundaries, encapsulated data access, and defined interfaces between modules β€” all within a single deployable unit.

// Modular monolith β€” modules communicate through defined interfaces only

// modules/orders/public-api.ts β€” only this is importable by other modules
export type { OrderSummary } from './types';
export { getOrderSummary } from './queries';
export { createOrder } from './commands';

// modules/orders/internal/ β€” private to the orders module
// Other modules CANNOT import from here
// Enforced by linting rules or build-time checks

// modules/payments/payment.service.ts
import { getOrderSummary } from '../orders/public-api';
// NOT: import { OrderRepository } from '../orders/internal/repository';

This gives you the simplicity of a monolith with the organizational clarity of microservices. When a module is ready to become a service, the public API becomes the network API.

Decision Framework

Use this flowchart to decide your architecture:

START
  β”‚
  β–Ό
Team size > 20 engineers? ──NO──▢ Monolith (or modular monolith)
  β”‚
  YES
  β”‚
  β–Ό
Clear domain boundaries? ──NO──▢ Modular monolith, identify domains first
  β”‚
  YES
  β”‚
  β–Ό
Need independent scaling? ──NO──▢ Modular monolith
  β”‚
  YES
  β”‚
  β–Ό
Have DevOps maturity? ──NO──▢ Modular monolith, build platform first
(CI/CD, monitoring,
 container orchestration)
  β”‚
  YES
  β”‚
  β–Ό
MICROSERVICES

Key Takeaways

  • Start with a monolith unless you have proven reasons not to. You can always extract services later.
  • The strangler fig pattern is the safest migration path. Never do a "big bang" rewrite.
  • Microservices trade development simplicity for operational complexity. Make sure your organization is ready for that trade.
  • Conway's Law is real. Restructure teams before restructuring architecture.
  • A modular monolith gives you 80% of the organizational benefits with 20% of the operational overhead.
  • Data consistency across services is the hardest problem. Understand sagas and eventual consistency before committing to microservices.
  • The right architecture depends on your team size, domain complexity, scaling requirements, and operational maturity β€” not on what FAANG companies use.

Found this helpful?

Support devsofus β€” help us keep creating free dev guides.

Related Articles