system design

Microservices vs Monolith: Which Architecture Should You Build?

Learn when microservices make sense and when they don't. Covers modular monolith, strangler fig pattern, Conway's Law, service communication, data management, and real examples from Netflix, Shopify, and GitHub.

By Akash Sharma·16 min read
#microservices
#monolith
#architecture
#system design
#backend
#scalability
#distributed systems

Microservices vs monolith is a false dilemma. The real question is: what is the right architecture for your current team size, traffic, and organizational structure? A monolith is the right answer for most teams under 20 engineers. Microservices become correct when organizational scale — not technical scale — makes independent deployment a genuine need. The most common mistake in software architecture is choosing microservices too early.

What Is a Monolith?

A monolith is one deployable unit. All your code — user auth, payments, notifications, orders — runs in one process, connects to one database, and deploys as a single artifact.

plaintext
Monolith:
┌─────────────────────────────────┐
│  App Process                    │
│  ├── Auth Module                │
│  ├── Orders Module              │
│  ├── Payments Module            │
│  └── Notifications Module       │
└─────────────┬───────────────────┘

         PostgreSQL

One codebase. One deploy. One database. Simple to develop, simple to debug, simple to operate.

What Are Microservices?

Microservices split the application into small, independent services. Each service owns its data, has its own deployment pipeline, and communicates over the network.

plaintext
Microservices:
┌──────────────┐  HTTP/gRPC  ┌──────────────┐
│ Auth Service │ ←─────────→ │Orders Service│
│  [auth DB]   │             │ [orders DB]  │
└──────────────┘             └──────┬───────┘
        ↑                           │ async events
        │          API Gateway      ▼
        └──────── ← Client  ┌──────────────┐
                             │  Payments    │
                             │ [payments DB]│
                             └──────────────┘

Each service is a separate process. Independent deployment, independent scaling, independent team ownership.

Why the Monolith Is Underrated

Simple to develop

One codebase. git clone, npm install, npm start. No Kubernetes, no service mesh, no message queues to run locally. Junior engineers are productive on day one.

python
# In a monolith — just call the function directly
from payments import charge_card
from inventory import reserve_items
from notifications import send_confirmation
 
def checkout(order):
    charge_card(order.user, order.total)
    reserve_items(order.items)
    send_confirmation(order.user)

No HTTP calls. No serialization overhead. No distributed transactions. No retries. It works or it doesn't, and the error is in one place.

Easy to debug

One log stream. One stack trace. No distributed tracing spans across 5 services. When something breaks at 3am, you find it in minutes, not hours.

bash
# Monolith debugging
grep "order_id=12345" app.log
# Done — one log, one place
 
# Microservices debugging (at scale)
# search order-service logs → find trace_id → search payment-service logs
# → find downstream call → search inventory-service logs...
# Need Jaeger/Zipkin, Datadog APM, or Grafana Loki to correlate

ACID transactions are free

Updating orders + inventory + payments in one database transaction is a single BEGIN / COMMIT. Consistency is guaranteed by the database.

sql
BEGIN;
  UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 42;
  INSERT INTO orders (user_id, product_id, amount) VALUES (1, 42, 99.00);
  INSERT INTO charges (user_id, amount, status) VALUES (1, 99.00, 'completed');
COMMIT;
-- Either all three succeed or all three fail

In microservices, this becomes a distributed saga with compensating transactions, idempotency keys, and eventual consistency — a fundamentally harder problem that takes weeks to implement correctly.

Scales further than you think

Stack Overflow serves 1.5 billion page views per month on 9 web servers. GitHub ran a monolith for years at global scale. Shopify's core is still largely a Rails monolith, serving millions of merchants. The idea that you need microservices to handle scale is a myth perpetuated by companies that have Netflix-scale problems and presented their solution as a general architecture.

Why Microservices Exist

Monoliths have real problems — but they are organizational problems, not technical ones.

Deployment bottleneck: 50 engineers deploying to one codebase. Every deploy has a blast radius that touches everything. Feature teams block each other waiting for a deploy slot. Risk increases as the team grows.

Independent scaling: The image-processing service needs 50 GPU workers. The notification service needs 2. In a monolith, you scale everything together — 50 GPU workers running idle auth code.

Technology heterogeneity: The ML team needs Python. The real-time WebSocket service performs better in Go. The video processing pipeline needs specific C++ libraries. A monolith forces one language and runtime.

Fault isolation: A memory leak in the recommendation service can take down the entire monolith. In microservices, it affects only the recommendation service — other services degrade gracefully.

