backend 9 read

Microservices vs Monolithic: How to Choose the Right Architecture

Honest comparison of microservices and monolithic architecture. When each makes sense, real trade-offs, and how to decide.

By Dmytro Klymentiev
Microservices vs Monolithic: How to Choose the Right Architecture

The tech industry loves microservices. Conference talks, blog posts, job descriptions - everyone's doing microservices. But should you?

Probably not. At least not yet.

I've built and maintained both architectures. Here's the honest truth about when each makes sense.

Quick Answer

Start with a monolith. Move to microservices when you have a specific problem that microservices solve.

Most applications never need microservices. The ones that do usually start as monoliths first.

What's the Difference?

Monolithic Architecture

One codebase, one deployment, one database. Everything runs together.

[Web App] → [Single Application] → [Database]

All your code lives in one place. User authentication, payments, notifications, business logic - all in one deployable unit.

// Monolith: Direct function call
class OrderService {
    public function createOrder(array $items, User $user): Order {
        // Validate inventory - direct call
        $this->inventoryService->reserve($items);

        // Process payment - same process
        $this->paymentService->charge($user, $this->calculateTotal($items));

        // Send notification - instant
        $this->notificationService->sendOrderConfirmation($user, $order);

        return $order;
    }
}

Simple. Fast. One transaction wraps everything.

Microservices Architecture

Multiple independent services, each with its own codebase and database. Services communicate over the network.

[API Gateway] → [User Service] → [User DB]
              → [Payment Service] → [Payment DB]
              → [Notification Service] → [Notification DB]
              → [Inventory Service] → [Inventory DB]

Each service does one thing. Teams can deploy independently. Services can scale independently.

# Microservice: HTTP call to another service
class OrderService:
    async def create_order(self, items: list, user_id: str) -> Order:
        # Validate inventory - network call, might fail
        try:
            await self.http_client.post(
                f"{INVENTORY_SERVICE_URL}/reserve",
                json={"items": items},
                timeout=5.0
            )
        except TimeoutError:
            raise ServiceUnavailableError("Inventory service timeout")

        # Process payment - another network call
        payment_response = await self.http_client.post(
            f"{PAYMENT_SERVICE_URL}/charge",
            json={"user_id": user_id, "amount": total}
        )

        # Queue notification - async message
        await self.message_queue.publish(
            "notifications",
            {"type": "order_confirmation", "user_id": user_id}
        )

        return order

More code. More failure modes. But more flexibility.

The Real Trade-offs

Monolith Advantages

Simplicity

  • One codebase to understand
  • One deployment to manage
  • One database to query
  • Local function calls, no network latency

Development Speed

  • Easier to refactor across boundaries
  • No service coordination needed
  • Simpler debugging (one log, one trace)
  • Faster to get started

Operations

  • One thing to deploy
  • One thing to monitor
  • One thing to scale (vertically at first)
  • Easier to reason about failures

Data Consistency

  • ACID transactions across the entire system
  • No distributed transaction headaches
  • Referential integrity enforced by the database

Microservices Advantages

Independent Scaling

  • Scale only what needs scaling
  • Different services can use different resources
  • Cost optimization at scale

Team Independence

  • Teams own services end-to-end
  • Deploy without coordinating with other teams
  • Choose the right technology per service

Fault Isolation

  • One service failing doesn't crash everything
  • Easier to implement circuit breakers
  • Graceful degradation possible

Technology Flexibility

  • Different languages per service
  • Different databases per service
  • Easier to adopt new tech incrementally

The Hidden Costs of Microservices

Nobody talks about these enough:

Network Complexity

Every service call is a network call. Networks fail. Networks are slow. You need:

  • Retry logic with exponential backoff
  • Timeouts (and deciding what timeout values)
  • Circuit breakers to prevent cascade failures
  • Service discovery (Consul, Kubernetes DNS, etc.)
  • Load balancing between service instances

What was a function call is now a distributed systems problem.

# Simple in monolith:
result = user_service.get_user(user_id)

# Complex in microservices:
async def get_user_with_retry(user_id: str, retries: int = 3):
    for attempt in range(retries):
        try:
            async with timeout(2.0):
                response = await http_client.get(
                    f"{USER_SERVICE_URL}/users/{user_id}"
                )
                if response.status == 200:
                    return response.json()
                elif response.status == 404:
                    return None
                elif response.status >= 500:
                    if attempt < retries - 1:
                        await asyncio.sleep(2 ** attempt)
                        continue
                    raise ServiceError("User service unavailable")
        except TimeoutError:
            if attempt < retries - 1:
                continue
            raise ServiceError("User service timeout")

