Antwa CodeAntwaCode Blog

Engineering11 min read

Microservices vs Monolith: Which One to Choose?

Comparison of microservices and monolithic architecture.

Read in Bahasa Indonesia


Introduction

Almost every developer has been involved in the great architecture debate: microservices or monolith? If you've ever Googled this topic, you've probably ended up more confused than when you started — because both camps make very convincing arguments.

What's interesting is that the answer isn't simply "this one is better than that one." Architecture choice depends on context — your team, your product, your scale, and your business roadmap. This article takes a realistic, technical look at both architectures, not just a rehash of vendor presentation slides.

Let's start with the basics.


What Is a Monolith?

A monolith is an architecture where the entire application — from UI and business logic to data access — runs in a single, integrated deployment unit. All code is deployed together, runs in one process (or a few instances of the same process), and shares a single database.

Monolith Characteristics

  • Single repository: All code lives in one place
  • Single deployment unit: Build once, deploy once
  • Shared database: All modules access the same database
  • Tightly coupled: Modules depend on each other through direct function calls
  • Horizontal scaling: Scale by running more instances of the same application

Famous Monolith Examples

GitHub started as a massive monolith — a Ruby on Rails application handling everything from repository management and pull requests to integrations. MySQL served as the single database used by all modules.

Then there's Shopify, which still runs a large Rails monolith at its core despite serving millions of merchants. They've proven that a monolith can scale if managed correctly.

And the most classic example: Basecamp/37signals has always stayed with the monolith approach for their products, including the Hey email service, for very pragmatic reasons — small team, focus on product, no unnecessary complexity.


What Are Microservices?

Microservices is an architecture where an application is broken down into small, independent services. Each service:

  • Owns a single bounded context (business capability)
  • Has its own database
  • Can be deployed independently
  • Communicates via APIs (HTTP/gRPC) or message queues
  • Can be written in a different language or technology stack

Microservices Characteristics

  • Polyglot: Each service can use a different language/framework
  • Decentralized data: Each service has its own database
  • Independent deployment: Deploy service A without deploying service B
  • Isolation: A failure in one service doesn't take down the entire application
  • Team autonomy: Small teams own one or more services

Microservices in the Real World

Netflix is the poster child of microservices. They broke their system into over 1,000 microservices serving hundreds of millions of users. Every service — recommendation engine, billing, streaming, search — runs independently on AWS.

Amazon also adopted microservices in the early 2000s. They famously implemented the "two-pizza team" rule — each team is small enough to be fed with two pizzas, and each team owns their own service.

Grab, a ride-hailing company based in Southeast Asia, also uses a microservices architecture to handle payments, ride matching, food delivery, and dozens of other services independently.


Head-to-Head Comparison

1. Initial Complexity vs. Long-Term Complexity

Monolith: Low initial complexity. You create one project, one database, and start coding. But as the application grows, complexity grows exponentially — more code means more dependencies, which means more side effects when you change something.

Microservices: High initial complexity. You need to set up service mesh, container orchestration, distributed tracing, API gateway, service discovery, and other infrastructure from day one. But the complexity per service stays manageable because each has a small scope.

Insight: Many startups fail because of "premature microservices" — building complex infrastructure before their product has proven product-market fit.

2. Deployment

Monolith: Simple deployment. git push, CI/CD runs, a single artifact gets deployed. But if there's a bug in one small feature, you have to redeploy the entire application. During peak hours, a bad deployment can take down the whole system.

Microservices: More frequent but safer per-service deployments. You can do blue-green deployments or canary releases per service. If something goes wrong, only the affected service needs to be rolled back. However, you now have to manage 10, 50, or even hundreds of deployment pipelines.

3. Scalability

Monolith: Horizontal scaling — run more instances of the same application. The problem is, you can't scale just the hot module. If the search endpoint gets heavy traffic, you still have to scale the entire application, including the billing module that might barely be used.

Microservices: Scale per service. Need 20 instances of the search service but only 2 instances of the billing service? Done. This is far more efficient from a resource utilization standpoint.

4. Database

Monolith: One database, easy join queries, straightforward ACID transactions. But the more modules that access the same database, the more contention occurs — table locks, slow queries that hurt other modules' performance, etc.

