Showing posts with label microservices. Show all posts
Showing posts with label microservices. Show all posts

Friday, April 17, 2026

Microservices vs Monolith in 2026: The Honest Decision Framework

Hero image

Introduction

In 2016, the industry consensus was loud and confident: monoliths are legacy, microservices are the future. Every conference talk, every architectural review, every greenfield project brief had the same answer. Split everything into services. Deploy them independently. Scale them independently. The architecture would mirror the organization, and the organization would ship faster.

A decade later, the honest post-mortems are piling up. Amazon famously decomposed their retail monolith into services, and that decomposition genuinely enabled their growth. But Amazon also has thousands of engineers, a dedicated distributed systems platform team, and the scale to justify the overhead. Most teams that cargo-culted the pattern got the complexity without the scale that justifies it. Monoliths were rebuilt from scratch as distributed systems and became harder to understand, harder to debug, and slower to ship.

Shopify runs one of the world's largest e-commerce platforms on a Ruby on Rails monolith. Stack Overflow serves millions of developers per month on nine physical servers. Prime Video's video monitoring team made headlines in 2023 when they collapsed their microservices architecture back into a monolith and reduced costs by 90 percent. These aren't edge cases or embarrassing admissions — they're engineering teams making correct decisions for their scale and team structure.

The microservices vs. monolith debate was always the wrong framing. The right question is: what level of distribution is right for this team, at this scale, with these constraints? That question has a different answer in 2026 than it did in 2016, because the costs of getting it wrong are better understood. We have more failure data. We have more honesty about the operational tax that distributed systems impose. And we have a clearer-eyed picture of when distribution actually delivers its promised benefits versus when it just moves the complexity from code into the network.

This post is an honest decision framework — not a technology endorsement. We will look at when monoliths are the right call, when microservices are genuinely justified, how to decompose when the time comes, how to handle service communication without building a reliability nightmare, and what the real operational costs look like before you commit to them. Position taken upfront: for most teams at most stages, a well-structured modular monolith is the correct default. Extract services when you have a specific, demonstrable reason. Not because a blog post said to.


1. The Monolith Is Not the Problem

The word "monolith" has become a pejorative in engineering culture, synonymous with technical debt, deployment risk, and legacy thinking. This is a category error. A monolith is a deployment topology, not a quality judgment. The distinction that actually matters is not monolith vs. microservices — it is modular vs. tangled.

A tangled monolith is what people are actually afraid of. It is a codebase where the user service imports the billing service which imports the analytics service which imports the user service again. Every change ripples unpredictably across the system. The test suite takes 45 minutes because nothing can be tested in isolation. Deployment is a full-system rebuild, and every release is a roulette wheel because nobody knows what touched what. This is a real problem, but it is a problem of internal architecture, not deployment topology. Converting a tangled monolith into microservices does not fix the tangle — it promotes it to a distributed tangle, which is harder to observe and harder to fix.

A modular monolith is structured around clear domain boundaries with explicit interfaces between modules. The payment module exposes a PaymentService interface. The order module calls that interface. Neither module reaches into the other's internals. The modules are independently testable, internally cohesive, and externally loosely coupled. The fact that they all run in the same process is incidental. Netflix's original monolith was modular. So is the Django codebase powering Instagram, and the Rails codebase powering Shopify.

Shopify is the canonical example worth sitting with. At the time of writing, Shopify processes more than $10 billion in GMV annually, handles traffic spikes that would buckle most architectures, and runs a global merchant and consumer platform — all on a Rails monolith they call their "modular monolith." They have invested heavily in defining module boundaries, preventing cross-module data access, and building internal tooling to enforce the rules. It is not simple, but it is significantly simpler than the alternative. Their chief architect has said publicly that the modular monolith is the right choice for Shopify at Shopify's scale, and that rewriting it as microservices would consume years of engineering effort for uncertain benefit.

Stack Overflow is the other number to keep in your head. Nine physical servers. Millions of page views per month. The team is small, the deployment is simple, and the performance is exceptional — because SQL Server, careful indexing, and in-process caching inside a single deployment unit beats the overhead of service-to-service network calls at that traffic volume.

graph TB subgraph "Tangled Monolith — The Real Problem" US1[User Service] -->|direct DB access| PD1[(Payment DB)] BS1[Billing Service] -->|circular import| US1 AS1[Analytics Service] -->|shared global state| BS1 OS1[Order Service] -->|direct table join| US1 US1 -->|side-effect import| AS1 end subgraph "Modular Monolith — Same Process, Clean Boundaries" US2[User Module] -->|interface| PS2[PaymentService Interface] BS2[Billing Module] -->|implements| PS2 AS2[Analytics Module] -->|event subscriber| EB2[Internal Event Bus] OS2[Order Module] -->|emits events| EB2 US2 -->|own DB schema| UD2[(users schema)] BS2 -->|own DB schema| BD2[(billing schema)] OS2 -->|own DB schema| OD2[(orders schema)] end

The real signals that a monolith has a structural problem — and not that it needs to be decomposed into services — are: circular dependencies between modules, a shared database god object where every module reads every table, the inability to run any subset of the codebase in isolation, and deployment gates that require every team to sign off because every change can affect every other change. These are problems you fix through refactoring and internal boundary enforcement, not through network boundaries. Building clear module interfaces is the prerequisite to decomposition. If you cannot define a clean interface between two modules inside a monolith, extracting them as services will not create one — it will just add latency to the confusion.

The design principle that matters most for future decomposability is domain-first module organization. Organize code by business domain (orders, payments, inventory, notifications), not by technical layer (controllers, services, repositories). Vertical slices that own their domain from API to database are far easier to extract into independent services later than horizontal layers that cut across every domain. Build the modular monolith correctly, and you have an extraction-ready architecture. Skip the modular structure in favor of early extraction, and you will be debugging distributed transactions before your user base justifies it.

Architecture diagram

2. When Microservices Are Justified

The useful question is not "should we use microservices?" but "do we have a specific problem that service extraction solves, where the solution's cost is less than the problem's cost?" Most of the time the answer is no. Some of the time — at sufficient scale, with sufficient team complexity — the answer is yes.

Independent scaling requirements are the clearest technical justification. If your payment processing workload requires 10x the compute during peak hours and your user authentication workload requires none, deploying them as separate services means you scale payment horizontally without paying for unused authentication capacity. Inside a monolith, you scale everything together. At the scale where that inefficiency costs meaningful money — typically when your infrastructure bill is in the tens of thousands per month — this math starts to matter. At startup scale, the waste from over-provisioning a single deployment is negligible compared to the engineering overhead of managing multiple services.

Different deployment cadences are the second strong technical justification. If your ML inference service needs to redeploy every hour as the model is retrained, and your core user service deploys once a month, coupling those two inside a monolith means every model refresh triggers a full system deployment, with all the associated risk, testing, and coordination. Decoupling their deployment cycles through service boundaries is a direct reduction in deployment risk, not an increase.

Team autonomy at Conway's Law scale is the organizational justification. Conway's Law states that systems reflect the communication structures of the organizations that build them. The inverse is also useful: if you have three independent teams with distinct ownership boundaries, a monolith will create constant merge conflicts, deployment coordination costs, and organizational friction that a services-based architecture resolves. This is not a technical requirement — it is an organizational one. But it is real. The signal to watch for is: are multiple teams fighting over deployment? Are you scheduling release windows to coordinate between teams? Are merge conflicts in shared modules a weekly source of delay? That is the organizational pressure that service extraction is designed to relieve.

Compliance isolation is the fourth justification, and often underweighted. PCI DSS scope is a real concern for any team handling payment card data. If you can isolate all cardholder data handling into a single service with its own infrastructure, you reduce the audit surface area from your entire system to one bounded component. The same logic applies to HIPAA compliance for health data, SOC 2 boundaries, and GDPR data residency requirements. Service extraction for compliance isolation is justified at any scale because the alternative — scoping your entire monolith under PCI DSS — is significantly more expensive in audit costs and ongoing compliance overhead.

flowchart TD Start([New service extraction request]) --> Q1{Do 2+ teams fight
over deploys monthly?} Q1 -->|No| Q2{Wildly different
scaling needs?} Q1 -->|Yes| Q3{Team size > 15?} Q3 -->|No| Stay[Keep in monolith\nFix process, not architecture] Q3 -->|Yes| Extract[Extract service] Q2 -->|No| Q4{Different deploy
cadences causing risk?} Q2 -->|Yes| Q5{Cost waste > $5k/mo?} Q5 -->|No| Stay Q5 -->|Yes| Extract Q4 -->|No| Q6{Compliance isolation
required? PCI/HIPAA} Q4 -->|Yes| Extract Q6 -->|Yes| Extract Q6 -->|No| Stay style Extract fill:#2d6a4f,color:#fff style Stay fill:#6b2737,color:#fff

The signal that is often mistaken for a microservices justification is team or engineer count. "We have 50 engineers, therefore we need microservices" is not valid logic. You need microservices when 50 engineers are organized into independent product teams with independent ownership, independent deployment, and independent scaling requirements. If 50 engineers are all working on the same product with shared ownership and coordinated releases, a modular monolith serves them better than services. The 2-pizza team rule from Amazon applies to the team ownership model, not to headcount alone.

The signal that actually indicates readiness for service extraction is operational maturity: do you have distributed tracing deployed? Do you have a service registry and health check infrastructure? Do you have on-call rotations capable of debugging cross-service failures at 2am? Without those foundations, extracting a service creates problems you cannot diagnose. Build the observability platform before you need it to debug a production incident in a distributed system.


3. Decomposition Patterns

When the decision to extract a service is made — based on the framework above, not on hype — the implementation matters enormously. Big-bang rewrites are the highest-risk migration path and the most common mistake. Every successful decomposition from a production system uses incremental migration patterns.

The Strangler Fig is the most battle-tested incremental migration pattern. The name comes from the strangler fig tree, which grows around an existing tree and gradually replaces it. In software, you route a subset of traffic to the new service while the old code still handles the rest. Over months, you increase the new service's traffic share, fix its bugs under production load, and eventually decommission the old code path. The monolith shrinks. The new service grows. At no point do you have a hard cutover.

# strangler_fig_router.py
# Routes requests to either the legacy monolith handler or the new payment service
# based on a feature flag. Enables gradual traffic migration with instant rollback.

import os
import httpx
from typing import Optional
from dataclasses import dataclass