Data Consistency

With one database, transactions are easy. With multiple databases:

  • No cross-service transactions
  • Eventual consistency by default
  • Saga patterns for multi-service operations
  • Debugging data issues is painful

Example: Creating an order that involves inventory, payment, and shipping. In a monolith:

BEGIN TRANSACTION;
  UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 123;
  INSERT INTO payments (user_id, amount) VALUES (456, 99.99);
  INSERT INTO orders (user_id, product_id, status) VALUES (456, 123, 'confirmed');
COMMIT;

In microservices, you need a saga:

async def create_order_saga(order_data):
    # Step 1: Reserve inventory
    reservation = await inventory_service.reserve(order_data.items)

    try:
        # Step 2: Charge payment
        payment = await payment_service.charge(order_data.user_id, order_data.total)
    except PaymentFailed:
        # Compensate: Release inventory
        await inventory_service.release(reservation.id)
        raise

    try:
        # Step 3: Create order
        order = await order_service.create(order_data)
    except OrderCreationFailed:
        # Compensate: Refund payment and release inventory
        await payment_service.refund(payment.id)
        await inventory_service.release(reservation.id)
        raise

    return order

More code. More failure modes. More things to test.

Operational Overhead

Instead of one thing to monitor, you have N things:

  • Multiple deployments to manage
  • Multiple log streams to aggregate
  • Distributed tracing required (Jaeger, Zipkin)
  • More infrastructure to manage (service mesh, API gateway)

Testing Complexity

  • Integration tests need multiple services running
  • Contract testing between services (Pact, etc.)
  • End-to-end tests are harder to maintain
  • Local development setup becomes complex

Cognitive Load

Developers need to understand:

  • Service boundaries and responsibilities
  • Communication patterns (sync vs async)
  • Failure modes and recovery strategies
  • Distributed system concepts (CAP theorem, eventual consistency)

Communication Patterns

How services talk to each other matters. Two main approaches:

Synchronous (HTTP/gRPC)

Request-response. Service A calls Service B and waits for response.

[Order Service] --HTTP POST--> [Payment Service]
                <--200 OK-----

Pros:

  • Simple mental model
  • Immediate feedback
  • Easy to debug

Cons:

  • Tight coupling
  • Cascade failures (if Payment is down, Order is down)
  • Latency adds up

When to use:

  • When you need immediate response
  • Read operations
  • Simple request-response patterns

Asynchronous (Message Queues)

Fire and forget. Service A publishes message, Service B consumes when ready.

[Order Service] --publish--> [Message Queue] --consume--> [Notification Service]

Technologies: RabbitMQ, Apache Kafka, AWS SQS, Redis Streams

Pros:

  • Loose coupling
  • Better fault tolerance
  • Natural load leveling

Cons:

  • More complex debugging
  • Eventual consistency
  • Message ordering challenges

When to use:

  • Notifications, emails
  • Background processing
  • Event-driven workflows
  • When consumer doesn't need immediate response

gRPC vs REST

For synchronous communication:

AspectRESTgRPC
ProtocolHTTP/1.1 or HTTP/2HTTP/2 only
FormatJSON (text)Protocol Buffers (binary)
PerformanceGoodBetter (smaller payloads, multiplexing)
ToolingUniversalRequires code generation
Browser supportNativeNeeds grpc-web proxy
Learning curveLowMedium

My recommendation: Start with REST. Switch to gRPC if you have internal service-to-service calls with high throughput requirements.

When Microservices Make Sense

You Have Multiple Teams

If you have 50+ engineers and clear domain boundaries, microservices let teams move independently. Below that, the coordination overhead usually isn't worth it.

You Have Extreme Scale Requirements

If one part of your system needs 100x the resources of another, independent scaling matters. For most apps, this isn't the case.

You Have Different Reliability Requirements

If your payment system needs 99.99% uptime but your analytics can tolerate more downtime, separating them makes sense.

You're Integrating Acquired Systems

Merging two companies with different tech stacks? Microservices can be a bridge.

You Have Different Security Domains

PCI compliance for payments? HIPAA for health data? Isolating these into separate services with different security controls makes sense.

When to Stay Monolithic

You're a Small Team

Less than 10 engineers? Monolith. The overhead of microservices will slow you down.

You're Still Finding Product-Market Fit

Need to pivot quickly? Monolith. Refactoring a monolith is easier than redesigning service boundaries.