Microservices: Database per service. But this brings new challenges: data that could be joined with SQL now has to be handled at the application level or with CQRS/event sourcing. Transactions spanning multiple services become Distributed Transactions — which are extremely complex.

5. Fault Tolerance

Monolith: If one module crashes, it usually takes down the entire application. But since everything is in one place, debugging is relatively straightforward — one log, one stack trace.

Microservices: Service A crashes, services B and C keep running. But you need circuit breakers, retry logic, fallback mechanisms, and distributed tracing to debug issues that span multiple services.

6. Developer Experience

Monolith: New developer onboarding is relatively easy — clone one repo, read one codebase, run one application. But when the codebase gets too large, navigating the code becomes a challenge in itself.

Microservices: Each service is easier to understand because of its small scope. But developers need to understand how services interact, how data flows between services, and often need to run multiple services locally for development (typically using Docker Compose).

7. Organization & Teams

Monolith: Great for small teams (3-8 developers). Everyone can be familiar with the entire codebase. Coordination is easy because everything lives in one repo.

Microservices: Better for larger teams (20+). Each team can own their service without needing to coordinate deployments with other teams. This aligns with Conway's Law — systems designed by organizations tend to mirror the structure of those organizations.


When to Choose a Monolith?

✅ A monolith is the right choice when:

  1. Your team is still small (< 10 developers). Better to focus on the product than the infrastructure.

  2. The product is still in the exploration/validation phase. You don't yet know what users need. With a monolith, pivoting is easier and faster.

  3. You don't need independent deployment. If one deployment per day is sufficient, a monolith is more practical.

  4. Your business domain is relatively simple. If your application is a CRUD app with 5-10 tables, microservices just add complexity without real benefit.

  5. You need fast time-to-market. From zero to production, a monolith can be achieved in days or months, not months or years.

Use Cases Where a Monolith Shines

  • Startup MVP validating an idea
  • Internal tools / admin panels
  • Applications for small-to-medium companies (SaaS with 100-1000 users)
  • Applications built by a team of 3-5 developers
  • Corporate websites / company profiles

When to Choose Microservices?

✅ Microservices are the right choice when:

  1. Your team is large and needs autonomy. If 20+ developers have to coordinate just to deploy one application, development throughput will be extremely low.

  2. Clear domain boundaries exist. Domain-driven design has identified clear bounded contexts — user management, order processing, payment, notifications, etc.

  3. Different modules have different scalability requirements. The search endpoint needs 100x more resources than the admin dashboard.

  4. You need a polyglot technology stack. For example, the machine learning service uses Python, the payment gateway uses Go, and the notification service uses Node.js.

  5. You've achieved product-market fit and the application is starting to hit scalability/deployment bottlenecks in the monolith.

Use Cases Where Microservices Shine

  • E-commerce platforms with millions of users
  • Ride-hailing / delivery platforms
  • Social media / content platforms
  • Banking / fintech with many separate modules
  • Multi-tenant SaaS serving thousands of customers

The Strangler Fig Pattern: A Migration Strategy from Monolith to Microservices

If you're already on a monolith and feel the need to move to microservices, don't rewrite from scratch. Use the Strangler Fig Pattern — an incremental migration strategy that's far safer.

The Concept

The Strangler Fig Pattern is named after the fig tree that grows around a host tree, gradually replacing it. In a software context:

  1. Build a new microservice for one feature / bounded context
  2. Set up a reverse proxy or API gateway that routes traffic to the new service or the monolith based on routing rules
  3. Once the new service is stable, gradually shift traffic
  4. Remove the old code from the monolith
  5. Repeat for the next feature

Practical Steps

Phase 1: Identify and Prioritize

# Analyze dependencies in the monolith
# Find the most frequently changed modules (high churn)
git log --oneline --since="6 months ago" | \
  awk '{print $NF}' | sort | uniq -c | sort -rn | head -20

Choose modules with:

  • High churn (frequently changing)
  • Low dependency (few other modules depend on them)
  • A dedicated team maintaining them

Phase 2: Build the New Service

For example, you want to extract a "notification service" from the monolith:

# notification_service/app.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
 
app = FastAPI(title="Notification Service")
 
class NotificationRequest(BaseModel):
    user_id: str
    channel: str  # email, sms, push
    template: str
    data: dict
    priority: Optional[str] = "normal"
 
