Tech Master Hub

Microservices Anti-Patterns

October 29, 2026 · 11 min read

Microservices Anti-Patterns: What to Avoid When Building Distributed Systems

Migrating from a monolith to microservices is one of the most transformative decisions an engineering team can make. Done well, it unlocks independent deployability, horizontal scalability, and technology diversity. Done poorly, it replicates the same organizational and technical debt across a distributed surface area. The gap between those two outcomes is paved with anti-patterns—recurring solutions that seem pragmatic in the moment but produce costly consequences downstream. This article surveys the most damaging microservices anti-patterns, shows what they look like in practice, and outlines concrete strategies for avoiding or remedying each one.

The Distributed Monolith

The distributed monolith occurs when services are logically coupled despite being physically separated. Calls between services mirror the tight coupling that once existed inside a single codebase. From the outside, you have a healthy fleet of independent containers; from the inside, a single state change still requires coordinated updates across half the service mesh.

Symptoms

  • Nearly every deployment of Service A requires a simultaneous deployment of Service B
  • Services share thick client libraries or internal SDKs that version together
  • Database schemas are split, but transactional boundaries cross service lines anyway
  • CI/CD pipelines enforce all-or-nothing releases because rollback coordination is impossible

Code Example

Consider a checkout flow where the order-service and inventory-service are separate binaries but tightly coupled through a shared protobuf contract and synchronous RPC:

// order.proto — shared between services
service Checkout {
  rpc ReserveStock(ReserveStockRequest) returns (ReserveStockResponse);
}

message ReserveStockRequest {
  string order_id = 1;
  repeated StockItem items = 2;
  // Business logic leak: inventory-service must know about order domains
  string fulfillment_type = 3;
  string shipping_zone = 4;
}

The caller in order-service looks like this:

async def checkout(order: Order) -> CheckoutResult:
    # Tight synchronous coupling: if inventory-service is slow,
    # the entire checkout stalls.
    stock_response = await inventory_client.reserve_stock(
        ReserveStockRequest(
            order_id=order.id,
            items=order.items,
            fulfillment_type=order.fulfillment_type,
            shipping_zone=order.shipping_zone,
        )
    )
    # ... further tight coupling continues
    return CheckoutResult(status="confirmed")

This pattern defeats the purpose of separation. Changes to the protobuf schema, especially the business-logic-leak fields like fulfillment_type, force coordinated releases.

Best Practices

  • Design around capabilities, not database tables. Each service should own a bounded context that maps to a business capability, not a table in a shared schema.
  • Prefer asynchronous communication where real-time consistency isn't required. Use message queues or event streams to decouple call timing.
  • Version contracts explicitly. Use tools like Buf for Protocol Buffers or OpenAPI for REST to enforce backward-compatible evolution without forcing coordinated deployments.
  • Apply the two-pizza rule. If a service can't be maintained by two small teams, it's probably pulling in too many dependencies and needs to split further along natural domain boundaries.

Database-per-Service Violations

The database-per-service principle sounds straightforward: each microservice owns its data store, and other services access it exclusively through the service API. In practice, teams routinely bypass this rule through shared databases, direct queries, or replicated tables that blur ownership.

Common Violations

| Violation | Description | |---|---| | Shared database schema | Two services connect to the same Postgres instance and read each other's tables | | Read replicas as shared state | A reporting service reads directly from a production database replica, creating load and coupling | | Denormalized copy without ownership | One service copies another service's data into its own tables without owning the source of truth |

Code Example

A shared-schema violation might look like this Python snippet, where user-service and billing-service both query the same database:

# billing_service.py — violates database-per-service
from db import get_connection

def get_user_email(user_id: str) -> str:
    conn = get_connection("shared_postgres_db")
    cursor = conn.cursor()
    # Direct table access bypasses the user-service API entirely
    cursor.execute("SELECT email FROM users WHERE id = %s", (user_id,))
    return cursor.fetchone()[0]

This code is fragile. If user-service renames the column, migrates to a different database, or adds a new privacy constraint, the billing service breaks silently at runtime.

Remediation Strategies

  • Use the API as the contract, not the schema. No service should have network-level access to another service's database.
  • Apply the Explicit API rule. Every data access path goes through a versioned service interface (REST, gRPC, or GraphQL).
  • For reporting needs, use CDC. Change data capture tools like Debezium replicate database changes into a read-optimized store that the reporting service owns. This preserves independence while enabling analytical queries.
  • Set up architectural gates. Use tools like architectural linters or CI checks that fail builds when a service imports another service's data-access library.

