PBO Tech

← Microservices Pattern Demo · builds on Part 2: resilience patterns

Part 3: CQRS and Cache-Aside

Part 2 fixed the demo’s biggest gap - nothing checked whether stock existed before accepting an order. This round adds two more patterns to the same orders/inventory system: CQRS, a genuinely separate read model built from an event the demo already publishes, and Cache-Aside, a cache layered onto inventory-service’s busiest read.

What’s new

flowchart LR
    client["Browser (UI)"]
    gw["API Gateway (nginx)"]
    orders["orders-service (FastAPI)"]
    inventory["inventory-service (FastAPI)"]
    broker[("Redis - broker + cache")]
    inventoryDb[("inventory.db (SQLite) - stock + read model")]

    client --> gw
    gw -->|"/inventory, /orders/summary"| inventory
    inventory -->|reads/writes stock, read model| inventoryDb
    inventory -->|"cache-aside: GET/SET/DEL"| broker
    orders -->|"publish order_created"| broker
    broker -->|subscribe| inventory

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

Two things worth noticing before the patterns themselves: order_created’s subscriber now does two jobs from one event (the fulfilment log from Part 2, and this round’s read model), and Redis now does two unrelated jobs on the one instance (message broker, and cache). Neither pairing knows the other exists.

CQRS: a read model built from an existing event

Database-per-service means a query needing data from more than one service’s database can’t be answered with a single join - there’s no shared database to join across. CQRS answers this by keeping the read side separate from the write side: a model built in advance, shaped for the question it needs to answer, rather than computed live from the write side’s own tables.

flowchart LR
    writer["orders-service (Container)"]
    writeDb[("orders.db (Container)")]
    event[("order_created event (Container)")]
    projector["inventory-service subscriber (Container)"]
    readDb[("fulfilled_orders - read model (Container)")]
    reader["GET /orders/summary (Container)"]

    writer -->|command| writeDb
    writer -->|publishes| event
    event -->|consumed by| projector
    projector -->|builds/updates| readDb
    reader -->|query| readDb

    classDef container fill:#1168bd,stroke:#0b4884,color:#fff
    class writer,writeDb,event,projector,readDb,reader container

GET /orders/summary answers “what was stock after this order” - something that would otherwise need a join across orders-service and inventory-service’s own, separate databases. Instead, the existing order_created event subscriber (already writing a fulfilment/audit log since Part 2) takes a snapshot of inventory.stock at the moment it processes each order, and stores it alongside the fulfilment record. No new event was published for this - the read model reuses what was already there.

One honesty point worth stating plainly: stock_after is a snapshot taken when the event was processed, not stock immediately after this specific order’s own reservation in isolation. Another order’s reservation can land in between. Fine for a summary view; would matter for anything needing an exact per-order figure.

Cache-Aside: caching the busiest read

GET /inventory is read far more often than stock actually changes - the UI polls it every five seconds. Cache-Aside serves it from Redis most of the time, falling back to SQLite only on a cache miss.

flowchart LR
    reader["GET /inventory (Container)"]
    cache[("Redis cache (Container)")]
    store[("inventory.db (Container)")]
    writer["reserve / release / reset (Container)"]

    reader -->|"1: check"| cache
    cache -->|"2: miss - read, then populate"| store
    writer -->|"on write: invalidate"| cache

    classDef container fill:#1168bd,stroke:#0b4884,color:#fff
    class reader,cache,store,writer container

A read checks Redis first; on a miss, it reads SQLite and populates the cache (10-second TTL) before returning. Any write to stock - reserve, release, or reset - deletes the cache key rather than updating it, so the next read repopulates it fresh. The response carries an X-Cache: HIT/MISS header, and the demo’s UI shows it next to the Inventory heading, so the pattern is observable rather than taken on faith.

Redis already ran in this architecture as the event broker from Part 1 - this reuses the same instance for the cache rather than adding a second piece of infrastructure, a deliberate trade-off (see the pattern catalogue for the full reasoning): the two roles now share fate if Redis is ever unreachable, where separate infrastructure would let one keep working if the other didn’t.

See it for yourself

# Cache-Aside: miss, then hit, then miss again after a write
curl -i http://localhost:8090/inventory | grep -i x-cache   # MISS
curl -i http://localhost:8090/inventory | grep -i x-cache   # HIT
curl -X POST http://localhost:8090/orders -H 'Content-Type: application/json' -d '{"item":"widget","qty":1}'
curl -i http://localhost:8090/inventory | grep -i x-cache   # MISS again

# CQRS: the read model, compared against the live value
curl http://localhost:8090/orders/summary
curl http://localhost:8090/inventory

Terms used on this page

See the glossary for plain-English definitions of CQRS, Cache-Aside, TTL, and the rest.

Catalogued

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

On Azure instead of Docker Compose

Part 3 on Azure covers the same read model and cache - and the one place the Azure variant can’t reuse what Compose reuses, since its event broker (Service Bus) isn’t a cache the way Redis is.