Your Domain Boundaries Are Unclear

Don't know where to split services? Don't split yet. Wrong boundaries are worse than no boundaries.

You Don't Have DevOps Maturity

No CI/CD pipeline? No infrastructure automation? No monitoring? Fix those first. Microservices without solid DevOps is painful.

The Middle Ground: Modular Monolith

Best of both worlds for most teams:

[Single Deployment]
├── User Module (isolated code, clear interface)
├── Payment Module (could become a service later)
├── Notification Module (internal boundaries)
└── Shared Database (for now)

Write your monolith with clear module boundaries. Each module has:

  • Its own directory structure
  • Well-defined interfaces (no reaching into another module's internals)
  • Minimal dependencies on other modules
  • Its own database schema (tables prefixed or in separate schemas)
src/
├── modules/
│   ├── users/
│   │   ├── UserService.php
│   │   ├── UserRepository.php
│   │   └── api/           # Public interface
│   │       └── UserApi.php
│   ├── payments/
│   │   ├── PaymentService.php
│   │   └── api/
│   │       └── PaymentApi.php
│   └── orders/
│       ├── OrderService.php
│       └── api/
│           └── OrderApi.php
└── shared/
    └── database/

Modules communicate through defined APIs, not by reaching into each other's code. When (if) you need to extract a service, the boundaries are already there.

Migration Path

If you do need microservices later:

  1. Start with the monolith - Get the product working
  2. Identify boundaries - Find natural seams in your domain (Domain-Driven Design helps here)
  3. Modularize - Enforce boundaries within the monolith
  4. Extract strangler pattern - Pull out one service at a time
  5. Repeat - Only extract what needs extracting

Don't rewrite everything. Extract incrementally. The Strangler Fig pattern:

Before:
[Client] → [Monolith handles everything]

During migration:
[Client] → [API Gateway] → [Monolith (most features)]
                         → [New Payment Service (extracted)]

After (if needed):
[Client] → [API Gateway] → [User Service]
                         → [Order Service]
                         → [Payment Service]

Real Talk

Netflix, Amazon, and Google use microservices. They also have thousands of engineers and massive scale. You're not them.

"But we need to scale!" Vertical scaling (bigger servers) goes further than you think. Many successful products run on a single powerful server. Basecamp runs on a few servers. Stack Overflow handled millions of requests with a handful of servers.

"But microservices are modern!" Architecture isn't fashion. Pick what solves your actual problems.

"But hiring is easier with microservices!" Maybe. But hiring is also easier when your codebase isn't a distributed mess that takes months to understand.

Decision Framework

Ask yourself:

  1. Do we have more than 50 engineers?
  2. Do different parts of our system have wildly different scaling needs?
  3. Do we have mature DevOps practices (CI/CD, monitoring, infrastructure as code)?
  4. Are our domain boundaries crystal clear?
  5. Is our current architecture actually causing problems?

If you answered "no" to most of these, stick with a well-structured monolith.

Frequently Asked Questions

Can I use Kubernetes with a monolith?

Yes. Kubernetes is useful for container orchestration regardless of architecture. You can run a monolith in Kubernetes for easy scaling, deployments, and self-healing.

What about serverless?

Serverless (AWS Lambda, etc.) is orthogonal to this discussion. You can have a monolithic serverless app or microservices running on servers. Serverless adds its own trade-offs (cold starts, vendor lock-in, debugging complexity).

How do I know when it's time to split?

Signs you might need to extract a service:

  • One team is constantly waiting for another team's changes
  • One part of the system needs 10x more resources than the rest
  • You need different deployment schedules for different features
  • Regulatory requirements demand isolation

What database should each microservice use?

Whatever fits the use case. But having different databases per service adds operational complexity. Start with one database type (PostgreSQL handles most cases well) unless you have specific requirements.

Is a "distributed monolith" really that bad?

Yes. It's the worst of both worlds: distributed system complexity without the benefits of independent deployability. If your services can't deploy independently, you don't have microservices.

Bottom Line

Microservices solve organizational and scaling problems at the cost of complexity. Most teams don't have those problems yet.

Start simple. Add complexity when you have specific problems that require it. The companies that successfully use microservices usually started with monoliths.

The best architecture is the one that lets your team ship features quickly and reliably. For most teams, that's a well-organized monolith.


Need help with system architecture decisions? I help teams design systems that fit their actual needs - whether that's a clean monolith or a thoughtful microservices approach. Let's talk about your situation.

Need help with backend?

Let's discuss your project

Get in touch
RELATED