# Feature flag thresholds — increase these gradually as confidence builds
# 0.0 = all traffic to legacy, 1.0 = all traffic to new service
PAYMENT_SERVICE_TRAFFIC_PERCENT = float(os.getenv("PAYMENT_SERVICE_TRAFFIC_PCT", "0.0"))

@dataclass
class PaymentRequest:
    order_id: str
    amount_cents: int
    currency: str
    customer_id: str

@dataclass
class PaymentResult:
    success: bool
    transaction_id: Optional[str]
    error: Optional[str]

class StranglerFigPaymentRouter:
    """
    Routes payment processing requests between the legacy monolith handler
    and the new standalone payment service. Uses deterministic hashing on
    order_id so the same order always goes to the same backend during
    migration — prevents split-brain issues where one system charges but
    the other records the transaction.
    """

    def __init__(self, legacy_handler, new_service_url: str):
        self.legacy_handler = legacy_handler
        self.new_service_url = new_service_url
        self.http_client = httpx.AsyncClient(timeout=5.0)

    def _should_use_new_service(self, order_id: str) -> bool:
        """
        Deterministic routing: hash the order_id to decide which backend
        handles this request. Same order_id always routes consistently,
        regardless of when the request arrives or which server handles it.
        """
        # Simple consistent hash: use last 4 hex chars of order_id
        # to get a stable 0-9999 bucket, then compare to threshold
        bucket = int(order_id[-4:], 16) % 10000
        threshold = int(PAYMENT_SERVICE_TRAFFIC_PERCENT * 100)
        return bucket < threshold

    async def process_payment(self, request: PaymentRequest) -> PaymentResult:
        if self._should_use_new_service(request.order_id):
            return await self._call_new_service(request)
        else:
            return await self._call_legacy(request)

    async def _call_new_service(self, request: PaymentRequest) -> PaymentResult:
        """Call the extracted payment microservice via HTTP."""
        try:
            response = await self.http_client.post(
                f"{self.new_service_url}/v1/payments",
                json={
                    "order_id": request.order_id,
                    "amount_cents": request.amount_cents,
                    "currency": request.currency,
                    "customer_id": request.customer_id,
                },
            )
            data = response.json()
            if response.status_code == 200:
                return PaymentResult(
                    success=True,
                    transaction_id=data["transaction_id"],
                    error=None,
                )
            return PaymentResult(success=False, transaction_id=None, error=data.get("error"))
        except httpx.TimeoutException:
            # On timeout, fall back to legacy — safety net during migration
            return await self._call_legacy(request)

    async def _call_legacy(self, request: PaymentRequest) -> PaymentResult:
        """Call the original monolith payment handler."""
        return await self.legacy_handler.process_payment(request)

Branch by Abstraction works when you cannot control routing at the HTTP layer. Introduce an interface that both the old and new implementations satisfy. Initially the interface delegates to the old code. You write the new implementation behind the interface. Once the new implementation passes tests, you flip the implementation at the injection point. The calling code never changes.

Domain-Driven Design bounded contexts should define your service boundaries, not technical convenience. A bounded context is a subsystem with its own domain model, its own language, and its own data. The Order concept in your ordering context has different attributes and behaviors than the Order concept in your fulfillment context. Trying to share one Order model across both creates the tight coupling that makes services hard to evolve independently.

Database-per-service is the hardest constraint and the most important one. A shared database between two services is not a microservices architecture — it is a distributed monolith with all the overhead of services and none of the independence. If service A and service B both read from the same table, they cannot be deployed or scaled independently. Any schema change requires coordinating both services. The independence that justifies the complexity of separate services requires separate data ownership. This means denormalization. It means eventual consistency between services. It means accepting that you cannot use a JOIN across service boundaries. Those costs are real, and they are why shared databases are so tempting. They are also why so many microservices migrations fail to deliver their promised independence.

sequenceDiagram participant Client participant Router as Strangler Fig Router participant Legacy as Monolith (Legacy) participant New as Payment Service (New) Note over Router: Phase 1: 0% to new service Client->>Router: POST /payments Router->>Legacy: forward 100% of traffic Legacy-->>Client: response Note over Router: Phase 2: 10% to new service Client->>Router: POST /payments Router->>Router: hash(order_id) % 10000 < 1000? Router->>New: 10% of traffic (canary) New-->>Client: response Note over Router: Phase 3: 100% to new service Client->>Router: POST /payments Router->>New: forward all traffic New-->>Client: response Note over Legacy: Decommission legacy path

The anti-corruption layer pattern prevents new service boundaries from being contaminated by the legacy domain model. When extracting a service from a monolith, the legacy codebase has its own internal model — often a god object with 60 fields that represents "everything about a customer." The new service has a clean, bounded model. The anti-corruption layer is a translation component at the boundary that converts the legacy model into the new service's model and back. Without it, the new service's design gets polluted by the legacy model's shape, and you have not actually established a new boundary — you have just moved the legacy model into a new process.

Comparison visual

4. Service Communication Patterns

How services talk to each other is where distributed systems earn their complexity tax. Every communication pattern is a tradeoff between latency, reliability guarantees, operational overhead, and coupling. Getting this wrong is the most common cause of microservices failures in production.

Synchronous communication via REST or gRPC is appropriate when you need a response before proceeding. A payment authorization must succeed before you confirm an order. A user lookup must return before you render a page. REST is universal and easy to debug. gRPC is faster (binary Protocol Buffers over HTTP/2) and enforces schema via .proto files. Use gRPC for internal service-to-service calls where you control both ends. Use REST for external-facing APIs where clients are diverse.

The fundamental problem with synchronous communication in a distributed system is temporal coupling. If service A calls service B synchronously, and service B is slow or unavailable, service A is slow or unavailable. Synchronous call chains compound: 100ms at each of five service hops means 500ms minimum latency for the calling service, plus the probability of failure at each hop multiplied together. If each service has 99.9% availability, five synchronous dependencies gives you 99.5% availability for the composite operation — before accounting for network failures.

Asynchronous communication via message queues (Kafka, RabbitMQ, Redis Streams) decouples services temporally. When an order is placed, the order service publishes an OrderPlaced event and returns immediately. The inventory service, notification service, and analytics service each consume that event in their own time. The order service does not know or care whether any of them are available when it publishes. This eliminates temporal coupling at the cost of eventual consistency — the inventory service will subtract stock, but not necessarily before the next request arrives.

# saga_choreography.py
# Implements the Saga pattern via event choreography for distributed transactions.
# Each service listens for events, performs its local transaction, and emits
# the next event in the saga chain. On failure, each service emits a compensating event.

import json
import asyncio
from enum import Enum
from dataclasses import dataclass, asdict
from typing import Optional

class SagaEventType(str, Enum):
    # Forward events — happy path
    ORDER_PLACED = "order.placed"
    PAYMENT_RESERVED = "payment.reserved"
    INVENTORY_RESERVED = "inventory.reserved"
    ORDER_CONFIRMED = "order.confirmed"

    # Compensating events — rollback path
    PAYMENT_FAILED = "payment.failed"
    INVENTORY_FAILED = "inventory.failed"
    PAYMENT_RELEASED = "payment.released"  # compensate payment.reserved
    ORDER_CANCELLED = "order.cancelled"

@dataclass
class SagaEvent:
    event_type: SagaEventType
    order_id: str
    correlation_id: str         # tracks the full saga across services
    payload: dict
    failure_reason: Optional[str] = None

class PaymentService:
    """
    Handles payment.reserved and payment.failed events.
    On order.placed: attempt to reserve funds. Emit payment.reserved or payment.failed.
    On inventory.failed: emit payment.released to compensate the reservation.
    """

    async def handle_event(self, event: SagaEvent, emit):
        if event.event_type == SagaEventType.ORDER_PLACED:
            await self._reserve_payment(event, emit)
        elif event.event_type == SagaEventType.INVENTORY_FAILED:
            await self._release_payment(event, emit)

    async def _reserve_payment(self, event: SagaEvent, emit):
        order = event.payload
        try:
            # Idempotency key: use correlation_id so retries are safe
            transaction_id = await self._charge_card(
                customer_id=order["customer_id"],
                amount_cents=order["amount_cents"],
                idempotency_key=event.correlation_id,
            )
            await emit(SagaEvent(
                event_type=SagaEventType.PAYMENT_RESERVED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload={"transaction_id": transaction_id, **order},
            ))
        except PaymentDeclinedError as e:
            await emit(SagaEvent(
                event_type=SagaEventType.PAYMENT_FAILED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
                failure_reason=str(e),
            ))

    async def _release_payment(self, event: SagaEvent, emit):
        # Compensating transaction: reverse the reservation
        await self._refund_charge(
            transaction_id=event.payload["transaction_id"],
            idempotency_key=f"refund-{event.correlation_id}",
        )
        await emit(SagaEvent(
            event_type=SagaEventType.PAYMENT_RELEASED,
            order_id=event.order_id,
            correlation_id=event.correlation_id,
            payload=event.payload,
        ))

    async def _charge_card(self, customer_id, amount_cents, idempotency_key):
        # Stubbed: actual Stripe/Adyen call here
        return f"txn_{idempotency_key[:8]}"

    async def _refund_charge(self, transaction_id, idempotency_key):
        # Stubbed: actual refund call here
        pass

class InventoryService:
    """
    Listens for payment.reserved. Attempts to reserve stock.
    Emits inventory.reserved or inventory.failed.
    On failure, the payment service will see inventory.failed and release the charge.
    """

    async def handle_event(self, event: SagaEvent, emit):
        if event.event_type == SagaEventType.PAYMENT_RESERVED:
            await self._reserve_stock(event, emit)

    async def _reserve_stock(self, event: SagaEvent, emit):
        order = event.payload
        try:
            await self._decrement_inventory(
                sku=order["sku"],
                quantity=order["quantity"],
                idempotency_key=event.correlation_id,
            )
            await emit(SagaEvent(
                event_type=SagaEventType.INVENTORY_RESERVED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
            ))
        except InsufficientStockError as e:
            await emit(SagaEvent(
                event_type=SagaEventType.INVENTORY_FAILED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
                failure_reason=str(e),
            ))

    async def _decrement_inventory(self, sku, quantity, idempotency_key):
        pass  # Actual inventory update here

class PaymentDeclinedError(Exception): pass
class InsufficientStockError(Exception): pass