Team independence: A service owned by one team can be deployed, tested, and evolved independently. You don't need to coordinate with 8 other teams for a deploy. This is the real reason companies adopt microservices at scale.

These problems are real. But they appear at large organizations — 50+ engineers actively deploying — not at 5 engineers building a startup.

The Real Cost of Microservices

Microservices don't remove complexity. They move it from your codebase to your infrastructure and your organizational processes.

Local development is painful. Running 12 services locally requires Docker Compose with all their dependencies. When Service A depends on Service B v2.3.1 and Service C depends on Service B v2.2.0, you have an integration problem. Engineers spend more time managing their local environment than building features.

Every network call can fail. In a monolith, function calls don't time out. In microservices, every service-to-service call can fail with network errors, timeouts, or unexpected errors. You need circuit breakers, retries with exponential backoff, and fallback behavior everywhere.

python
# Every service call needs this
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
 
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10))
def get_user_profile(user_id: str) -> dict:
    response = httpx.get(
        f"http://user-service/users/{user_id}",
        timeout=2.0
    )
    response.raise_for_status()
    return response.json()

Distributed transactions are hard. What was a simple database transaction now requires a saga pattern, a message broker, compensating transactions, and idempotency handling. This is 10x the code for the same business outcome.

Operational overhead multiplies. 8 services = 8 deployment pipelines, 8 sets of logs, 8 health dashboards, 8 alerting configurations. You need Kubernetes, a service mesh (Istio, Linkerd), distributed tracing, centralized logging, and a dedicated platform team to operate this.

A small team paying this cost before reaching the scale that demands it produces a "distributed monolith" — all the complexity of microservices with none of the independence benefits.

Monolith vs Microservices: Full Comparison

AspectMonolithMicroservices
Local dev setupSingle process, simpleMulti-service Docker Compose
DeploymentOne artifact, one deployN independent CI/CD pipelines
DebuggingSingle log, single stack traceDistributed tracing required
TransactionsSingle-DB ACIDSagas, eventual consistency
ScalingScale entire appScale individual services
Team independenceShared codebase, coordination requiredFully independent deploys
Fault isolationOne bug can affect allFailures isolated to service
Technology choiceOne language/frameworkPolyglot by service
Operational complexityLowHigh (K8s, service mesh, APM)
Right team size1–30 engineers30+ engineers
Time to first deployHoursDays to weeks
Code refactoringEasy within codebaseRequires API versioning

Conway's Law and Architecture

Conway's Law: "Organizations which design systems are constrained to produce designs which are copies of the communication structures of those organizations."

This is why microservices work at Amazon (two-pizza teams) but fail at startups (one team). Amazon's microservices boundaries mirror Amazon's organizational structure — each service is owned by a team with its own roadmap, on-call rotation, and deployment schedule.

If your organization doesn't have independent teams with separate deployment authority, microservices will produce a distributed monolith. You'll have separate services but they'll be so tightly coupled through shared databases, synchronous calls, and coordinated deployments that you get none of the benefits.

Inverse Conway Maneuver: Design your architecture to encourage the organizational structure you want. If you want two independent teams, start with two services. But if you have one team, start with one service.

The Modular Monolith: Best of Both Worlds

A modular monolith is a single deployable unit internally structured into modules with clear, enforced boundaries. It has the operational simplicity of a monolith with the code organization of microservices.

python
# Clear module boundaries — modules only expose public interfaces
# orders/service.py
class OrderService:
    def create_order(self, user_id: int, items: list) -> dict:
        ...
    def get_order(self, order_id: int) -> dict:
        ...
 
# payments/service.py
from orders.service import OrderService  # Only import the public interface
 
class PaymentService:
    def __init__(self, order_service: OrderService):
        self.order_service = order_service
 
    def charge_for_order(self, order_id: int) -> dict:
        order = self.order_service.get_order(order_id)  # Call via interface
        ...

Use tools to enforce boundaries — Python's namespace packages, Java's modules (Project Jigsaw), Go's packages. Treat each module as if it were a separate service: no direct database access across module boundaries, no shared mutable state, clear public API.

When you eventually need to extract a service, the module boundary is the extraction point — you've already done the hard work of defining the interface.

