PBO Tech

← Microservices Pattern Demo

Part 2: resilience patterns

Part 1 of this demo shipped with a known limitation, flagged repeatedly in its own write-up: nothing ever checked whether stock existed before an order was accepted. The async order_created event decremented stock after the fact, with a floor clamp (MAX(stock - qty, 0)) standing in for a real check. Part 2 fixes that properly, and picks up three more patterns along the way: Saga / Compensating Transaction, Circuit Breaker, and Retry.

What changed

A genuinely new integration: orders-service now calls inventory-service synchronously, before accepting an order at all.

flowchart LR
    client["Browser (UI)"]
    gw["API Gateway (nginx)"]
    orders["orders-service (FastAPI)"]
    inventory["inventory-service (FastAPI)"]
    broker[("Redis - order_created topic")]
    ordersDb[("orders.db (SQLite)")]
    inventoryDb[("inventory.db (SQLite)")]
    jaeger[("Jaeger - traces")]

    client --> gw
    gw -->|"/orders, /inventory"| orders
    gw -->|"/inventory"| inventory
    gw -->|"/jaeger/"| jaeger
    orders -->|reads/writes| ordersDb
    inventory -->|reads/writes| inventoryDb
    orders -->|"1: reserve/release (sync, retry + circuit breaker)"| inventory
    orders -->|"2: publish order_created + trace context (fulfilment log)"| broker
    broker -->|subscribe| inventory
    orders -.->|OTLP spans| jaeger
    inventory -.->|OTLP spans| jaeger

    classDef container fill:#1168bd,stroke:#0b4884,color:#fff
    class gw,orders,inventory,broker,ordersDb,inventoryDb,jaeger container

Two separate integrations now, deliberately: the synchronous call decides whether stock exists at all (strongly consistent), while order_created over Redis is now just a fulfilment/audit record (eventually consistent - stock was already settled by the time it’s processed). Conflating the two - letting the async event be the only check on stock - was Part 1’s actual problem.

Saga / Compensating Transaction

When two services each own their own database, there’s no distributed transaction spanning both - a multi-step operation that touches both can succeed partway and leave them inconsistent.

flowchart LR
    caller["orders-service (Container)"]
    resourceOwner["inventory-service (Container)"]
    store[("stock (Container)")]

    caller -->|"1: reserve"| resourceOwner
    resourceOwner -->|atomically decrements| store
    caller -->|"2: policy check"| caller
    caller -->|"3a: passed - create order"| caller
    caller -->|"3b: failed - release"| resourceOwner
    resourceOwner -->|undoes the reservation| store

    classDef container fill:#1168bd,stroke:#0b4884,color:#fff
    class caller,resourceOwner,store container

Placing an order now reserves stock first (POST /inventory/reserve), an atomic SQL UPDATE ... WHERE stock >= ? that can’t be raced by two concurrent orders both believing they have enough. A deliberately arbitrary policy check then runs - any order over 20 units is rejected, standing in for a downstream fraud/credit/compliance check that only makes sense once stock is already set aside. If it rejects, orders-service calls POST /inventory/release to undo the reservation rather than leaving stock short for an order that never goes through.

The 20-unit limit is labelled honestly as a demo hook, not a real policy - it exists purely to give anyone trying the demo a repeatable way to trigger the compensating path, by ordering a large quantity that’s still within stock.

Circuit Breaker and Retry

Composed together, not independently: Retry absorbs a brief transient blip (up to 3 attempts, linear backoff); Circuit Breaker wraps the whole retry sequence as one logical attempt, and after 3 consecutive failures opens and fails every subsequent call immediately - no network attempt at all - for a 10-second cooldown.

flowchart LR
    caller["orders-service (Container)"]
    breaker{"Circuit breaker: closed / open / half-open"}
    dependency["inventory-service (Container)"]

    caller -->|call| breaker
    breaker -->|closed: attempt, up to 3x with backoff| dependency
    breaker -->|open: fail immediately, no network call| caller
    dependency -->|3 consecutive failed attempts| breaker

    classDef container fill:#1168bd,stroke:#0b4884,color:#fff
    class caller,dependency container

Both are hand-rolled rather than pulled from a library - the point of a pattern demo is to show how the pattern works, not hide it behind a decorator. The whole mechanism is about 60 lines.

Measured directly, not estimated: stopping inventory-service and placing orders, the first three attempts each took roughly 8 seconds (a full retry sequence against a dead connection) before returning 503. The fourth request, once the circuit had opened, returned 503 in 69 milliseconds. That gap is the entire case for a circuit breaker in one number: once a dependency is known to be down, failing fast beats paying the full retry cost on every request.

See it for yourself

Every rejected or failed order on the live demo still returns a trace, with the retries, the circuit state, and (for a policy rejection) the compensating release call all visible in it - exactly the same way a successful order’s trace is shown.

  • Order 30 gadgets (over the limit, within stock) - reserves, then releases. Inventory stays unchanged.
  • Order 999 of anything - plain insufficient stock. No reservation ever happens, so nothing to release.

The circuit breaker needs a terminal, not just the browser:

docker compose stop inventory-service
# place a couple of orders - watch the first few take ~8s, then fail in ms
docker compose start inventory-service
# wait ~10s for the circuit's cooldown; orders succeed normally again

Catalogued

Both new patterns are documented in Architecture Governance Platform alongside the ones from Part 1 - see the pattern catalogue for the public copy.

On Azure instead of Docker Compose

Part 2 on Azure covers the same reservation call, and two ways to get Circuit Breaker + Retry on Azure Container Apps - porting this code as-is, or Dapr’s declarative resiliency policies instead.

Terms used on this page

See the glossary for plain-English definitions of Saga, Circuit Breaker, Retry, and the rest.

What’s next

Part 3 adds CQRS and Cache-Aside - a genuine read model built from the fulfilment event this page introduced, and a cache in front of GET /inventory.