The Circuit Breaker prevents cascading failures. When service B is failing, service A should stop trying to call it immediately rather than queuing up requests that will timeout after five seconds each, exhausting connection pools, and propagating the failure upstream. A circuit breaker wraps a remote call and tracks failure rate. When failures exceed a threshold, the circuit "opens" and requests fail fast (immediately, without attempting the call). After a cooldown window, the circuit moves to "half-open" and tries a test request. If it succeeds, the circuit closes and normal traffic resumes.

# circuit_breaker.py
# A minimal circuit breaker with exponential backoff for remote service calls.
# States: CLOSED (normal), OPEN (failing fast), HALF_OPEN (testing recovery).

import time
import asyncio
from enum import Enum
from typing import Callable, TypeVar, Awaitable

T = TypeVar("T")

class CircuitState(Enum):
    CLOSED = "closed"       # Normal operation
    OPEN = "open"           # Failing fast — not attempting calls
    HALF_OPEN = "half_open" # Testing if service has recovered

class CircuitBreakerOpen(Exception):
    """Raised when a call is blocked because the circuit is open."""
    pass

class CircuitBreaker:
    """
    Circuit breaker with exponential backoff on retry windows.
    failure_threshold: number of failures before circuit opens
    recovery_timeout: seconds to wait before attempting recovery (HALF_OPEN)
    success_threshold: consecutive successes in HALF_OPEN before closing
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
        success_threshold: int = 2,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.success_threshold = success_threshold

        self._state = CircuitState.CLOSED
        self._failure_count = 0
        self._success_count = 0
        self._last_failure_time: float = 0.0
        self._backoff_multiplier = 1.0   # Increases with each OPEN cycle

    @property
    def state(self) -> CircuitState:
        if self._state == CircuitState.OPEN:
            # Check if recovery window has elapsed
            elapsed = time.monotonic() - self._last_failure_time
            recovery_window = self.recovery_timeout * self._backoff_multiplier
            if elapsed >= recovery_window:
                self._state = CircuitState.HALF_OPEN
                self._success_count = 0
        return self._state

    async def call(self, func: Callable[..., Awaitable[T]], *args, **kwargs) -> T:
        """Execute func through the circuit breaker."""
        current_state = self.state

        if current_state == CircuitState.OPEN:
            raise CircuitBreakerOpen(
                f"Circuit is OPEN. Next retry in "
                f"{self.recovery_timeout * self._backoff_multiplier:.0f}s"
            )

        try:
            result = await func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _on_success(self):
        if self._state == CircuitState.HALF_OPEN:
            self._success_count += 1
            if self._success_count >= self.success_threshold:
                # Service recovered — close the circuit and reset backoff
                self._state = CircuitState.CLOSED
                self._failure_count = 0
                self._backoff_multiplier = 1.0
        elif self._state == CircuitState.CLOSED:
            # Reset failure count on any success (sliding window behavior)
            self._failure_count = max(0, self._failure_count - 1)

    def _on_failure(self):
        self._failure_count += 1
        self._last_failure_time = time.monotonic()

        if self._failure_count >= self.failure_threshold:
            if self._state != CircuitState.OPEN:
                # First time opening: start backoff at 1x
                self._state = CircuitState.OPEN
            else:
                # Already open — exponential backoff up to 8x the base timeout
                self._backoff_multiplier = min(self._backoff_multiplier * 2, 8.0)

# Usage example
payment_circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)

async def get_payment_status(order_id: str) -> dict:
    try:
        return await payment_circuit.call(
            payment_service_client.get_status,
            order_id=order_id,
        )
    except CircuitBreakerOpen:
        # Return cached status or degraded response rather than failing hard
        return {"status": "unknown", "degraded": True}

A service mesh (Istio, Linkerd) handles cross-cutting concerns — mutual TLS between services, circuit breaking, retry logic, distributed tracing, traffic splitting — at the infrastructure layer without code changes. For teams with 10+ services, the investment in a service mesh pays off by removing dozens of per-service implementations of the same retry/timeout/mTLS logic. For teams with 3-5 services, the operational overhead of the mesh itself (Istio in particular is operationally demanding) likely exceeds the benefit.


5. The Distributed Systems Tax

Every microservices adoption prospectus focuses on the benefits. The tax is real and should be stated plainly before any decomposition decision is made.

Network failures are now a first-class concern. In a monolith, a function call either returns or throws an exception from the called code. In a distributed system, the call can fail because the network dropped the packet, because the remote service is restarting, because DNS resolution failed, because the TLS handshake timed out, because a load balancer returned a 502, or because the remote service returned a 200 but the response was truncated. Every cross-service call requires timeout handling, retry logic with exponential backoff and jitter, and circuit breakers. This is not optional. A service that does not handle these failure modes will eventually fail in production in a way that cascades across your entire system.

Distributed tracing is a prerequisite, not an afterthought. When a user request in a monolith fails, you have one stack trace in one log. When a request traverses five services and fails, you have five partial logs in five log streams with no correlation between them — unless you have implemented distributed tracing. OpenTelemetry with Jaeger or Honeycomb, propagating trace IDs through every service call, is the minimum viable observability for a microservices architecture. Without it, debugging a production incident requires correlating timestamps across five dashboards and reconstructing the call graph manually. This is what "flying blind" looks like in practice, and it happens at 2am.

Data consistency requires explicit engineering. The ACID transaction guarantee that a relational database gives you inside a monolith does not extend across service boundaries. When an order is placed and requires a payment reservation and an inventory reservation, you cannot wrap those three operations in a single database transaction. You must implement the Saga pattern, with compensating transactions for each step that can fail. You must design every operation to be idempotent so that retries do not double-charge customers. You must accept that the system will be in inconsistent intermediate states during normal operation and design the user experience around eventual consistency.

Operational complexity scales linearly with service count. Each new service requires: its own deployment pipeline, its own container registry entry, its own Kubernetes namespace and resource limits, its own alert policies, its own runbook, its own on-call escalation path, its own log aggregation configuration, and its own metrics dashboard. A team that manages ten services needs ten times the operational infrastructure of a team with one monolith. This overhead does not scale down when services are small — a two-function service costs nearly as much to operate as a large one.

The latency math is unforgiving. A synchronous call chain of five services, each adding 20ms of internal processing time and 10ms of network latency on a low-latency internal network, contributes 150ms of minimum latency to the terminal response. The same logic executed as five function calls inside a monolith takes microseconds. This only matters when latency is a user-facing concern — interactive UIs, APIs with SLAs, real-time pipelines — but it matters a lot in those contexts. The "just throw Varnish in front of it" solution does not work when the response contains personalized or real-time data.

The Prime Video story is worth the specifics. Their video quality monitoring system was originally built as microservices on AWS Lambda and Step Functions. The system worked, but at scale the inter-service communication costs and Lambda invocation costs grew with data volume. When they collapsed it into a monolith running on a single ECS service, costs dropped by 90% and scalability improved because the bottleneck had been the orchestration layer, not the processing logic. The key insight: their data pipeline had high throughput and low latency requirements between steps — exactly the workload profile where in-process function calls vastly outperform inter-service network calls. The microservices architecture had been chosen by default, not by analysis.


6. The Majestic Monolith and Modular Approaches

The 2026 landscape has produced a clearer vocabulary for the middle ground. The "Majestic Monolith" — a term popularized by DHH and the Rails community — describes a well-structured single-deployment application that deliberately eschews distribution until the evidence demands it. The "Modular Monolith" is its more formal cousin: a monolith organized around hard domain module boundaries enforced by tooling, not just convention.

For most teams at most stages, the modular monolith is the right default. This is not a consolation prize. It is the correct engineering decision given the available evidence. A modular monolith built with clean domain boundaries, interface-based module communication, and vertical slicing by feature is a genuinely production-grade architecture. It deploys as a single unit, which means one pipeline, one deployment, one set of dashboards. It fails as a single unit, which means one stack trace, one log stream, one place to look. And it can be decomposed incrementally when and if the evidence of scaling or team pressure appears.

The escalation path for a modular monolith is well-defined. You start with vertical feature slices: the orders module owns everything from the API endpoint to the database table, with no cross-module data access. Interfaces define the contract between modules. An internal event bus handles cross-cutting concerns like notifications and analytics without creating import cycles. When a specific module shows the characteristics that justify extraction — independent scaling needs, different deployment cadence, compliance isolation, team ownership friction — you apply the Strangler Fig and extract exactly that module. The rest of the system continues to run as before. The modular structure you built from day one means the extracted module already has a clean interface — you are adding a network boundary, not redesigning the module.

Mini-services occupy a useful middle ground that does not appear in most architectural discussions: one-concern-per-process without full microservices overhead. A worker process that handles asynchronous email sending is a mini-service. A cron process that runs nightly batch reconciliation is a mini-service. They deploy separately, can be scaled independently, and have narrow enough scope that they do not require a full distributed systems framework. They share the main application database under a single schema owner. This pattern gives you the deployment independence that matters (email sending can go down without affecting the main API) without the data consistency complexity of full service decomposition.

Internal service boundaries enforced by linting tools — the dependency-cruiser for JavaScript, import-linter for Python, custom Go module constraints — are the unglamorous work that makes the modular monolith actually work. Without enforcement, module boundaries drift. Engineers add a convenience import across a boundary. Then another. Within six months the modular structure exists in the documentation but not in the codebase. The architectural tests that enforce "orders module must not import from payments module" are the difference between a real modular monolith and a tangled one with aspirational documentation.


Conclusion

The honest decision framework is this: start with a modular monolith, organized around domain boundaries, with interfaces between modules and vertical feature slices. This is the correct default for new projects and for teams under 15-20 engineers working on a single product. Build it well — enforce the module boundaries with tooling, maintain clear interfaces, resist the urge to share database tables across module lines. You will have an architecture that is easy to understand, easy to debug, cheap to operate, and ready to decompose when the time comes.

Extract a service when you have a specific, demonstrable, quantified reason: a compliance boundary that scopes the audit surface, a scaling requirement that is costing real money, a deployment cadence mismatch that is causing real risk, or a team ownership conflict that is causing real friction. Not because your architecture looks like what Netflix presented at QCon. Not because your team has hit 30 engineers. Not because the new engineer from Google says that's how they do it there.

The companies that have gotten this right — Shopify, Stack Overflow, Basecamp, and even the Prime Video team when they made their reversal — have one thing in common: they made architectural decisions based on the specific problems in front of them, not on the architectural fashion of the moment. Microservices are not a destination. They are a tool. The tool has a real cost. Use it when the problem justifies the cost, and not before.

The worst outcome is a distributed monolith: all the operational complexity of microservices with all the tight coupling of a tangled monolith. It is achievable by extracting services without establishing clean domain boundaries, by sharing a database across services, or by building synchronous call chains without fault tolerance. Avoid it by doing the hard work of module design first, inside the monolith, before any extraction happens. Get the boundaries right in code before you promote them to network boundaries.

Start structured. Extract deliberately. Measure first.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-11 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, April 15, 2026

gRPC in Production: Protocol Buffers, Streaming, and Why REST Isn't Always the Answer

Hero: gRPC vs REST performance comparison with latency and throughput charts

REST with JSON is the default for web APIs. It's readable, flexible, and works everywhere. It's also 3-10× slower than gRPC for service-to-service communication, requires manual schema documentation, and has no built-in streaming semantics.

gRPC is the alternative for internal microservices and high-throughput APIs: binary serialization with Protocol Buffers, HTTP/2 multiplexing, bi-directional streaming, and code generation in 12 languages from a single .proto schema. In 2026, gRPC is standard for service meshes, ML inference pipelines, and any internal API where latency and throughput matter.

The Problem: REST at the Wrong Layer

REST over JSON was designed for client-server communication across the public internet — where human readability matters and client diversity is unpredictable. Applied to internal microservice communication, its characteristics become costs:

JSON parsing overhead: Serializing a complex object to JSON and back is 5-10× slower than Protocol Buffer serialization. At 10,000 RPC calls/second, this overhead compounds.

No schema enforcement: REST with JSON has no built-in contract. A service changes a field name; clients break silently. API versioning is manual and inconsistent.

HTTP/1.1 head-of-line blocking: A slow request blocks subsequent requests on the same connection. HTTP/2 multiplexes multiple requests over a single connection — a slow stream doesn't block others.

No streaming: REST request-response is fundamentally single-shot. Real-time streaming (model inference tokens, log tailing, live data feeds) requires workarounds: SSE, WebSockets, or polling.

gRPC solves all four using HTTP/2 as transport, Protocol Buffers as serialization, and code generation to enforce the contract at compile time.

graph LR subgraph "REST / JSON" A[Client] -->|HTTP/1.1 + JSON text| B[Server] B -->|JSON response| A A -.->|"Each request: parse JSON\nNo streaming\nNo schema"| A end subgraph "gRPC" C[Client] -->|HTTP/2 + Protobuf binary| D[Server] D -->|Binary response| C C -.->|"Binary: 5-10× faster\nStreaming built-in\nSchema enforced"| C end style A fill:#f59e0b style C fill:#22c55e,color:#fff

How It Works: Protocol Buffers and Code Generation

The center of gRPC is the .proto file — a language-agnostic schema that defines your service and message types. This single file generates client and server code in Python, Go, Java, TypeScript, Rust, and more.

// payments.proto
syntax = "proto3";

package payments.v1;

option go_package = "github.com/myorg/payments/gen/go/payments/v1;paymentsv1";

// Service definition — the RPC contract
service PaymentService {
  // Unary RPC: single request, single response
  rpc ChargeCard(ChargeRequest) returns (ChargeResponse);

  // Server streaming: single request, stream of responses
  rpc StreamTransactions(TransactionStreamRequest) returns (stream Transaction);

  // Client streaming: stream of requests, single response
  rpc BatchCharge(stream ChargeRequest) returns (BatchChargeResponse);

  // Bidirectional streaming: stream in both directions
  rpc PaymentChat(stream PaymentMessage) returns (stream PaymentMessage);
}

message ChargeRequest {
  string customer_id = 1;
  int64 amount_cents = 2;
  string currency = 3;         // "USD", "EUR", etc.
  string idempotency_key = 4;  // Prevents double-charges
  optional string description = 5;
}

message ChargeResponse {
  string transaction_id = 1;
  ChargeStatus status = 2;
  string processor_reference = 3;
  int64 processed_at_unix = 4;
}

enum ChargeStatus {
  CHARGE_STATUS_UNSPECIFIED = 0;  // proto3: always have a zero value
  CHARGE_STATUS_SUCCESS = 1;
  CHARGE_STATUS_DECLINED = 2;
  CHARGE_STATUS_ERROR = 3;
}

message Transaction {
  string id = 1;
  string customer_id = 2;
  int64 amount_cents = 3;
  string currency = 4;
  int64 created_at_unix = 5;
}

message TransactionStreamRequest {
  string customer_id = 1;
  int64 since_unix = 2;  // Stream transactions after this timestamp
}

message BatchChargeResponse {
  int32 total = 1;
  int32 succeeded = 2;
  int32 failed = 3;
  repeated string failed_idempotency_keys = 4;
}

Generate code:

# Install protoc + gRPC plugins
pip install grpcio grpcio-tools

# Generate Python client and server code from .proto
python -m grpc_tools.protoc \
  -I. \
  --python_out=./gen/python \
  --grpc_python_out=./gen/python \
  payments.proto

This generates payments_pb2.py (message types) and payments_pb2_grpc.py (service stubs). When the .proto changes, regenerate — mismatches are caught at import time, not at runtime.

Implementation: Server and Client

Python gRPC Server

import grpc
from concurrent import futures
import payments_pb2
import payments_pb2_grpc
import logging
import time

class PaymentServicer(payments_pb2_grpc.PaymentServiceServicer):
    """Implements the PaymentService defined in payments.proto"""

    def ChargeCard(self, request, context):
        """Unary RPC: charge a card and return the result."""
        # Validate request
        if request.amount_cents <= 0:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details("amount_cents must be positive")
            return payments_pb2.ChargeResponse()

        if not request.idempotency_key:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details("idempotency_key is required")
            return payments_pb2.ChargeResponse()

        # Check idempotency (deduplication)
        existing = idempotency_store.get(request.idempotency_key)
        if existing:
            return existing  # Return cached result — safe to retry

        # Process charge
        try:
            result = stripe_client.charge(
                customer=request.customer_id,
                amount=request.amount_cents,
                currency=request.currency,
            )

            response = payments_pb2.ChargeResponse(
                transaction_id=result.id,
                status=payments_pb2.CHARGE_STATUS_SUCCESS,
                processor_reference=result.balance_transaction,
                processed_at_unix=int(time.time()),
            )
            idempotency_store.set(request.idempotency_key, response, ttl=86400)
            return response

        except stripe.CardError as e:
            return payments_pb2.ChargeResponse(
                status=payments_pb2.CHARGE_STATUS_DECLINED,
            )

    def StreamTransactions(self, request, context):
        """Server streaming: yield transactions as they occur."""
        # Initial backfill of historical transactions
        for tx in db.get_transactions(
            customer_id=request.customer_id,
            since=request.since_unix,
        ):
            if context.is_active():  # Check if client is still connected
                yield payments_pb2.Transaction(
                    id=tx.id,
                    customer_id=tx.customer_id,
                    amount_cents=tx.amount_cents,
                    currency=tx.currency,
                    created_at_unix=int(tx.created_at.timestamp()),
                )

        # Subscribe to real-time events
        with event_bus.subscribe(f"transactions:{request.customer_id}") as sub:
            for event in sub:
                if not context.is_active():
                    return  # Client disconnected — stop streaming
                yield payments_pb2.Transaction(**event)


def serve():
    server = grpc.server(
        futures.ThreadPoolExecutor(max_workers=10),
        options=[
            ('grpc.max_receive_message_length', 4 * 1024 * 1024),  # 4MB
            ('grpc.max_send_message_length', 4 * 1024 * 1024),
            ('grpc.keepalive_time_ms', 30000),      # Send keepalive every 30s
            ('grpc.keepalive_timeout_ms', 5000),    # Wait 5s for keepalive ack
        ]
    )
    payments_pb2_grpc.add_PaymentServiceServicer_to_server(PaymentServicer(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    logging.info("gRPC server started on port 50051")
    server.wait_for_termination()

Python gRPC Client with Interceptors

import grpc
from grpc import UnaryUnaryClientInterceptor

class AuthInterceptor(UnaryUnaryClientInterceptor):
    """Adds authorization header to every outbound RPC."""

    def __init__(self, token_provider):
        self.token_provider = token_provider

    def intercept_unary_unary(self, continuation, client_call_details, request):
        metadata = list(client_call_details.metadata or [])
        metadata.append(('authorization', f'Bearer {self.token_provider()}'))
        metadata.append(('x-request-id', generate_request_id()))

        new_details = client_call_details._replace(metadata=metadata)
        return continuation(new_details, request)


class RetryInterceptor(UnaryUnaryClientInterceptor):
    """Retries failed RPCs with exponential backoff for retriable status codes."""

    RETRIABLE_CODES = {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED}

    def intercept_unary_unary(self, continuation, client_call_details, request):
        for attempt in range(3):
            response = continuation(client_call_details, request)
            try:
                return response.result()
            except grpc.RpcError as e:
                if e.code() in self.RETRIABLE_CODES and attempt < 2:
                    time.sleep(0.1 * (2 ** attempt))  # 100ms, 200ms backoff
                    continue
                raise


# Build client with interceptors
channel = grpc.intercept_channel(
    grpc.secure_channel('payments.internal:50051', grpc.ssl_channel_credentials()),
    AuthInterceptor(token_provider=get_service_token),
    RetryInterceptor(),
)

stub = payments_pb2_grpc.PaymentServiceStub(channel)

# Unary call with deadline
try:
    response = stub.ChargeCard(
        payments_pb2.ChargeRequest(
            customer_id="cust_123",
            amount_cents=4999,
            currency="USD",
            idempotency_key="order_789_charge_1",
        ),
        timeout=5.0,  # 5-second deadline
    )
    print(f"Charged: {response.transaction_id}")
except grpc.RpcError as e:
    print(f"RPC failed: {e.code()}: {e.details()}")

The Four Streaming Modes

gRPC's most distinctive feature over REST is native streaming. The four modes cover all communication patterns:

# Mode 1: Unary — request/response (same as REST)
response = stub.ChargeCard(request, timeout=5.0)

# Mode 2: Server streaming — one request, many responses
# Use case: tail a log, stream ML inference tokens, real-time feeds
def stream_inference_tokens(prompt: str):
    request = InferenceRequest(prompt=prompt, max_tokens=512)
    for chunk in stub.StreamInference(request):
        yield chunk.token  # Streams as LLM generates

# Mode 3: Client streaming — many requests, one response
# Use case: batch operations, file upload in chunks
def batch_charge(charges: list[ChargeRequest]) -> BatchChargeResponse:
    def generate_charges():
        for charge in charges:
            yield charge
    return stub.BatchCharge(generate_charges())

# Mode 4: Bidirectional streaming — both sides stream simultaneously
# Use case: real-time bidirectional chat, agent/tool call loops
async def payment_chat(messages):
    async def request_iterator():
        for msg in messages:
            yield PaymentMessage(text=msg)

    async for response in stub.PaymentChat(request_iterator()):
        print(f"Server: {response.text}")

For LLM inference APIs, server streaming is the critical mode: instead of waiting for the entire response before returning (4-30 seconds for long responses), the client receives tokens as they're generated. This is how ChatGPT, Claude, and every production LLM API works at the protocol level.

gRPC in Service Meshes: Istio and Envoy

Service meshes like Istio and Linkerd use Envoy as a sidecar proxy. Envoy has first-class gRPC support: health checking, load balancing, observability, and circuit breaking all work at the gRPC protocol level.

# Istio VirtualService: route 10% of gRPC traffic to new version
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: payments-service
spec:
  hosts:
    - payments.internal
  http:
    - match:
        - headers:
            grpc-method:   # Route specific gRPC methods differently
              exact: "/payments.v1.PaymentService/ChargeCard"
      route:
        - destination:
            host: payments-service
            subset: v2
          weight: 10     # 10% to new version
        - destination:
            host: payments-service
            subset: v1
          weight: 90

Envoy also handles retries for gRPC. The key difference from HTTP retries: gRPC has built-in status codes that indicate whether a request is safe to retry. UNAVAILABLE and DEADLINE_EXCEEDED are typically safe; ALREADY_EXISTS and FAILED_PRECONDITION are not.

# Istio DestinationRule: retry policy for gRPC services
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: payments-retry
spec:
  host: payments.internal
  trafficPolicy:
    connectionPool:
      http:
        h2UpgradePolicy: UPGRADE  # Force HTTP/2 for gRPC
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 10s
      baseEjectionTime: 30s
    retryPolicy:
      attempts: 3
      perTryTimeout: 2s
      retryOn: "5xx,gateway-error,reset,connect-failure,retriable-4xx"

gRPC vs REST: When to Use Which

flowchart TD Q1{Public API or\ninternal service?} Q1 -- Public, browser clients --> R[REST + JSON\nOpenAPI spec] Q1 -- Internal microservices --> Q2{Streaming needed?} Q2 -- Yes --> G[gRPC with streaming] Q2 -- No --> Q3{High throughput\n> 1k req/s?} Q3 -- Yes --> G Q3 -- No --> Q4{Multiple language\nclients?} Q4 -- Yes, need type safety --> G Q4 -- No or simple --> R2[REST + JSON\nsimpler tooling] style G fill:#22c55e,color:#fff style R fill:#3b82f6,color:#fff style R2 fill:#3b82f6,color:#fff
Dimension gRPC REST/JSON
Serialization speed Binary (~10× faster) Text (flexible)
Schema enforcement Compile-time (Protobuf) Optional (OpenAPI)
Streaming Native (4 modes) Workaround (SSE/WS)
Browser support Limited (grpc-web proxy) Native
Human readability Low (binary) High
Tooling maturity Good, growing Excellent
Use case fit Internal services, ML inference Public APIs, browser clients

gRPC is the right choice for:
- Internal microservice communication (service mesh)
- ML model inference (streaming token output, batch inference)
- High-throughput data pipelines
- Polyglot teams needing type-safe cross-language contracts

REST is the right choice for:
- Public APIs consumed by browsers and third parties
- APIs where human readability and curl-debuggability matter
- Simple CRUD services with low traffic

Reflection and Debugging

REST APIs are debuggable with curl. Binary gRPC is not. Two tools bridge this gap:

grpcurl — curl for gRPC:

# List available services (requires reflection enabled on server)
grpcurl -plaintext localhost:50051 list

# Describe a service
grpcurl -plaintext localhost:50051 describe payments.v1.PaymentService

# Call an RPC
grpcurl -plaintext -d '{
  "customer_id": "cust_123",
  "amount_cents": 4999,
  "currency": "USD",
  "idempotency_key": "test-001"
}' localhost:50051 payments.v1.PaymentService/ChargeCard

Evans — interactive gRPC REPL:

evans --host localhost --port 50051 --reflection repl
# > call ChargeCard
# customer_id (TYPE_STRING) => cust_123
# amount_cents (TYPE_INT64) => 4999
# ...

To enable server reflection (needed by grpcurl/Evans):

from grpc_reflection.v1alpha import reflection

# Add to your server setup
SERVICE_NAMES = (
    payments_pb2.DESCRIPTOR.services_by_name['PaymentService'].full_name,
    reflection.SERVICE_NAME,
)
reflection.enable_server_reflection(SERVICE_NAMES, server)

Enable reflection only in non-production environments. Reflection exposes your entire API schema — useful for dev/staging, a security concern in production.

Production Considerations

Health Checking and Load Balancing

gRPC has a standard health checking protocol. All production gRPC servers should implement it — load balancers and service meshes (Istio, Linkerd) rely on it:

from grpc_health.v1 import health_pb2_grpc, health_pb2
from grpc_health.v1.health import HealthServicer

# Add health service to your server
health_servicer = HealthServicer()
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)

# Mark service as serving (or NOT_SERVING during graceful shutdown)
health_servicer.set(
    "payments.v1.PaymentService",
    health_pb2.HealthCheckResponse.SERVING
)

gRPC-Gateway for REST Compatibility

Sometimes you need both: gRPC for internal services and REST for external clients. grpc-gateway generates a REST proxy from your proto annotations:

import "google/api/annotations.proto";

service PaymentService {
  rpc ChargeCard(ChargeRequest) returns (ChargeResponse) {
    option (google.api.http) = {
      post: "/v1/charges"
      body: "*"
    };
  }
}

The gateway translates JSON REST requests into gRPC calls transparently — one server implementation, two transports.

Metadata and Custom Headers

gRPC metadata is the equivalent of HTTP headers — key-value pairs sent with each RPC call. Use metadata for authentication, request tracing, and custom context:

# Server: extract metadata from incoming context
class PaymentServicer(payments_pb2_grpc.PaymentServiceServicer):
    def ChargeCard(self, request, context):
        # Extract metadata (like HTTP headers)
        metadata = dict(context.invocation_metadata())

        request_id = metadata.get('x-request-id', 'unknown')
        auth_token = metadata.get('authorization', '')

        # Verify token
        if not verify_token(auth_token):
            context.set_code(grpc.StatusCode.UNAUTHENTICATED)
            context.set_details("Invalid or missing authorization token")
            return payments_pb2.ChargeResponse()

        # Add response metadata (like response headers)
        context.send_initial_metadata([
            ('x-request-id', request_id),       # Echo back for correlation
            ('x-processing-region', 'us-east-1'),
        ])

        return process_charge(request)

Interceptors (shown earlier) are the idiomatic way to add metadata globally, rather than in every service method.

Deadlines Are Mandatory

Every gRPC call should have a deadline. Without one, a slow upstream can hold connections indefinitely:

# Always set a timeout — never make an unbounded RPC call
try:
    response = stub.ChargeCard(request, timeout=3.0)  # 3 seconds max
except grpc.RpcError as e:
    if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
        # Timeout — circuit break or return cached result
        ...

Set deadlines based on your SLO, not generously. A 30-second deadline on a 200ms call means slow cascading failures propagate for 30 seconds instead of failing fast.

Performance: Why gRPC Is Faster Than REST

The performance advantage comes from three compounding factors:

Binary serialization vs JSON: JSON is human-readable text. "amount_cents": 4999 encodes as 21 bytes. The same int64 in protobuf encodes as 3 bytes (field tag + varint). For complex nested messages with repeated fields, protobuf is typically 5-10× smaller than JSON.

import json
import time
from google.protobuf import json_format

# Benchmark: serialize 1000 ChargeRequest messages
charge = {"customer_id": "cust_abc123", "amount_cents": 4999, "currency": "USD", "idempotency_key": "idem_xyz789"}

# JSON serialization: ~1,200 nanoseconds per message
json_bytes = json.dumps(charge).encode()  # 83 bytes

# Protobuf serialization: ~120 nanoseconds per message
proto_msg = ChargeRequest(**charge)
proto_bytes = proto_msg.SerializeToString()  # 34 bytes

# 2.4× smaller, 10× faster serialization

HTTP/2 multiplexing: HTTP/1.1 connections handle one request at a time. Multiple requests require multiple connections (or pipelining with head-of-line blocking). HTTP/2 multiplexes many streams over one TCP connection. At 10,000 RPC/s, the connection overhead difference is significant.

Connection reuse: gRPC clients maintain a pool of long-lived HTTP/2 connections. REST clients often open a new connection per request (or maintain a pool with HTTP keep-alive). Long-lived HTTP/2 connections eliminate TCP and TLS handshake overhead per request.

Combined: in benchmarks of internal service-to-service communication, gRPC typically shows 2-7× lower latency and 2-5× higher throughput than REST/JSON for equivalent payloads. The gap widens with larger payloads and higher concurrency.

Protocol Buffers: Field Numbers and Backward Compatibility

One of protobuf's most important properties: backward-compatible schema evolution. Field numbers — not names — identify fields in the serialized binary. This means you can rename fields without breaking existing clients, and you can add new fields without breaking old clients.

// Version 1 of ChargeRequest
message ChargeRequest {
  string customer_id = 1;
  int64 amount_cents = 2;
  string currency = 3;
  string idempotency_key = 4;
}

// Version 2: BACKWARD COMPATIBLE additions
message ChargeRequest {
  string customer_id = 1;
  int64 amount_cents = 2;
  string currency = 3;
  string idempotency_key = 4;
  optional string description = 5;   // New field — old clients ignore it
  optional string merchant_id = 6;   // Another new field
  // NEVER reuse field numbers 1-4 — would break existing serialized data
}

Rules for safe proto evolution:
1. Never delete a field — mark it reserved and add to the reserved list instead
2. Never reuse a field number — the binary format uses numbers, not names
3. New fields should be optional — required fields in proto3 don't exist; in proto2, adding a required field is a breaking change
4. Never change a field type — int32 to int64 might work, but int64 to string will break
5. Never rename an enum value — enum values have both a number and a name; changing the name changes the default serialized value

// SAFE: reserve removed fields to prevent accidental reuse
message OldChargeRequest {
  reserved 5, 6;  // These field numbers can never be reused
  reserved "coupon_code", "promo_id";  // These names can never be reused

  string customer_id = 1;
  int64 amount_cents = 2;
}

This makes gRPC schema evolution safer than REST JSON APIs. A JSON API change that renames a field silently breaks all clients. A protobuf rename is invisible to the wire format — old and new clients interoperate without modification.

Conclusion

gRPC's advantages are clearest in internal service-to-service communication: binary serialization that's 5-10× faster than JSON, schema enforcement that catches breaking changes at compile time, native streaming for ML inference and real-time data, and code generation that eliminates hand-written client boilerplate.

The ecosystem has matured to the point where gRPC is no longer an exotic choice. Kubernetes, Envoy, Istio, and most cloud-native infrastructure speak gRPC natively. ML frameworks (TensorFlow Serving, Triton Inference Server) use gRPC for inference APIs. The service mesh ecosystem depends on gRPC for control plane communication.

For teams building new internal services in 2026, the decision framework is simple: if it's a browser-facing public API, use REST. If it's a service talking to another service, start with gRPC. The tooling (grpcurl, Evans, reflection), the generated clients, and the schema-first development workflow are all production-ready.

The migration path from existing REST services isn't all-or-nothing. grpc-gateway lets you expose both REST and gRPC from the same server implementation — add gRPC for new service-to-service consumers while maintaining the REST API for existing browser clients. Over time, internal consumers migrate to gRPC; the REST endpoint remains for compatibility. This hybrid approach is how most organizations transition their internal API surface to gRPC without a big-bang rewrite.

The learning curve — proto files, code generation, lack of curl debuggability — is real but small. The payoff at scale is significant. Use REST for public-facing APIs where browser clients and human readability matter. Use gRPC everywhere internal, especially in service meshes where the efficiency gains multiply across thousands of calls per second.

The proto-first workflow also improves cross-team collaboration. Service contracts live in a shared proto repository. Teams consume the generated clients without needing to understand server internals. API reviews become proto reviews — structured, diff-able, and enforceable in CI. This is the developer experience improvement that, more than raw performance numbers, drives gRPC adoption in mature engineering organizations.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-05-19 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Monday, April 13, 2026

Event-Driven Architecture in 2026: Kafka, RabbitMQ, and Building Decoupled Systems

Event-Driven Architecture Hero

Introduction

Every sufficiently large distributed system eventually confronts the same fundamental tension: services need to communicate, but tight coupling between services makes the whole system fragile. When Service A directly calls Service B, and Service B calls Service C, you have built a chain. If any link in that chain is slow, unavailable, or returns an unexpected error, the failure propagates upstream. A slow payment processor causes your checkout service to time out. A slow checkout service causes your shopping cart to block. Now your entire application is degraded because of a single slow downstream dependency.

Event-driven architecture is the answer to this tension. Instead of Service A calling Service B directly, Service A emits an event — "OrderPlaced," "UserRegistered," "PaymentReceived" — onto a message bus. Service B, C, and D each consume events they care about and process them independently. Service A does not wait for the others. It does not care how many consumers exist, or whether they are online at the moment of publication. It just emits the event and moves on.

This decoupling changes the failure model of your system fundamentally. A slow consumer does not slow the producer. A temporarily unavailable consumer catches up from the message log when it comes back online. New consumers can be added without any changes to producers. The system becomes resilient, composable, and independently scalable.

In 2026, the two dominant technologies for building event-driven systems are Apache Kafka and RabbitMQ, and they solve fundamentally different versions of the problem. Understanding the distinction — message queuing vs event streaming — is the foundation for choosing correctly. We will also briefly cover Apache Pulsar, which occupies interesting territory between the two.

This post gives you the mental model, the architecture patterns, and the code to build production-ready event-driven systems.


The Problem: Tight Coupling and Synchronous Systems

To understand why event-driven architecture matters, consider a typical synchronous microservices architecture for an e-commerce checkout flow:

sequenceDiagram participant Client participant OrderService participant InventoryService participant PaymentService participant EmailService participant AnalyticsService Client->>OrderService: POST /checkout OrderService->>InventoryService: GET /reserve (HTTP) InventoryService-->>OrderService: 200 OK (150ms) OrderService->>PaymentService: POST /charge (HTTP) PaymentService-->>OrderService: 200 OK (800ms) OrderService->>EmailService: POST /send-confirmation (HTTP) EmailService-->>OrderService: 200 OK (300ms) OrderService->>AnalyticsService: POST /track (HTTP) AnalyticsService-->>OrderService: 503 Slow (2000ms) OrderService-->>Client: Response after 3250ms total note over OrderService,AnalyticsService: Total latency = sum of all downstream calls note over AnalyticsService: Analytics outage = checkout degraded

The total checkout latency is the sum of every downstream call: 150ms + 800ms + 300ms + 2000ms = 3.25 seconds, plus your own processing time. If Analytics is down or slow — something fundamentally non-critical to the checkout — your users experience a degraded checkout. If EmailService is temporarily overloaded, orders stop completing.

This is the synchronous coupling problem. The solutions:

  1. Async calls with circuit breakers — fire-and-forget for non-critical services, but now you have no confirmation. Did the email send? Did Analytics record the event?
  2. Event-driven decoupling — OrderService emits one "OrderCompleted" event and is done. Every other service processes it asynchronously, in their own time, without coupling the checkout latency to their performance.

How It Works: Message Queues vs Event Streaming

The terms "message queue" and "event streaming" are often used interchangeably, but they describe fundamentally different paradigms.

Message Queue Model (RabbitMQ)

In a message queue, a message is a task to be done. When a consumer processes a message, it is acknowledged and deleted from the queue. The queue is a work distribution mechanism: here is a list of jobs, workers pick them up and mark them done.

Key properties:
- Messages are consumed once — once processed and acknowledged, they are gone
- Multiple consumers on the same queue compete for messages (work queue pattern)
- The broker is responsible for routing, filtering, and delivering messages
- Push-based: the broker actively delivers messages to consumers
- Designed for task distribution, RPC, and workflows with complex routing logic

Event Stream Model (Kafka)

In an event stream, an event is a record of something that happened. When a consumer reads an event, it is not deleted — the event remains in the log indefinitely (subject to retention policy). Different consumer groups each get their own independent cursor (offset) into the log, allowing multiple independent consumers to read the same events.

Key properties:
- Events are retained and replayable — consumers can reread history
- Multiple consumer groups read the same events independently — no competition
- The broker is a dumb log — it stores events in order, consumers control their position
- Pull-based: consumers poll the broker for new events
- Designed for high-throughput event streaming, audit logs, and event sourcing

Kafka vs RabbitMQ Architecture

The Decision in One Sentence

Use RabbitMQ when you have tasks to distribute among workers. Use Kafka when you have events that multiple independent systems need to know about.


Kafka Deep Dive: Topics, Partitions, and Consumer Groups

Apache Kafka organizes data into topics — named, append-only logs. Each topic is divided into partitions — shards that enable parallel processing. Events within a partition are ordered and immutable. Events across partitions have no ordering guarantee.

flowchart LR subgraph PROD["Producers"] P1[Order Service] P2[Payment Service] P3[Inventory Service] end subgraph TOPIC["Topic: order-events\n(3 partitions)"] PART0["Partition 0\noffset: 0→N\nkey: user_id % 3 == 0"] PART1["Partition 1\noffset: 0→N\nkey: user_id % 3 == 1"] PART2["Partition 2\noffset: 0→N\nkey: user_id % 3 == 2"] end subgraph CG1["Consumer Group: email-service\n(3 consumers, 1 per partition)"] C1A[Email Consumer 0] C1B[Email Consumer 1] C1C[Email Consumer 2] end subgraph CG2["Consumer Group: analytics-service\n(1 consumer, reads all partitions)"] C2A[Analytics Consumer] end P1 --> PART0 P1 --> PART1 P2 --> PART2 P3 --> PART0 PART0 --> C1A PART1 --> C1B PART2 --> C1C PART0 --> C2A PART1 --> C2A PART2 --> C2A style PART0 fill:#3498DB,color:#fff style PART1 fill:#27AE60,color:#fff style PART2 fill:#E74C3C,color:#fff

Partitioning and Ordering

The partition key determines which partition an event lands in. Kafka guarantees ordering within a partition. If you partition by user_id, all events for a given user arrive in order to the same consumer, enabling correct event processing (e.g., "apply these account transactions in sequence"). If you need global ordering across all events, you need a single partition — which caps your throughput at what one consumer can handle.

Producer Code (Python with confluent-kafka)

from confluent_kafka import Producer
import json
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)


class OrderEventProducer:
    """
    Produces order lifecycle events to the 'order-events' Kafka topic.

    Uses confluent-kafka, which wraps the high-performance librdkafka C library.
    This is the recommended Kafka client for Python in production — it handles
    batching, compression, and retry logic automatically.
    """

    def __init__(self, bootstrap_servers: str):
        self._producer = Producer({
            'bootstrap.servers': bootstrap_servers,

            # Acknowledge after all in-sync replicas have written the message.
            # 'all' is the safest setting — prevents data loss on broker failure.
            # '1' = just leader ack (faster, slightly higher risk of data loss)
            'acks': 'all',

            # Retry transient failures up to 3 times before raising an error
            'retries': 3,
            'retry.backoff.ms': 100,

            # Idempotent producer: prevents duplicate messages on retry
            # Requires acks='all' and retries > 0
            'enable.idempotence': True,

            # Compression reduces network and storage cost significantly
            # lz4 is fast to compress/decompress; snappy is also popular
            'compression.type': 'lz4',

            # Batching: collect up to 16KB before sending, or wait up to 5ms
            # Higher batch.size + linger.ms = better throughput, slightly higher latency
            'batch.size': 16384,
            'linger.ms': 5,
        })

    def publish_order_placed(self, order_id: str, user_id: str, total: float, items: list):
        """Publish an OrderPlaced event."""
        event = {
            'event_type': 'OrderPlaced',
            'order_id': order_id,
            'user_id': user_id,
            'total': total,
            'items': items,
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'version': '1.0',
        }
        self._publish(
            topic='order-events',
            key=user_id,        # Partition by user_id for per-user ordering
            value=event,
        )

    def publish_order_completed(self, order_id: str, user_id: str):
        """Publish an OrderCompleted event."""
        event = {
            'event_type': 'OrderCompleted',
            'order_id': order_id,
            'user_id': user_id,
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'version': '1.0',
        }
        self._publish(topic='order-events', key=user_id, value=event)

    def _publish(self, topic: str, key: str, value: dict):
        """
        Internal publish method with delivery confirmation callback.
        The produce() call is non-blocking — it enqueues the message locally.
        poll() flushes the delivery callback queue.
        """
        def delivery_callback(err, msg):
            if err:
                logger.error(
                    'Message delivery failed: topic=%s key=%s error=%s',
                    topic, key, err
                )
            else:
                logger.debug(
                    'Message delivered: topic=%s partition=%d offset=%d',
                    msg.topic(), msg.partition(), msg.offset()
                )

        self._producer.produce(
            topic=topic,
            key=key.encode('utf-8'),
            value=json.dumps(value).encode('utf-8'),
            callback=delivery_callback,
        )
        # poll() triggers delivery callbacks — call frequently to avoid buffer buildup
        self._producer.poll(0)

    def flush(self, timeout_seconds: float = 10.0):
        """
        Wait for all outstanding messages to be delivered.
        Call before application shutdown or in batch scenarios.
        """
        remaining = self._producer.flush(timeout=timeout_seconds)
        if remaining > 0:
            logger.warning('%d messages were not delivered within timeout', remaining)

    def __del__(self):
        self.flush()

Consumer Code (Python with confluent-kafka)

from confluent_kafka import Consumer, KafkaError, KafkaException
import json
import logging
import signal
import sys

logger = logging.getLogger(__name__)


class OrderEventConsumer:
    """
    Consumes order events and dispatches them to handlers.

    Key design decisions:
    1. Manual offset commit after successful processing (at-least-once delivery)
    2. Graceful shutdown on SIGTERM/SIGINT
    3. Dead letter queue for messages that fail after max retries
    """

    def __init__(
        self,
        bootstrap_servers: str,
        group_id: str,
        topics: list[str],
        max_retries: int = 3,
        dlq_topic: str = 'order-events-dlq',
    ):
        self._consumer = Consumer({
            'bootstrap.servers': bootstrap_servers,
            'group.id': group_id,

            # Start from the beginning of the topic if this group has no committed offset.
            # Use 'latest' for consumers that only care about new messages.
            'auto.offset.reset': 'earliest',

            # Disable auto-commit — we commit manually after successful processing
            # to guarantee at-least-once delivery semantics.
            'enable.auto.commit': False,

            # Session timeout: if the consumer doesn't send a heartbeat within this
            # window, the broker considers it dead and triggers a rebalance.
            'session.timeout.ms': 30000,
            'heartbeat.interval.ms': 10000,

            # Maximum records returned in a single poll()
            'max.poll.records': 100,
        })
        self._consumer.subscribe(topics)
        self._topics = topics
        self._max_retries = max_retries
        self._dlq_producer = Producer({'bootstrap.servers': bootstrap_servers})
        self._dlq_topic = dlq_topic
        self._running = True

        # Handle graceful shutdown on SIGTERM/SIGINT
        signal.signal(signal.SIGTERM, self._shutdown)
        signal.signal(signal.SIGINT, self._shutdown)

    def _shutdown(self, signum, frame):
        logger.info('Shutdown signal received, stopping consumer...')
        self._running = False

    def _send_to_dlq(self, msg, error_reason: str, attempt: int):
        """Send a failed message to the dead letter queue with error metadata."""
        dlq_payload = {
            'original_topic': msg.topic(),
            'original_partition': msg.partition(),
            'original_offset': msg.offset(),
            'original_key': msg.key().decode('utf-8') if msg.key() else None,
            'original_value': msg.value().decode('utf-8') if msg.value() else None,
            'error_reason': error_reason,
            'failed_at': attempt,
        }
        self._dlq_producer.produce(
            topic=self._dlq_topic,
            value=json.dumps(dlq_payload).encode('utf-8'),
        )
        self._dlq_producer.poll(0)
        logger.warning('Message sent to DLQ: %s', error_reason)

    def run(self, handler):
        """
        Main consume loop. Calls handler(event_dict) for each message.
        Commits offset only after successful handling.
        Sends to DLQ after max_retries failures.
        """
        logger.info('Starting consumer for topics: %s, group: %s', self._topics, self._consumer)

        try:
            while self._running:
                # poll() blocks for up to 1 second waiting for messages
                msg = self._consumer.poll(timeout=1.0)

                if msg is None:
                    continue  # No message within timeout — loop again

                if msg.error():
                    if msg.error().code() == KafkaError._PARTITION_EOF:
                        # Reached end of partition — not an error, just informational
                        logger.debug('Reached partition EOF: %s [%d]', msg.topic(), msg.partition())
                    else:
                        raise KafkaException(msg.error())
                    continue

                # Deserialize the event
                try:
                    event = json.loads(msg.value().decode('utf-8'))
                except json.JSONDecodeError as e:
                    logger.error('Failed to decode message: %s', e)
                    self._send_to_dlq(msg, f'JSON decode error: {e}', attempt=0)
                    self._consumer.commit(message=msg)
                    continue

                # Process with retry logic
                last_error = None
                for attempt in range(self._max_retries + 1):
                    try:
                        handler(event)
                        last_error = None
                        break
                    except Exception as e:
                        last_error = e
                        if attempt < self._max_retries:
                            logger.warning(
                                'Handler failed (attempt %d/%d): %s',
                                attempt + 1, self._max_retries, e
                            )
                        # Exponential backoff between retries (basic implementation)
                        import time
                        time.sleep(min(2 ** attempt * 0.1, 5.0))

                if last_error:
                    # All retries exhausted — send to DLQ
                    self._send_to_dlq(msg, str(last_error), attempt=self._max_retries)

                # Commit offset after processing (whether succeeded or sent to DLQ)
                # This ensures we never reprocess unless intentionally replaying from DLQ
                self._consumer.commit(message=msg)

        finally:
            logger.info('Closing consumer...')
            self._consumer.close()


# Example handler function
def handle_order_event(event: dict):
    """Route order events to the appropriate handler."""
    event_type = event.get('event_type')

    if event_type == 'OrderPlaced':
        # Send confirmation email
        logger.info('Sending confirmation email for order %s', event['order_id'])
        # email_service.send_order_confirmation(event['user_id'], event['order_id'])

    elif event_type == 'OrderCompleted':
        # Track in analytics
        logger.info('Recording order completion in analytics: %s', event['order_id'])
        # analytics.track('order_completed', event)

    else:
        logger.warning('Unknown event type: %s', event_type)


# Usage
if __name__ == '__main__':
    consumer = OrderEventConsumer(
        bootstrap_servers='kafka-broker-1:9092,kafka-broker-2:9092',
        group_id='email-service-consumers',
        topics=['order-events'],
        dlq_topic='order-events-dlq',
    )
    consumer.run(handle_order_event)

RabbitMQ Deep Dive: Exchanges, Queues, and Routing

RabbitMQ uses a richer routing model than Kafka. Producers publish messages to exchanges, not directly to queues. Exchanges route messages to queues based on routing keys and bindings. This gives you flexible fan-out, topic-based filtering, and direct routing — all configurable without code changes.

Exchange Types

Exchange Type Routing Behavior Use Case
Direct Route by exact routing key match Task distribution to specific queues
Fanout Broadcast to all bound queues Notifications to multiple services
Topic Route by pattern matching (order.*, *.created) Flexible event routing with wildcards
Headers Route by message header attributes Complex routing without key-based patterns

RabbitMQ Producer and Consumer (Python with pika)

import pika
import json
import logging
from datetime import datetime, timezone
from typing import Callable

logger = logging.getLogger(__name__)


class RabbitMQEventBus:
    """
    Simple event bus built on RabbitMQ using a topic exchange.

    Routing key convention: <domain>.<entity>.<event>
    Examples: orders.order.placed, orders.order.completed, users.user.registered

    Consumers bind queues with wildcards:
    - 'orders.*.*'      = all order events
    - '*.*.placed'      = all 'placed' events across all domains
    - 'orders.order.#'  = all order entity events (# matches zero or more words)
    """

    EXCHANGE_NAME = 'amtocsoft.events'
    EXCHANGE_TYPE = 'topic'

    def __init__(self, amqp_url: str):
        self._url = amqp_url
        self._connection = None
        self._channel = None

    def connect(self):
        """Establish connection and declare the exchange."""
        params = pika.URLParameters(self._url)
        # Enable heartbeats to detect dead connections
        params.heartbeat = 60
        params.blocked_connection_timeout = 300

        self._connection = pika.BlockingConnection(params)
        self._channel = self._connection.channel()

        # Declare the topic exchange — durable means it survives broker restart
        self._channel.exchange_declare(
            exchange=self.EXCHANGE_NAME,
            exchange_type=self.EXCHANGE_TYPE,
            durable=True,
        )
        logger.info('Connected to RabbitMQ and declared exchange: %s', self.EXCHANGE_NAME)

    def publish(self, routing_key: str, event_data: dict):
        """
        Publish an event to the topic exchange.

        routing_key: dot-separated path, e.g. 'orders.order.placed'
        event_data: dict that will be JSON-serialized
        """
        body = json.dumps({
            **event_data,
            'routing_key': routing_key,
            'timestamp': datetime.now(timezone.utc).isoformat(),
        }).encode('utf-8')

        self._channel.basic_publish(
            exchange=self.EXCHANGE_NAME,
            routing_key=routing_key,
            body=body,
            properties=pika.BasicProperties(
                delivery_mode=pika.DeliveryMode.Persistent,  # Survive broker restart
                content_type='application/json',
                content_encoding='utf-8',
            ),
        )
        logger.debug('Published event: routing_key=%s', routing_key)

    def subscribe(
        self,
        queue_name: str,
        binding_pattern: str,
        handler: Callable[[dict], None],
        prefetch_count: int = 10,
    ):
        """
        Subscribe to events matching a routing key pattern.

        queue_name: unique name for this consumer's queue (persistent)
        binding_pattern: topic pattern, e.g. 'orders.*.*' or '*.*.placed'
        handler: function called for each message
        prefetch_count: max unacknowledged messages (backpressure control)
        """
        # Declare a durable queue — survives broker restart
        result = self._channel.queue_declare(
            queue=queue_name,
            durable=True,
            arguments={
                # Dead letter exchange: failed messages go here
                'x-dead-letter-exchange': f'{self.EXCHANGE_NAME}.dlx',
                # After 5 failed deliveries, message goes to DLQ
                'x-dead-letter-routing-key': queue_name,
                # Messages expire after 7 days if not consumed
                'x-message-ttl': 7 * 24 * 60 * 60 * 1000,
            }
        )

        # Bind queue to exchange with the routing key pattern
        self._channel.queue_bind(
            exchange=self.EXCHANGE_NAME,
            queue=queue_name,
            routing_key=binding_pattern,
        )

        # Prefetch: don't deliver more than N messages before receiving acks
        # Prevents one slow consumer from holding all messages
        self._channel.basic_qos(prefetch_count=prefetch_count)

        def _on_message(ch, method, properties, body):
            try:
                event = json.loads(body.decode('utf-8'))
                handler(event)
                # Acknowledge: message is done, remove from queue
                ch.basic_ack(delivery_tag=method.delivery_tag)
            except Exception as e:
                logger.error('Handler failed for routing key %s: %s', method.routing_key, e)
                # Negative acknowledge with requeue=False: send to DLQ after max retries
                # (x-death header tracks retry count — check it in handler for custom logic)
                ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

        self._channel.basic_consume(
            queue=queue_name,
            on_message_callback=_on_message,
        )

        logger.info(
            'Subscribed: queue=%s, pattern=%s',
            queue_name, binding_pattern
        )

    def start_consuming(self):
        """Block and process incoming messages until connection closes."""
        logger.info('Starting to consume messages...')
        try:
            self._channel.start_consuming()
        except KeyboardInterrupt:
            self._channel.stop_consuming()
        finally:
            if self._connection and not self._connection.is_closed:
                self._connection.close()


# Example usage: Email service subscribes to order events
def send_email_for_order_event(event: dict):
    """Handle order events that require email notifications."""
    event_type = event.get('event_type')

    if event_type == 'OrderPlaced':
        logger.info('Sending order confirmation for %s', event.get('order_id'))
    elif event_type == 'OrderShipped':
        logger.info('Sending shipping notification for %s', event.get('order_id'))


if __name__ == '__main__':
    bus = RabbitMQEventBus(amqp_url='amqp://user:pass@rabbitmq:5672/')
    bus.connect()

    # Subscribe to ALL order events using wildcard
    bus.subscribe(
        queue_name='email-service.order-events',
        binding_pattern='orders.order.*',
        handler=send_email_for_order_event,
    )

    # Also subscribe to user registration events
    bus.subscribe(
        queue_name='email-service.user-events',
        binding_pattern='users.user.registered',
        handler=lambda event: logger.info('Welcome email for %s', event.get('user_id')),
    )

    bus.start_consuming()

Delivery Semantics: At-Least-Once vs Exactly-Once

One of the most misunderstood topics in distributed messaging is delivery guarantees. There are three levels:

flowchart TD subgraph AMO["At-Most-Once\n(fire and forget)"] AMO_P[Producer] -->|publish + forget| AMO_B[(Broker)] AMO_B -->|deliver once, no retry| AMO_C[Consumer] AMO_NOTE["Messages may be lost\nif broker/consumer crashes\nUse for: metrics, non-critical logs"] end subgraph ALO["At-Least-Once\n(most common)"] ALO_P[Producer] -->|publish + wait for ack| ALO_B[(Broker)] ALO_B -->|deliver, retry on nack| ALO_C[Consumer] ALO_C -->|ack after processing| ALO_B ALO_NOTE["Messages never lost\nbut may be delivered 2+ times\nConsumers must be idempotent\nUse for: most event-driven systems"] end subgraph EO["Exactly-Once\n(complex)"] EO_P[Producer] -->|idempotent produce\ntransactional API| EO_B[(Broker)] EO_B -->|transactional deliver| EO_C[Consumer] EO_C -->|consume + produce in\nsame transaction| EO_B EO_NOTE["Never lost, never duplicated\nRequires transactions on both sides\nKafka: enable.idempotence + transactions\nUse for: financial, billing systems"] end style ALO_B fill:#27AE60,color:#fff style EO_B fill:#3498DB,color:#fff style AMO_B fill:#E74C3C,color:#fff

At-least-once is what most systems implement and what you should default to. The key implication: your consumers must be idempotent — processing the same event twice must produce the same result as processing it once.

Making consumers idempotent:

def handle_order_placed_idempotent(event: dict, db_conn):
    """
    Idempotent handler: safe to call multiple times with the same event.
    Uses PostgreSQL's INSERT ... ON CONFLICT DO NOTHING with event_id as unique key.
    """
    event_id = event['event_id']  # Must be included by producer — a UUID
    order_id = event['order_id']

    with db_conn.cursor() as cur:
        # The processed_events table acts as a deduplication log
        cur.execute("""
            INSERT INTO processed_events (event_id, processed_at)
            VALUES (%s, NOW())
            ON CONFLICT (event_id) DO NOTHING
        """, (event_id,))

        if cur.rowcount == 0:
            # This event_id was already processed — skip
            logger.info('Skipping duplicate event: %s', event_id)
            return

        # First time seeing this event — process it
        cur.execute("""
            INSERT INTO order_notifications (order_id, notified_at)
            VALUES (%s, NOW())
            ON CONFLICT (order_id) DO NOTHING
        """, (order_id,))

        db_conn.commit()
        logger.info('Processed event %s for order %s', event_id, order_id)

Apache Pulsar: The Middle Ground

Apache Pulsar deserves a brief mention as a third option that combines elements of both Kafka and RabbitMQ. It separates the serving layer (brokers) from the storage layer (BookKeeper), enabling independent scaling of compute and storage. Key Pulsar differentiators:

  • Multi-tenancy built in: namespaces with per-tenant quotas and isolation
  • Geo-replication native: built-in cross-datacenter replication
  • Tiered storage: automatically offloads older segments to S3/GCS
  • Flexible subscription modes: exclusive (like RabbitMQ), shared (round-robin), key-shared (partitioned), failover

Pulsar is compelling for organizations running multi-region deployments or needing geo-replication without manual Kafka MirrorMaker configuration. However, its operational complexity (managing BookKeeper + Zookeeper + brokers) is higher than Kafka, which itself is already substantial.


Comparison and Tradeoffs

Kafka vs RabbitMQ Decision Guide
Feature Kafka RabbitMQ Pulsar
Model Event log (pull) Message queue (push) Both
Ordering Per-partition Per-queue Per-partition/key
Retention Time/size based Until consumed (+ TTL) Tiered (S3 offload)
Throughput Very high (millions/sec) High (hundreds of thousands/sec) Very high
Routing flexibility Topic + partition Exchange types + wildcards Topic + subscription
Replay Native (seek to offset) No (message consumed = gone) Native
Ops complexity Medium (Kafka + ZK/KRaft) Low (single binary cluster) High (3 tiers)
Best for Event streaming, audit, analytics Task queues, RPC, workflows Multi-region, SaaS

When to Choose Kafka

  • Multiple independent consumers need to read the same events
  • You need event replay (reprocessing history with new logic)
  • High throughput: millions of events per second
  • Event sourcing or CQRS architectures
  • Stream processing with Kafka Streams or Flink

When to Choose RabbitMQ

  • Work queue pattern: N consumers competing for tasks
  • Complex routing logic with topic matching and headers
  • Request-reply (RPC over messaging)
  • Lower operational complexity is a priority
  • Mixed task types that benefit from priority queues

Production Considerations

Kafka Operational Checklist

# Monitor consumer lag — how far behind are consumers?
kafka-consumer-groups.sh \
  --bootstrap-server kafka:9092 \
  --describe \
  --group email-service-consumers

# Output shows: TOPIC, PARTITION, CURRENT-OFFSET, LOG-END-OFFSET, LAG
# LAG > 0 means the consumer is falling behind — investigate throughput

Key metrics to alert on:
- Consumer lag > threshold: consumers cannot keep up with production rate
- Under-replicated partitions > 0: a broker may be down or slow
- ISR (In-Sync Replicas) shrinking: replica is falling behind, risk of data loss on failure
- Request rate on leader vs. follower fetch rate: imbalance indicates hot partitions

Schema Management with Avro and Schema Registry

Without schema management, a producer deploying a breaking schema change will crash all consumers. Use Confluent Schema Registry (or AWS Glue Schema Registry) to version and validate event schemas:

from confluent_kafka.schema_registry import SchemaRegistryClient
from confluent_kafka.schema_registry.avro import AvroSerializer, AvroDeserializer

# Schema Registry enforces compatibility modes:
# BACKWARD: new schema can read data written with previous schema
# FORWARD: previous schema can read data written with new schema
# FULL: both backward and forward compatible

ORDER_SCHEMA_STR = """
{
  "type": "record",
  "name": "OrderPlaced",
  "namespace": "com.amtocsoft.orders",
  "fields": [
    {"name": "order_id", "type": "string"},
    {"name": "user_id", "type": "string"},
    {"name": "total", "type": "double"},
    {"name": "timestamp", "type": "string"},
    {"name": "version", "type": "string", "default": "1.0"}
  ]
}
"""

RabbitMQ High Availability

Run RabbitMQ in a cluster with quorum queues (the modern replacement for mirrored queues):

# Declare a quorum queue — replicated across N nodes for HA
channel.queue_declare(
    queue='critical-orders',
    durable=True,
    arguments={
        'x-queue-type': 'quorum',  # Quorum queue: Raft-based replication
        # x-quorum-initial-group-size defaults to cluster size
    }
)

Quorum queues use Raft consensus to ensure a message is written to a majority of nodes before acking. This prevents data loss on broker failure.


Conclusion

Event-driven architecture is not a silver bullet — it introduces operational complexity, eventual consistency, and the need for idempotent consumers. But for systems beyond a certain scale, it is the only way to achieve genuine decoupling and independent scalability.

The choice between Kafka and RabbitMQ is not about which is better — it is about which model fits your problem. Task distribution with complex routing logic? RabbitMQ. High-throughput event streaming where multiple independent consumers need the full event history? Kafka. Both? Run both — many mature systems use Kafka for the event backbone and RabbitMQ for internal task queues.

The most important thing to get right is your delivery guarantee and idempotency story. Almost every event-driven system operates at-least-once, which means your consumers will occasionally process the same event twice. Build that assumption into your design from day one, and use a deduplication table keyed on event IDs to handle it cleanly.


Building event-driven systems and have questions about Kafka vs RabbitMQ for your use case? Drop a comment below or connect on LinkedIn. Follow AmtocSoft Tech Insights for more deep-dives into distributed systems architecture.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-14 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...