Synchronous Cascading Calls

When a single user request fans out into a chain of synchronous HTTP calls across five or six services, latency compounds and failure propagates instantly. A slow payment gateway doesn't just delay payments—it brings down the checkout page, the order confirmation, and the notification service too.

The Problem

# Checkout orchestrator — classic cascading call anti-pattern
async def place_order(user_id: str, items: list[Item]) -> Order:
    # Five sequential HTTP calls in a single request path
    user = await http_get(f"https://user-service/api/users/{user_id}")
    inventory = await http_post("https://inventory-service/reserve", items)
    pricing = await http_get(f"https://pricing-service/calculate", items)
    payment = await http_post("https://payment-service/charge", pricing.total)
    notification = await http_post("https://notification-service/send", user.email)

    return Order(status="placed")

Each call adds 50–200 ms of latency. Five calls in series mean 250–1000 ms of pure orchestration overhead, not counting failures. If the notification service is degraded, the order still succeeds—but the response is slower, and partial failures leave the system in an inconsistent state.

Best Practices

  • Use the Saga pattern. Coordinate long-running transactions through a sequence of local transactions paired with compensating actions. Libraries like Temporal, AWS Step Functions, or Azure Durable Functions implement this reliably.
  • Apply the Circuit Breaker pattern. Wrap each outbound call so that transient failures fail fast rather than accumulating latency across the entire chain.
  • Batch and parallelize. Where order doesn't matter, fire independent calls concurrently instead of sequentially.
  • Consider an API Gateway with request composition. Let the gateway fan out calls in parallel and assemble the response, reducing the burden on the client.

Ignoring Idempotency

Distributed systems retry. Networks partition. Services restart. If your service treats every request as unique, retries duplicate charges, duplicate order creations, and corrupt state. Idempotency isn't optional in microservices—it's the foundation of resilient design.

Code Example

A non-idempotent payment endpoint:

@app.post("/payments")
async def create_payment(request: PaymentRequest):
    # Dangerous: no idempotency key, no deduplication
    payment = await db.execute(
        "INSERT INTO payments (user_id, amount, order_id) VALUES (%s, %s, %s)",
        (request.user_id, request.amount, request.order_id)
    )
    await stripe.charge(payment.id, request.amount)
    return {"payment_id": payment.id}

A client retry after a timeout creates a double charge. Here's the corrected version:

import uuid
from fastapi import HTTPException

@app.post("/payments")
async def create_payment(request: PaymentRequest):
    # Require an idempotency key from the client
    idempotency_key = request.idempotency_key or uuid.uuid4().hex

    # Check for existing payment with this key
    existing = await db.fetchone(
        "SELECT id, status FROM payments WHERE idempotency_key = %s",
        (idempotency_key,)
    )
    if existing:
        if existing["status"] == "completed":
            return {"payment_id": existing["id"]}
        raise HTTPException(status_code=409, detail="Payment in progress")

    # Insert with the idempotency key as a unique constraint
    payment = await db.execute(
        """INSERT INTO payments 
           (idempotency_key, user_id, amount, order_id, status) 
           VALUES (%s, %s, %s, %s, 'pending') 
           RETURNING id""",
        (idempotency_key, request.user_id, request.amount, request.order_id)
    )
    await stripe.charge(payment["id"], request.amount)
    await db.execute(
        "UPDATE payments SET status = 'completed' WHERE id = %s",
        (payment["id"],)
    )
    return {"payment_id": payment["id"]}

The database column idempotency_key should have a UNIQUE constraint to prevent race-condition duplicates at the storage layer, not just in application logic.

Best Practices

  • Accept an idempotency key from callers (often a client-generated UUID) and store it alongside the result.
  • Make POST handlers safe to retry by design. If a handler reads-only or updates based on a unique key, it's naturally idempotent.
  • Use at-least-once delivery semantics with idempotent consumers rather than trying to achieve exactly-once delivery, which is fragile and expensive in distributed systems.
  • Document the contract. Make it clear in your API spec whether an endpoint is idempotent and what key format callers must use.

Over-Engineering with Event-Driven Architecture

Event-driven patterns—event sourcing, CQRS, sagas, outbox patterns—are powerful tools. They are also the answer to problems most teams don't have. When every service interaction is modeled as an event stream with compensating transactions, the system becomes impossible to debug, impossible to reason about, and expensive to operate.