@app.post("/notify")
async def send_notification(req: NotificationRequest):
    # Notification sending logic
    if req.channel == "email":
        result = await send_email(req)
    elif req.channel == "sms":
        result = await send_sms(req)
    elif req.channel == "push":
        result = await send_push(req)
 
    return {"status": "sent", "notification_id": result.id}
 
@app.get("/health")
async def health_check():
    return {"status": "healthy"}

Phase 3: Set Up Routing in the API Gateway

# api-gateway/routes.yaml
routes:
  - path: /api/notifications/**
    service: notification-service
    port: 8080
 
  - path: /api/**
    service: monolith  # fallback to monolith
    port: 3000

Phase 4: Gradual Traffic Shifting

# Canary: start with 5% of traffic to the new service
curl -X POST http://api-gateway/admin/routing \
  -d '{"path": "/api/notifications/**", "weight": 5}'
 
# Monitor for a week...
# If OK, increase to 25%, 50%, 100%

Phase 5: Cleanup

After 100% of traffic is going to the new service:

  • Remove the notification code from the monolith
  • Drop unused database tables
  • Update documentation

Distributed Systems: The Reality You'll Face

If you move to microservices, brace yourself for problems that don't exist in monoliths.

The Eight Fallacies of Distributed Systems (Peter Deutsch)

  1. The network is reliable — The network will fail. Often.
  2. Latency is zero — Every inter-service call adds latency (milliseconds, not microseconds).
  3. Bandwidth is infinite — Network bandwidth is limited, especially when lots of data needs to move between services.
  4. The network is secure — Every endpoint between services is a potential attack vector.
  5. Topology doesn't change — Services can be scaled, restarted, or moved at any time.
  6. There is one administrator — Multiple teams manage different services.
  7. Transport cost is zero — Serialization/deserialization (JSON, Protobuf) consumes CPU time.
  8. The network is homogeneous — Services can be written in different languages/technologies.

Problems You'll Need to Solve

1. Service Discovery

Service A needs to know the address of Service B. In a monolith, you just call a function. In microservices, you need a service registry (Consul, etcd) or DNS-based discovery.

# Automatic Kubernetes service discovery
apiVersion: v1
kind: Service
metadata:
  name: notification-service
spec:
  selector:
    app: notification-service
  ports:
    - port: 8080
      targetPort: 8080

2. Circuit Breaker

If Service B goes down, Service A shouldn't keep retrying until timeout. Implement a circuit breaker:

# Python example using tenacity
from tenacity import retry, stop_after_attempt, wait_exponential
 
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
async def call_notification_service(data):
    response = await httpx.post(
        "http://notification-service/notify",
        json=data,
        timeout=5.0
    )
    response.raise_for_status()
    return response.json()

3. Distributed Tracing

When a request flows through 5 different services, debugging becomes a nightmare without distributed tracing. Use OpenTelemetry:

# Every service must propagate trace context
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
 
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://jaeger:4317"))
)
trace.set_tracer_provider(tracer_provider)
tracer = trace.get_tracer("order-service")

4. Data Consistency

In a monolith, you can use standard database transactions:

BEGIN;
  INSERT INTO orders (...) VALUES (...);
  UPDATE inventory SET stock = stock - 1 WHERE product_id = ...;
  INSERT INTO payments (...) VALUES (...);
COMMIT;

In microservices, order-service, inventory-service, and payment-service each have their own database. You'll need the Saga Pattern or Event Sourcing:

Order Service: Create Order → Publish OrderCreated Event
Inventory Service: Listen OrderCreated → Reserve Stock → Publish StockReserved
Payment Service: Listen OrderCreated → Process Payment → Publish PaymentCompleted
Order Service: Listen PaymentCompleted → Confirm Order
// If PaymentFailed → Compensate: Release Stock, Cancel Order

Decision Framework: Architecture Selection Checklist

Use this checklist to determine the right architecture for your project:

Monolith Scorecard (+1 each)

  • Team < 10 developers
  • Product is still pre-PMF / MVP
  • Simple domain (< 5 bounded contexts)
  • No need for independent module scaling
  • Need fast time-to-market (< 3 months to production)
  • Limited infrastructure budget
  • Team doesn't have distributed systems experience

Microservices Scorecard (+1 each)

  • Team > 15 developers, needs autonomous teams
  • Product is post-PMF with a significant user base
  • Complex domain with ≥ 5 clear bounded contexts
  • Different modules have very different scalability requirements
  • Need a polyglot technology stack
  • High deployment frequency (multiple per day per service)
  • Team has DevOps / SRE capability

Monolith score > Microservices: Choose monolith. You can migrate later if needed. Microservices score > Monolith + team has DevOps capability: Consider microservices. Scores are close: Start with a monolith, but design it to be stranglable later.


The Hybrid Approach: Modular Monolith as a Middle Ground

There's a third option that's often overlooked: the modular monolith. The concept:

  • Single deployment unit (monolith)
  • But code is organized into isolated modules
  • Each module exposes a clear interface
  • Database can be split per module or remain shared with schema separation
  • Module boundaries are enforced by tooling (ArchUnit, boundary checks, etc.)

Example Structure

src/
├── modules/
│   ├── user/
│   │   ├── api/
│   │   │   └── UserController.java
│   │   ├── domain/
│   │   │   └── User.java
│   │   ├── application/
│   │   │   └── UserService.java
│   │   └── infrastructure/
│   │       └── UserRepository.java
│   ├── order/
│   │   ├── api/
│   │   ├── domain/
│   │   ├── application/
│   │   └── infrastructure/
│   └── payment/
│       ├── api/
│       ├── domain/
│       ├── application/
│       └── infrastructure/
└── shared/
    ├── kernel/
    └── infrastructure/

Key Rules

  1. Modules must not access each other directly — only through published interfaces
  2. Each module has its own database schema (can be a shared DB instance, but schemas are separate)
  3. Inter-module communication via events or service interfaces, not direct DB access

A modular monolith gives you 80% of microservices' benefits (clean boundaries, independently testable modules) with only 20% of the complexity.


Real-World Migration Stories

Shopify: The Monolith That Scales

Shopify decided to stick with their Rails monolith. But they did massive refactoring:

  • Split their database into "cells" — each cell serves a subset of merchants
  • Used service-oriented modules within the monolith
  • Introduced async processing with job queues
  • Built a custom database proxy for horizontal scaling

The result? A monolith serving millions of merchants that stays scalable without migrating to microservices.

SoundCloud: Gradual Migration from Monolith

SoundCloud migrated from a Ruby on Rails monolith to microservices incrementally:

  1. Started by extracting the "playback service"
  2. Added "track service," "user service," "search service"
  3. Used REST APIs and event-driven communication
  4. The migration took several years, but with zero downtime

Their key to success: incremental migration using the Strangler Fig Pattern.

Majoo (Indonesia): Microservices from the Start

Majoo, an Indonesian fintech startup, built their architecture as microservices from the beginning due to their domain complexity — payments, lending, merchant management, analytics, and dozens of other features. They used Kubernetes and a service mesh (Istio) to manage hundreds of services from the start.


For Monoliths

ComponentRecommendation
LanguageRuby (Rails), Python (Django), Java (Spring Boot), PHP (Laravel)
DatabasePostgreSQL, MySQL
DeploymentPaaS (Heroku, Railway, Render) or a simple VPS
MonitoringSentry (errors), UptimeRobot (availability)
CI/CDGitHub Actions, GitLab CI

For Microservices

ComponentRecommendation
ContainerDocker
OrchestrationKubernetes, Docker Swarm
API GatewayKong, Traefik, Envoy
Service MeshIstio, Linkerd
Service DiscoveryK8s DNS, Consul
ObservabilityPrometheus + Grafana, Jaeger (tracing), ELK Stack
Message QueueKafka, RabbitMQ, NATS
CI/CDArgoCD, Flux, Jenkins X

Conclusion

There's no perfect architecture. Microservices aren't a silver bullet, and monoliths aren't outdated.

Choose a monolith if:

  • You're still finding product-market fit
  • Your team is small
  • Your domain isn't overly complex
  • You need high velocity

Choose microservices if:

  • Your team is large and needs autonomy
  • Different modules have very different scalability needs
  • You've achieved product-market fit and are hitting monolith bottlenecks

Choose a modular monolith if:

  • You want clean boundaries without distributed systems complexity
  • Your team is growing and isn't ready for full microservices

Most importantly, remember: good architecture is architecture that can evolve. Start simple, refactor when real problems arise — not imagined ones.

Happy coding! 🚀


References

More posts