Companies using this approach: Shopify (Rails monolith with component architecture), Stack Overflow (modular C# monolith), 37signals/Basecamp (Rails monolith by choice, indefinitely).

The Strangler Fig Pattern

When you do have a monolith that needs to become microservices, the strangler fig pattern is the safest migration path. Named after the fig tree that grows around a host tree and eventually replaces it.

The strategy: don't rewrite the monolith. Intercept traffic at the API gateway and gradually redirect endpoints to new services, one feature at a time.

plaintext
Phase 1 — All traffic to monolith:
Client → API Gateway → Monolith → DB
 
Phase 2 — New auth service handles /auth/* paths:
Client → API Gateway → /auth/* → Auth Service → auth DB
                     → everything else → Monolith → DB
 
Phase 3 — Continue extracting:
Client → API Gateway → /auth/* → Auth Service
                     → /orders/* → Order Service
                     → everything else → Monolith (shrinking)

Implementation with Nginx:

nginx
server {
    listen 443 ssl;
    server_name api.example.com;
 
    # New auth service — extracted first
    location /api/auth/ {
        proxy_pass http://auth-service:8001;
    }
 
    # New order service — extracted second
    location /api/orders/ {
        proxy_pass http://order-service:8002;
    }
 
    # Everything else still hits the monolith
    location / {
        proxy_pass http://monolith:8000;
    }
}

Each extraction is a separate project with its own timeline. The monolith keeps running throughout. If an extraction fails, roll back by removing the Nginx rule — the monolith still handles it.

Service Communication Patterns

When you do have multiple services, how they talk to each other is a critical design decision.

Synchronous (HTTP/gRPC) — Use When You Need an Immediate Response

python
import httpx
 
async def get_user_profile(user_id: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"http://user-service/users/{user_id}",
            timeout=2.0
        )
        return response.json()

When to use: Real-time user-facing requests, queries that need immediate responses, services where the response is needed to complete the current operation.

Problem: If User Service is slow, Order Service is slow. Every dependency is a potential bottleneck.

Asynchronous (Events/Messages) — Use for Decoupling

python
import json
from kafka import KafkaProducer
 
producer = KafkaProducer(bootstrap_servers='kafka:9092')
 
def create_order(order_data: dict):
    order = db.save_order(order_data)
 
    # Publish event — Notification Service and Analytics pick it up independently
    producer.send('orders', json.dumps({
        'event': 'order.created',
        'order_id': order.id,
        'user_id': order.user_id,
        'amount': order.total
    }).encode())
 
    return order  # Return immediately, don't wait for downstream services

When to use: Post-processing that doesn't need to block the user response (sending emails, updating analytics, triggering fulfillment), fan-out to multiple independent consumers, operations that tolerate eventual consistency.

Data Management in Microservices

The rule: each microservice owns its data and is the only service that writes to it. No service reads another service's database directly.

plaintext
Wrong:
Order Service → orders DB ← Payment Service directly queries orders table
 
Right:
Order Service → orders DB (owns this)
Payment Service → payments DB (owns this)
Payment Service ──HTTP──→ Order Service (to ask for order data)

When you need data that spans multiple services, use API Composition:

python
async def get_order_summary(order_id: int) -> dict:
    # Fetch from multiple services in parallel
    order, payment, shipment = await asyncio.gather(
        order_service.get_order(order_id),
        payment_service.get_payment(order_id),
        shipment_service.get_shipment(order_id)
    )
    return {**order, "payment": payment, "shipment": shipment}

Or event-driven materialized views — each service maintains a local read cache of data it needs from other services, updated via events:

python
@kafka_consumer('user.profile.updated')
def handle_user_update(event: dict):
    # Order Service keeps local copy of user data needed for display
    db.execute(
        "INSERT INTO user_cache (user_id, name, email) VALUES (?, ?, ?) "
        "ON CONFLICT (user_id) DO UPDATE SET name=?, email=?",
        event['user_id'], event['name'], event['email'],
        event['name'], event['email']
    )

Real Company Examples

Shopify is the canonical counter-argument to "you need microservices to scale." Their core platform is a Rails monolith that processes $1B+ in sales on Black Friday. They've invested in modularizing it with a component architecture, but the deployment model is largely monolithic. Their secret: a world-class monolith is faster to ship features and easier to operate than a poorly-designed microservices architecture.

Netflix runs 700+ microservices. They have dedicated engineering teams for each domain (streaming, recommendations, billing, device compatibility). They also run Chaos Monkey, sophisticated distributed tracing, and a full chaos engineering discipline. Netflix's architecture is only viable because they have the engineering org to operate it.

GitHub ran a Ruby on Rails monolith for years and scaled it to serve 100 million developers. They've extracted some services over time (Actions, Packages) but the core product is still largely monolithic.

Amazon migrated from a monolith to microservices around 2001 because different teams were deploying simultaneously and blocking each other — a team problem, not a scale problem. The "two-pizza team" concept came from this migration.

The pattern: most companies start with a monolith, modularize it well, and extract services only when organizational or scaling pain makes it necessary.

When to Use Each

Stay with a monolith when:

  • Fewer than 20–30 engineers
  • Early-stage product (discovering what to build)
  • Single cross-functional team
  • Team doesn't have distributed systems expertise
  • Operational capacity is limited

Consider microservices when:

  • Multiple independent teams working on different domains
  • Parts of the system need drastically different scaling
  • Compliance isolation is required (PCI-DSS scope reduction by isolating payment handling)
  • Technology requirements differ significantly per service
  • The deployment coordination cost is measurably slowing down shipping

The right progression for most companies:

  1. Ship a monolith
  2. Modularize it with clear module boundaries
  3. Extract one or two services when there's concrete pain
  4. Repeat step 3 as needed

Key Takeaways

  • Monolith: simpler dev, deploy, debug, transactions — right for most teams under 30 engineers
  • Microservices: independent deployment, scaling, and teams — right for large organizations
  • Microservices move complexity from code to infrastructure — a bad trade for small teams
  • Conway's Law: architecture mirrors org structure. No independent teams = no independent services
  • Start with a modular monolith: clean module boundaries + single deployment
  • Use the strangler fig pattern when migrating: extract one endpoint at a time, never big-bang rewrite
  • Database per service is the rule in microservices: no cross-service direct DB access
  • Service communication: synchronous HTTP/gRPC for real-time responses, async events for decoupling
  • Shopify, GitHub, Stack Overflow prove monoliths scale to hundreds of millions of users
  • Netflix, Amazon prove microservices work — with hundreds of engineers and dedicated platform teams

Build a monolith. Modularize it well. Extract services only when the pain of not doing so is concrete and measurable.


FAQ

What is the main difference between microservices and a monolith?

A monolith deploys as a single unit — all code runs in one process, shares one database, and deploys together. Microservices split functionality into small, independent services that communicate over HTTP or messaging, each with its own database and deployment pipeline. The monolith is simpler to build, debug, and operate. Microservices enable independent deployment and scaling at the cost of distributed systems complexity.

When should I choose microservices over a monolith?

Choose microservices when: you have multiple independent teams that need to deploy without coordinating with each other, different parts of your system have radically different scaling requirements, you need technology diversity across services (Python for ML, Go for real-time), or compliance isolation requires a specific domain to be in a separate boundary. Don't choose microservices because it's "more scalable" — monoliths scale further than most companies ever need.

What is a modular monolith?

A modular monolith is a single deployable unit internally structured into modules with clearly defined, enforced interfaces between them. Each module has its own domain logic and data access, but everything deploys as one process. It gives you the code organization of microservices — clear boundaries, defined APIs between modules — without the operational overhead of distributed systems. Shopify's Rails codebase and Stack Overflow's C# codebase are examples of successful modular monoliths at massive scale.

What is the strangler fig pattern?

The strangler fig pattern is a migration strategy for moving from a monolith to microservices incrementally. Instead of rewriting the monolith all at once, you intercept traffic at the API gateway and redirect individual endpoints to new services one at a time. The monolith continues running and handling all other traffic. As more endpoints are migrated, the monolith "shrinks" until it can be retired. The key advantage: the system remains fully functional throughout, and you can roll back any extraction by reverting the gateway rule.

How do microservices handle database transactions?

They generally don't — not in the traditional ACID sense. Each service owns its own database, and cross-service consistency is achieved through eventual consistency and the saga pattern. The saga pattern breaks a distributed transaction into a sequence of local transactions, each publishing an event. If a step fails, compensating transactions undo the previous steps. This is significantly more complex than a single database transaction and requires careful design to handle failures, duplicate events, and partial state.

How should microservices communicate?

Two options based on the use case. Synchronous communication (HTTP REST or gRPC) for operations where the caller needs an immediate response — real-time user-facing requests, read queries. Asynchronous messaging (Kafka, RabbitMQ) for operations that don't need to block the response — sending emails, updating analytics, triggering downstream workflows. Use async whenever possible to reduce coupling between services and avoid cascading failures.

Why do companies start with a monolith and later migrate to microservices?

Because the problems microservices solve (independent deployment across teams, isolated scaling, technology diversity) only appear at scale. A startup with 5 engineers doesn't have the deployment coordination problem that microservices solve. By the time the company grows to 50+ engineers and does have that problem, they understand their domain well enough to draw service boundaries correctly. Companies that start with microservices prematurely end up with the wrong service boundaries, which are very expensive to fix later.


Related reading: API Gateway Pattern · Service Discovery Explained · Message Queues Explained

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.