Signs You've Gone Too Far

  • New engineers take weeks to understand where data lives and who owns it
  • Every state change requires replaying an event log to verify correctness
  • Monitoring dashboards show event bus backpressure but no clear owner for the backlog
  • Simple CRUD operations are wrapped in multi-step event choreography

When Event-Driven Makes Sense

  • Financial systems where auditability of every state change is a compliance requirement
  • Real-time analytics platforms that need to react to every domain event
  • Systems with high write throughput where synchronous consistency is a bottleneck
  • Domain events that genuinely represent a business fact worth broadcasting (e.g., OrderPlaced, PaymentConfirmed)

When to Stick with Simple RPC or REST

  • Internal admin tools with low traffic and simple consistency requirements
  • Services that primarily serve well-defined CRUD operations
  • Teams that are early in their microservices journey and still learning operational discipline
  • Systems where debugging and observability are already stretching team capacity

Best Practices

  • Start synchronous, move to async only when you feel the pain. Don't architect for scale you don't have yet.
  • Apply the YAGNI principle to event schemas. Don't define events for domain concepts that don't currently need cross-service notification.
  • Limit event scope. An event should carry the minimum information needed for consumers to act. Avoid dumping entire entity snapshots into every event.
  • Invest in observability before going event-driven. If you can't trace a request through a synchronous call, you won't be able to trace it through an event choreography either.

Service Sprawl

Service sprawl happens when teams create new services for marginal gains—a second service to handle a subset of traffic, a separate service to isolate a team, a microservice for a feature that would fit neatly in a module. The result is a landscape with dozens of services, each handling a fraction of the original monolith's responsibility, and none of its operational simplicity.

Metrics That Signal Sprawl

  • More than 2–3 services per domain capability
  • Average request touches 4+ services
  • On-call rotations require context-switching across a dozen services
  • Infrastructure cost per transaction increases as the number of inter-service calls grows

Remediation

  • Audit service boundaries annually. Map each service to a business capability. Delete or merge services that no longer have a clear owner or distinct responsibility.
  • Set a maximum service count. Some organizations cap the number of services in a domain (e.g., "no more than 5 services in the fulfillment domain").
  • Re-monolithize when justified. A well-structured modular monolith beats a poorly organized fleet of ten microservices. Tools like the Modular Monolith pattern and packages like spring-modulith make this a real option.
  • Measure before you split. Don't create a new service because you anticipate scale. Create one because you've hit a scaling bottleneck that module boundaries can't solve.

Tight Coupling Through Shared Libraries

Teams often extract common logic into a shared SDK or library to avoid duplication. Over time, this library becomes a hidden dependency graph. A change to the shared library forces updates across every consuming service, recreating the coordination problem that microservices were meant to solve.

Code Example

┌─────────────────────────────────────────────┐
│           shared-auth-sdk v2.4.1             │
│  • JWT validation                             │
│  • OAuth2 token refresh                       │
│  • Rate-limiting middleware                   │
│  • Audit logging helper                       │
│  • Feature-flag client                        │
└──────────┬──────────┬──────────┬────────────┘
           │          │          │
     ┌─────▼────┐ ┌──▼────────┐ ┌▼───────────┐
     │ order-svc│ │user-svc   │ │billing-svc │
     └──────────┘ └───────────┘ └────────────┘

Updating the rate-limiting middleware in the shared SDK requires redeploying all three services, even though only the order service actually uses rate limiting.

Best Practices

  • Prefer composition over shared libraries. Each service should vendor or independently depend on the libraries it needs, rather than sharing a monolithic SDK.
  • If you must share code, use a proper package registry with semantic versioning. Pin exact versions, require changelog entries, and never publish a breaking change without a major version bump.
  • Treat shared libraries as external dependencies. Don't develop them in the same repository as the services that consume them. Separate repos, separate release cycles, separate owners.
  • Audit dependencies quarterly. Use tools like depgraph or custom scripts to find unused dependencies in shared libraries and remove them.

Conclusion

Anti-patterns in microservices architecture share a common root cause: they optimize for the wrong thing. The distributed monolith optimizes for physical separation over logical independence. The cascading call anti-pattern optimizes for simplicity of implementation over resilience. Service sprawl optimizes for organizational convenience over operational sanity.

The antidote isn't to avoid patterns altogether—it's to apply them deliberately, with clear justification and an awareness of the trade-offs. Start simple. Favor synchronous communication until async provides a measurable benefit. Design services around bounded contexts, not database tables. Enforce idempotency, circuit breakers, and explicit contracts from day one. And when in doubt, resist the urge to over-engineer: a well-built monolith is closer to

Latest posts