diff --git a/docs/STATUS.md b/docs/STATUS.md
index cbde117c..b95e34d3 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -2,7 +2,7 @@
> **Read this first when picking up work.** It's the cross-session entry point: where the project is right now, what to do next, and where the deeper docs live. Keep it short (~100 lines). Update it at the start or end of each working session.
-**Last updated:** 2026-05-25
+**Last updated:** 2026-05-25 (per-service code-flow walkthroughs + cache-backplane reframing)
---
@@ -138,11 +138,12 @@ Conditional follow-up — only matters once we deploy more than one replica of a
**The problem.** `Microsoft.Extensions.Caching.Hybrid` 10.x has no backplane. When replica A invalidates a `ProductDto`, replicas B/C continue serving the stale value from their own in-process L1 for up to `LocalCacheExpiration` (currently 5 min). The API proposal for a pluggable backplane ([dotnet/extensions#5517](https://github.com/dotnet/extensions/issues/5517)) was closed as "NOT ready for implementation" — not coming soon.
-**Mitigation, cheapest first:**
-1. **Drop `LocalCacheExpiration` to 60s** in [HybridProductCache.cs](../CatalogService/CatalogService.Infrastructure/Caching/HybridProductCache.cs). One-line change. Bounds cross-replica staleness at 60s. We lose part of the L1 win for the warm-but-aging tail of entries but keep the hot-entry win and the L2 win. **This is the right move for "ship multi-replica with reasonable consistency."**
-2. **Migrate to [FusionCache](https://github.com/ZiggyCreatures/FusionCache)** if 60s isn't tight enough. FusionCache ships a Redis pub/sub backplane that publishes invalidations to all replicas — drop-in functional replacement with the consistency story we originally wanted from HybridCache. Wiring change is moderate: swap package, retarget the `IProductCache` adapter, verify metrics still flow through OTel. Estimate ~half day plus a chaos test. The cache *seam* (`IProductCache`) stays the same; handlers don't change.
+**Mitigations, cheapest band-aid first → proper fix last:**
+1. **(Band-aid) Drop `LocalCacheExpiration` to 60s** in [HybridProductCache.cs](../CatalogService/CatalogService.Infrastructure/Caching/HybridProductCache.cs). One-line change, bounds cross-replica staleness at 60s. Trade: lose part of the L1 win for the warm-but-aging tail of entries, keep the hot-entry win and the L2 win. **Acceptable for "ship multi-replica with reasonable consistency this sprint," but explicitly a band-aid** — per [Milan Jovanović: *Solving the distributed cache invalidation problem with Redis and HybridCache*](https://www.milanjovanovic.tech/blog/solving-the-distributed-cache-invalidation-problem-with-redis-and-hybridcache), shorter TTL doesn't fix the problem, it just shrinks the inconsistency window. For permissions, pricing, or feature flags the residual staleness is not OK. For product catalog reads it's tolerable.
+2. **(Proper fix, custom backplane) Hand-roll Redis Pub/Sub backplane**, ~50-100 lines. Add an `ICacheInvalidator` registered alongside `IProductCache`; its impl publishes the cleared cache key to a `cache-invalidation` Redis channel via the existing `IConnectionMultiplexer`. An `IHostedService` subscribes, calls `HybridCache.RemoveAsync(key)` locally on every replica when a message arrives, ignores its own publishes. Reuses our existing Redis dependency — no new infrastructure. Self-publishes are redundant but harmless. Walks the same shape as the article's panel 3-4.
+3. **(Proper fix, migrate) Swap to [FusionCache](https://github.com/ZiggyCreatures/FusionCache)** if we want the backplane out-of-the-box. FusionCache ships a built-in Redis Pub/Sub backplane and `.AsHybridCache()` shim so call sites that depend on `Microsoft.Extensions.Caching.Hybrid.HybridCache` keep working. Cleaner long-term; heavier short-term (new package, OTel re-verify, chaos test). Estimate ~half day.
-**Filed here, not in "After the smoke run,"** because this only matters once there's an actual multi-replica deployment. Don't pre-optimize for cross-replica before there's a real cross-replica. Background reading: [Tim Deschryver: FusionCache backplane synchronizing HybridCache](https://timdeschryver.dev/blog/hybridcache-sync-with-fusioncache-backplane).
+**Filed here, not in "After the smoke run,"** because this only matters once there's an actual multi-replica deployment. Don't pre-optimize for cross-replica before there's a real cross-replica. Background reading: [Milan Jovanović: *Distributed cache invalidation with Redis + HybridCache*](https://www.milanjovanovic.tech/blog/solving-the-distributed-cache-invalidation-problem-with-redis-and-hybridcache) (the rationale for #2 and against #1 as a permanent solution); [Tim Deschryver: FusionCache backplane synchronizing HybridCache](https://timdeschryver.dev/blog/hybridcache-sync-with-fusioncache-backplane) (the rationale for #3).
---
diff --git a/docs/code-flows.md b/docs/code-flows.md
new file mode 100644
index 00000000..837259b9
--- /dev/null
+++ b/docs/code-flows.md
@@ -0,0 +1,103 @@
+# Per-service code-flow walkthroughs
+
+Five walkthroughs — one per microservice — that show **which files, classes, and interfaces get touched along the most-load-bearing request path** in each service. New contributors should read the walkthrough for whichever service they're about to touch before opening a file.
+
+Each walkthrough is the same shape:
+- Short intro framing the service's role + architecture style
+- Mermaid `sequenceDiagram`(s) for the main flow(s), with lane labels = `ClassName
file/path.cs`
+- `stateDiagram-v2` if the service has a state machine
+- `graph LR` showing structural relationships (CQRS split, DI wiring) when it adds clarity
+- File inventory table
+- Cross-references to neighbours
+
+---
+
+## The 5 services
+
+| Service | Pattern | Role in the saga | Walkthrough |
+|---|---|---|---|
+| **OrderService** | VSA | Saga **entry point** + state owner (Order aggregate) | [orderservice.md](code-flows/orderservice.md) |
+| **CatalogService** | Clean Architecture | Product catalog (read-heavy, cached); gRPC server for synchronous validation from Order | [catalogservice.md](code-flows/catalogservice.md) |
+| **PaymentService** | VSA | Saga **middle** — charges via gateway + publishes outcome; recovery sweeper for stuck Pendings | [paymentservice.md](code-flows/paymentservice.md) |
+| **ShippingService** | VSA | Saga **last** — creates & dispatches shipment; buyer-scoped read with IDOR protection | [shippingservice.md](code-flows/shippingservice.md) |
+| **NotificationService** | VSA (minimal — no `Domain/`) | Stateless event-to-email pump (3 events in, email out) | [notificationservice.md](code-flows/notificationservice.md) |
+
+---
+
+## The saga at a glance — how the 5 services connect
+
+Each walkthrough explains its own service in depth. This diagram is the glue — what events flow where, in time order.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Buyer
+ participant Order as OrderService
+ participant Cat as CatalogService
+ participant Pay as PaymentService
+ participant Ship as ShippingService
+ participant Notif as NotificationService
+
+ Buyer->>Order: POST /api/v1/orders
+ par sync gRPC (validate + reserve stock)
+ Order->>Cat: GetProductAsync × N
+ Cat-->>Order: ProductDto × N
+ Order->>Cat: ReserveStockAsync × N
+ Cat-->>Order: bool × N
+ end
+
+ Order->>Order: persist Order + stage
OrderPlacedEvent in outbox
(same DB tx)
+ Order-->>Buyer: 202 Accepted
+
+ Note over Order,Notif: From here on, async over Azure Service Bus
+ Order--)Pay: OrderPlacedEvent
+ Order--)Notif: OrderPlacedEvent (parallel)
+
+ Pay->>Pay: ProcessPaymentHandler
(idempotency check, gateway call,
state transition, publish)
+
+ alt gateway success
+ Pay--)Order: PaymentCompletedEvent
+ Pay--)Ship: PaymentCompletedEvent (parallel)
+ Order->>Order: PaymentCompletedHandler
MarkAsPaid()
+
+ Ship->>Ship: CreateShipmentHandler
(idempotency, create, dispatch)
+ Ship--)Order: ShipmentDispatchedEvent
+ Ship--)Notif: ShipmentDispatchedEvent (parallel)
+ Order->>Order: ShipmentDispatchedHandler
MarkAsShipped()
+
+ Notif->>Notif: SendNotification ("Order Shipped")
+ else gateway failure
+ Pay--)Order: PaymentFailedEvent
+ Pay--)Notif: PaymentFailedEvent (parallel)
+ Order->>Order: PaymentFailedHandler
MarkAsPaymentFailed()
+ Notif->>Notif: SendNotification ("Payment Failed")
+ end
+```
+
+The dashed (`--)`) arrows are over Service Bus; the solid arrows inside Phase 1 are synchronous HTTP/gRPC.
+
+---
+
+## Conventions across all 5 walkthroughs
+
+**Lane labels in sequence diagrams** use `ClassName
file/path.cs` so you can read a flow without checking a separate legend.
+
+**Color coding inside `graph LR` blocks** (where used):
+- Blue (`#dbeafe`) — write loaders / mutable state
+- Green (`#a7f3d0`) — read projections / success endpoints
+- Orange (`#fed7aa`) — start/trigger / infrastructure
+- Purple (`#ddd6fe`) — caching layer
+
+**Mermaid syntax gotcha** worth knowing if you edit these files: inside a `Note over X: ` block, no `;` or `:` allowed in the content — Mermaid uses both as parser delimiters. Use em-dash (`—`) or comma instead.
+
+---
+
+## See also
+
+- [docs/architecture.md](architecture.md) — system-level architecture (Aspire, transport, polyglot persistence)
+- [docs/cqrs-data-access.md](cqrs-data-access.md) — the read/write split rule that shapes each service's repository layer
+- [docs/event-catalog.md](event-catalog.md) — every event's shape, producer, and consumer (the saga's contract surface)
+- [docs/transactional-outbox.svg](transactional-outbox.svg) — diagram of outbox mechanics referenced from multiple walkthroughs
+- [docs/hybridcache-flow.svg](hybridcache-flow.svg) — diagram of CatalogService's L1+L2 cache + stampede protection
+- [docs/nextaurora-architecture.svg](nextaurora-architecture.svg) — the full-system visual referenced in the saga-at-a-glance section above
+- [CLAUDE.md](../CLAUDE.md) — the canonical rules every walkthrough references
diff --git a/docs/code-flows/catalogservice.md b/docs/code-flows/catalogservice.md
new file mode 100644
index 00000000..bb68ff5c
--- /dev/null
+++ b/docs/code-flows/catalogservice.md
@@ -0,0 +1,282 @@
+# CatalogService — code flow walkthrough
+
+> **What this is.** A walk through the code paths a new contributor will hit first in [CatalogService](../../CatalogService/). CatalogService owns the product catalog: HTTP for buyer browsing + seller mutations, and a **gRPC server** that OrderService calls synchronously during order placement. Reads go through a two-tier `HybridCache` (in-process L1 + Redis L2); writes invalidate the cache in the same handler that performs the mutation.
+>
+> **Architecture style:** Clean Architecture, four projects. [`CatalogService.Domain/`](../../CatalogService/CatalogService.Domain) (entities + write-side interfaces, no dependencies), [`CatalogService.Application/`](../../CatalogService/CatalogService.Application) (commands, queries, handlers, read-side interfaces), [`CatalogService.Infrastructure/`](../../CatalogService/CatalogService.Infrastructure) (EF, repositories, cache), [`CatalogService.Api/`](../../CatalogService/CatalogService.Api) (HTTP endpoints, gRPC service, composition root).
+>
+> **Three flows to understand:**
+> 1. **GET product by ID** — HTTP read through `HybridCache` (stampede-protected).
+> 2. **PUT product** — HTTP write with seller-scope IDOR check (null → 404), DB write, then cache invalidation in the same handler.
+> 3. **gRPC ReserveStock** — synchronous call from OrderService during order placement; mutates `StockQuantity` under an optimistic-concurrency token.
+
+---
+
+## Flow 1 — GET /api/v1/products/{id} (cached read)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Buyer
+ participant EP as CatalogEndpoints
Api/Endpoints/CatalogEndpoints.cs
+ participant Bus as IMessageBus
(Wolverine)
+ participant H as GetProductByIdHandler
Application/Handlers/GetProductByIdHandler.cs
+ participant Cache as IProductCache
Application/Interfaces/IProductCache.cs
(HybridProductCache impl)
+ participant L1 as L1 MemoryCache
(in-process)
+ participant L2 as L2 Redis
+ participant RS as IProductReadStore
Infrastructure/Repositories/ProductReadStore.cs
+ participant DB as Postgres
(catalog DB)
+
+ Buyer->>EP: GET /api/v1/products/{id}
+ EP->>Bus: bus.InvokeAsync(GetProductByIdQuery)
+ Bus->>H: HandleAsync(query, ct)
+ H->>Cache: GetOrLoadAsync(id, factory, ct)
+
+ Cache->>L1: lookup catalog:product:{id}
+ alt L1 hit
+ L1-->>Cache: ProductDto
+ Cache-->>H: ProductDto
+ else L1 miss
+ Cache->>L2: lookup catalog:product:{id}
+ alt L2 hit
+ L2-->>Cache: ProductDto
+ Cache->>L1: backfill
+ Cache-->>H: ProductDto
+ else L2 miss — invoke factory
+ Note over Cache: STAMPEDE PROTECTION —
concurrent misses for the same key
invoke factory only ONCE
+ Cache->>RS: factory(ct) → readStore.GetByIdAsync
+ RS->>DB: SELECT id, name, price, ... ,
category.Name
FROM products LEFT JOIN categories
WHERE id = @id
(AsNoTracking + .Select to ProductDto)
+ DB-->>RS: 1 row (DTO shape only)
+ RS-->>Cache: ProductDto (or null)
+ Cache->>L2: store with tag product:{id}
+ Cache->>L1: store with tag product:{id}
+ Cache-->>H: ProductDto
+ end
+ end
+
+ H-->>Bus: ProductDto?
+ Bus-->>EP: ProductDto?
+ EP-->>Buyer: 200 OK + ProductDto
(or 404 if null)
+```
+
+**Why projection-in-EF.** The factory hits [`IProductReadStore.GetByIdAsync`](../../CatalogService/CatalogService.Infrastructure/Repositories/ProductReadStore.cs), which projects directly to `ProductDto` inside the `IQueryable` — no entity materialization, no in-memory mapper. The cache stores the DTO, so on hit there's literally nothing to map. See [docs/cqrs-data-access.md](../cqrs-data-access.md) for the rule.
+
+**Negative caching.** If `GetByIdAsync` returns `null`, the cache stores `null`. Subsequent lookups for that ID skip the DB. Safe here because product IDs are server-generated GUIDs — a "not found now, exists later" race is effectively impossible.
+
+---
+
+## Flow 2 — PUT /api/v1/products/{id} (seller-scoped write + invalidation)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Seller
+ participant EP as CatalogEndpoints
Api/Endpoints/CatalogEndpoints.cs
+ participant Bus as IMessageBus
(Wolverine)
+ participant Val as UpdateProductCommandValidator
Application/Validators/
(FluentValidation, runs in pipeline)
+ participant H as UpdateProductHandler
Application/Handlers/UpdateProductHandler.cs
+ participant Repo as IProductRepository
Domain/Interfaces/
(ProductRepository impl)
+ participant Agg as Product aggregate
Domain/Entities/Product.cs
+ participant Cache as IProductCache
+ participant DB as Postgres
+
+ Seller->>EP: PUT /api/v1/products/{id}
{ ProductId, SellerId, Name, ... }
+ Note over EP: 1. route id == body.ProductId? else 400
2. JWT sub == command.SellerId? else 403
(authentication mismatch, not IDOR)
+ EP->>Bus: bus.InvokeAsync(command, ct)
+ Bus->>Val: validate command
+ Val-->>Bus: ok (or rejected before reaching handler)
+ Bus->>H: HandleAsync(command, ct)
+
+ H->>Repo: GetByIdAsync(productId, ct)
+ Repo->>DB: SELECT * FROM products
WHERE id = @id (tracked)
+ DB-->>Repo: Product entity + xmin
+ Repo-->>H: Product (tracked)
+
+ alt product is null
+ H-->>Bus: false
+ Bus-->>EP: false
+ EP-->>Seller: 404 Not Found
+ else seller mismatch — IDOR guard
+ Note over H: stored product.SellerId != command.SellerId
→ return false (NOT throw, NOT 403)
indistinguishable from "not found"
(anti-enumeration — see CLAUDE.md Security)
+ H-->>Bus: false
+ Bus-->>EP: false
+ EP-->>Seller: 404 Not Found
+ else owner match
+ H->>Agg: UpdateDetails(name, description, price)
+ Note over Agg: domain method validates
invariants (price > 0, etc.)
+ H->>Repo: UpdateAsync(product, ct)
+ Repo->>DB: UPDATE products
SET ..., xmin (auto)
WHERE id = @id AND xmin = @originalXmin
+ alt xmin matches
+ DB-->>Repo: 1 row affected
+ else concurrency conflict
+ DB-->>Repo: 0 rows → DbUpdateConcurrencyException
(GlobalExceptionHandler → 409)
+ end
+ Repo-->>H: ok
+
+ H->>Cache: InvalidateAsync(productId, ct)
+ Note over Cache: invalidate AFTER save —
order matters. Invalidating first
would let a concurrent reader
repopulate the cache with the OLD
value between invalidate and save.
+ Cache->>L2: remove by tag product:{id}
+ Cache->>L1: remove by tag product:{id} (this replica only)
+ H-->>Bus: true
+ Bus-->>EP: true
+ EP-->>Seller: 204 No Content
+ end
+```
+
+**Two-tier ownership check.** The endpoint catches the case where a caller submits SOMEONE ELSE's `SellerId` in the body (403 — that's authentication-mismatch, the caller lied about identity). The handler catches the case where a caller submits THEIR own `SellerId` paired with another seller's product ID (404 — IDOR, anti-enumeration). Both layers are required: each one alone has a bypass.
+
+**Multi-replica cache caveat.** `HybridCache` has no backplane in .NET 10. `InvalidateAsync` clears L2 (Redis) and the L1 of *this* replica only; other replicas continue serving stale `ProductDto` from their own L1 for up to `LocalCacheExpiration` (5 min). Documented in [HybridProductCache.cs](../../CatalogService/CatalogService.Infrastructure/Caching/HybridProductCache.cs) and the deferred follow-up in [STATUS.md](../STATUS.md).
+
+---
+
+## Flow 3 — gRPC ReserveStock (called by OrderService)
+
+This is the synchronous server-side of the cross-service path you saw in OrderService's `PlaceOrderHandler`. OrderService calls `ReserveStockAsync` once per line; each call enters here.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Order as OrderService
(GrpcCatalogClient)
+ participant gRPC as CatalogGrpcService
Api/Services/CatalogGrpcService.cs
+ participant Bus as IMessageBus
+ participant H as ReserveStockHandler
Application/Handlers/ReserveStockHandler.cs
+ participant Repo as IProductRepository
+ participant Agg as Product aggregate
+ participant Cache as IProductCache
+ participant DB as Postgres
+
+ Order->>gRPC: ReserveStock(productId, qty)
(HTTP/2 binary protobuf)
+ Note over gRPC: parse productId string → Guid
RpcException(InvalidArgument)
on malformed input
+ gRPC->>Bus: bus.InvokeAsync(ReserveStockCommand)
+ Bus->>H: HandleAsync(command, ct)
+
+ H->>Repo: GetByIdAsync(productId, ct)
+ Repo->>DB: SELECT * FROM products
WHERE id = @id (tracked)
+ DB-->>Repo: Product (tracked) + xmin
+ Repo-->>H: Product
+
+ alt product is null OR stock < qty
+ H-->>Bus: false
+ Bus-->>gRPC: false
+ gRPC-->>Order: ReserveStockResponse { Success = false }
+ else stock available
+ H->>Agg: AdjustStock(stock - qty)
+ H->>Repo: UpdateAsync(product, ct)
+ Repo->>DB: UPDATE products
SET stock_quantity = @new,
xmin = NEW
WHERE id = @id AND xmin = @originalXmin
+ alt xmin matches (this caller won the race)
+ DB-->>Repo: 1 row
+ Repo-->>H: ok
+ H->>Cache: InvalidateAsync(productId, ct)
+ H-->>Bus: true
+ Bus-->>gRPC: true
+ gRPC-->>Order: ReserveStockResponse { Success = true }
+ else xmin stale (concurrent reservation won)
+ DB-->>Repo: DbUpdateConcurrencyException
+ Note over H: bubbles up as gRPC Internal status —
OrderService sees the call fail and
aborts the order.
xmin is the race protection,
last-write-wins is impossible.
+ end
+ end
+```
+
+**Why gRPC instead of REST for this path.** Service-to-service hot calls benefit from binary protobuf (~5× smaller payloads than JSON), HTTP/2 multiplexing, and generated client stubs with zero serialization ambiguity. Browser-facing APIs stay REST.
+
+**Same handler as HTTP — no logic duplication.** Both `CatalogGrpcService.ReserveStock` (this flow) and any future HTTP endpoint for stock adjustment dispatch through `bus.InvokeAsync(new ReserveStockCommand(...))`. The handler is the single source of truth for the business rules; gRPC and HTTP are interchangeable transports.
+
+---
+
+## Read/write data-access split
+
+CatalogService is the Clean Architecture variant of the [CQRS data-access rule](../cqrs-data-access.md): write loaders live on the Domain-layer `IProductRepository`; read projections live on a separate Application-layer `IProductReadStore`. The split exists because the Domain project doesn't reference `NextAurora.Contracts` (where DTOs live), so a DTO-returning method can't sit on a Domain interface.
+
+```mermaid
+graph LR
+ subgraph Domain["CatalogService.Domain"]
+ IPR["IProductRepository
(write loaders)"]
+ end
+
+ subgraph App["CatalogService.Application"]
+ IPRS["IProductReadStore
(read projections)"]
+ IPC["IProductCache"]
+ end
+
+ subgraph Infra["CatalogService.Infrastructure"]
+ PR["ProductRepository
GetByIdAsync
AddAsync, UpdateAsync"]
+ PRS["ProductReadStore
GetByIdAsync
GetAllAsync
SearchAsync"]
+ HPC["HybridProductCache
GetOrLoadAsync
InvalidateAsync"]
+ end
+
+ subgraph Writers["Write handlers"]
+ CW["CreateProductHandler
UpdateProductHandler
ReserveStockHandler"]
+ end
+
+ subgraph Readers["Read handlers"]
+ CR["GetProductByIdHandler
GetAllProductsHandler
SearchProductsHandler"]
+ end
+
+ IPR -.->|impl| PR
+ IPRS -.->|impl| PRS
+ IPC -.->|impl| HPC
+
+ CW -->|tracked entity| IPR
+ CW -->|invalidate after save| IPC
+ CR -->|cache-aside| IPC
+ IPC -->|miss → factory| IPRS
+
+ style PR fill:#dbeafe,stroke:#1e3a5f
+ style PRS fill:#a7f3d0,stroke:#047857
+ style HPC fill:#ddd6fe,stroke:#6d28d9
+```
+
+The method signature is the contract: anything returning `Product` is a write loader; anything returning `ProductDto` is a read projection. The write path also invalidates the cache in the same handler.
+
+---
+
+## File inventory
+
+| Path | Purpose |
+|---|---|
+| [Api/Endpoints/CatalogEndpoints.cs](../../CatalogService/CatalogService.Api/Endpoints/CatalogEndpoints.cs) | HTTP surface: GET (public), POST/PUT (seller-scoped with two-tier check) |
+| [Api/Services/CatalogGrpcService.cs](../../CatalogService/CatalogService.Api/Services/CatalogGrpcService.cs) | gRPC server — translates to Wolverine commands/queries (same handlers as HTTP) |
+| [Api/Protos/catalog.proto](../../CatalogService/CatalogService.Api/Protos/catalog.proto) | gRPC contract for `GetProduct`, `GetProducts`, `ReserveStock` |
+| [Api/Program.cs](../../CatalogService/CatalogService.Api/Program.cs) | Composition root: Wolverine, EF, HybridCache, gRPC, OpenAPI/Scalar |
+| [Application/Commands/](../../CatalogService/CatalogService.Application/Commands) | `CreateProductCommand`, `UpdateProductCommand`, `ReserveStockCommand` |
+| [Application/Queries/](../../CatalogService/CatalogService.Application/Queries) | `GetProductByIdQuery`, `GetAllProductsQuery`, `SearchProductsQuery` |
+| [Application/Handlers/UpdateProductHandler.cs](../../CatalogService/CatalogService.Application/Handlers/UpdateProductHandler.cs) | Write + IDOR seller-scope check + cache invalidation |
+| [Application/Handlers/ReserveStockHandler.cs](../../CatalogService/CatalogService.Application/Handlers/ReserveStockHandler.cs) | Stock mutation under xmin token + cache invalidation |
+| [Application/Handlers/GetProductByIdHandler.cs](../../CatalogService/CatalogService.Application/Handlers/GetProductByIdHandler.cs) | Cache-aside read; factory hits the read store on miss |
+| [Application/Handlers/GetAllProductsHandler.cs](../../CatalogService/CatalogService.Application/Handlers/GetAllProductsHandler.cs) | Paginated list via read store (no cache) |
+| [Application/Handlers/SearchProductsHandler.cs](../../CatalogService/CatalogService.Application/Handlers/SearchProductsHandler.cs) | ILIKE search via read store (Postgres `ILike`, case-insensitive) |
+| [Application/Interfaces/IProductCache.cs](../../CatalogService/CatalogService.Application/Interfaces/IProductCache.cs) | Cache port: `GetOrLoadAsync` (factory) + `InvalidateAsync` (by tag) |
+| [Application/Interfaces/IProductReadStore.cs](../../CatalogService/CatalogService.Application/Interfaces/IProductReadStore.cs) | Read-side port: DTO-returning projection methods |
+| [Application/Validators/](../../CatalogService/CatalogService.Application/Validators) | FluentValidation rules; run automatically in Wolverine pipeline |
+| [Domain/Entities/Product.cs](../../CatalogService/CatalogService.Domain/Entities/Product.cs) | Aggregate root — factory + invariants + `UpdateDetails` / `AdjustStock` |
+| [Domain/Entities/Category.cs](../../CatalogService/CatalogService.Domain/Entities/Category.cs) | Owned by Product (1-to-many) |
+| [Domain/Interfaces/IProductRepository.cs](../../CatalogService/CatalogService.Domain/Interfaces/IProductRepository.cs) | Write-side port (tracked entity loaders + Add/Update) |
+| [Infrastructure/Repositories/ProductRepository.cs](../../CatalogService/CatalogService.Infrastructure/Repositories/ProductRepository.cs) | EF write-side impl; `Include(p => p.Category)` for tracked loads |
+| [Infrastructure/Repositories/ProductReadStore.cs](../../CatalogService/CatalogService.Infrastructure/Repositories/ProductReadStore.cs) | EF read-side impl; `AsNoTracking` + `.Select` projection + `ILike` search |
+| [Infrastructure/Caching/HybridProductCache.cs](../../CatalogService/CatalogService.Infrastructure/Caching/HybridProductCache.cs) | `IProductCache` over `Microsoft.Extensions.Caching.Hybrid` (L1+L2 + stampede + tags) |
+| [Infrastructure/Data/CatalogDbContext.cs](../../CatalogService/CatalogService.Infrastructure/Data/CatalogDbContext.cs) | EF context — `xmin` concurrency token configured here |
+| [Infrastructure/DependencyInjection.cs](../../CatalogService/CatalogService.Infrastructure/DependencyInjection.cs) | DI wiring — registers Repo + ReadStore + Cache |
+
+---
+
+## Open questions
+
+**`HybridCache` has no cross-replica L1 invalidation backplane.** This is documented inline in [HybridProductCache.cs](../../CatalogService/CatalogService.Infrastructure/Caching/HybridProductCache.cs) and in [STATUS.md](../STATUS.md). When a write handler calls `InvalidateAsync`, L2 (Redis) is cleared globally, but L1 (in-process MemoryCache) is cleared only on **this replica** — other replicas continue serving the stale `ProductDto` from their own L1 for up to `LocalCacheExpiration` (5 min). For Catalog reads this is tolerable; for permissions, pricing, or feature flags it wouldn't be. Today this doesn't bite because we deploy single-replica.
+
+**Two real fixes when multi-replica lands**, both spelled out in [Milan Jovanović — *Solving the distributed cache invalidation problem with Redis and HybridCache*](https://www.milanjovanovic.tech/blog/solving-the-distributed-cache-invalidation-problem-with-redis-and-hybridcache):
+
+1. **Hand-roll a Redis Pub/Sub backplane** (~50-100 lines). Add an `ICacheInvalidator` whose publisher writes the cleared cache key to a `cache-invalidation` channel via our existing `IConnectionMultiplexer`. An `IHostedService` subscribes and calls `HybridCache.RemoveAsync(key)` locally on every replica when a message arrives. Self-publishes are redundant but harmless. Reuses our existing Redis — no new infrastructure dependency. The `IProductCache` seam doesn't change; handlers don't change.
+2. **Migrate `IProductCache` to FusionCache.** FusionCache ships the Pub/Sub backplane built in and provides `.AsHybridCache()` so `Microsoft.Extensions.Caching.Hybrid.HybridCache` call sites keep working unchanged. Cleaner long-term, heavier short-term — new package, OTel re-verify, chaos test for the backplane behaviour under Redis partition. Estimate ~half day.
+
+The band-aid mitigation (dropping `LocalCacheExpiration` to 60s) is what [STATUS.md](../STATUS.md) currently positions as the acceptable interim for shipping multi-replica with reasonable consistency in a single sprint, but the article frames it as a band-aid rather than a fix — shorter TTL shrinks the inconsistency window, doesn't eliminate it, and trades L1 hit rate to do so. For Catalog reads, the band-aid is a defensible interim; for any future cached domain where staleness has correctness consequences (pricing, permissions, flags), go straight to one of the two proper fixes.
+
+**Trigger to act:** a second `CatalogService` replica gets deployed. Not before — pre-optimizing the backplane for a single-replica deployment is the kind of speculation [CLAUDE.md](../../CLAUDE.md) "Measure before optimizing" warns against.
+
+---
+
+## See also
+
+- [docs/code-flows/orderservice.md](orderservice.md) — OrderService is the caller for `gRPC ReserveStock`
+- [docs/cqrs-data-access.md](../cqrs-data-access.md) — read/write split rule (Clean Architecture variant uses `IProductReadStore`)
+- [docs/hybridcache-flow.svg](../hybridcache-flow.svg) — diagram of the L1/L2/stampede/tag-invalidation mechanics
+- [docs/performance-and-data-correctness.md](../performance-and-data-correctness.md) — full perf rationale incl. caching decisions
+- [Milan Jovanović — *Solving the distributed cache invalidation problem with Redis and HybridCache*](https://www.milanjovanovic.tech/blog/solving-the-distributed-cache-invalidation-problem-with-redis-and-hybridcache) — external; source of the "Open questions" framing above
diff --git a/docs/code-flows/notificationservice.md b/docs/code-flows/notificationservice.md
new file mode 100644
index 00000000..5fab1aed
--- /dev/null
+++ b/docs/code-flows/notificationservice.md
@@ -0,0 +1,120 @@
+# NotificationService — code flow walkthrough
+
+> **What this is.** A walk through the code paths in [NotificationService](../../NotificationService/) — the **smallest service in the system**. NotificationService is a stateless event-to-email pump: it consumes three events from other services and dispatches an email-like notification via a pluggable sender. No DB, no aggregate, no state worth protecting.
+>
+> **Architecture style:** Vertical Slice Architecture at its most minimal — **three files in `Features/`**, one in `Infrastructure/`, one `Program.cs`. **No `Domain/` folder at all.** This is the canonical example of "the pattern only earns its keep when there's something to protect": NotificationService has no invariants, no state machine, no aggregate root, so adding one would be pure ceremony. See [CLAUDE.md "Rich Domain Entities (when warranted)"](../../CLAUDE.md).
+>
+> **One flow to understand:** three events come in (one each from OrderService, PaymentService, ShippingService); each is translated to a `SendNotificationRequest` and dispatched through a single `SendNotificationHandler` that ends in `INotificationSender.SendAsync`.
+
+---
+
+## The flow — events in, email out
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant ASB as Azure Service Bus
(orders / payments / shipping topics)
+ participant W as Wolverine consumer +
ContextPropagation middleware
+ participant EH as NotificationEventHandlers
Features/NotificationEventHandlers.cs
(3 static overloads, return commands)
+ participant SH as SendNotificationHandler
Features/SendNotification.cs
+ participant Send as INotificationSender
Features/SendNotification.cs
(port)
+ participant CS as ConsoleNotificationSender
Infrastructure/
(dev impl —
SendGrid/Twilio/SES swap in prod)
+
+ ASB->>W: OrderPlacedEvent
OR PaymentFailedEvent
OR ShipmentDispatchedEvent
+ Note over W: ContextPropagation restores
logger scope from envelope headers
+ W->>EH: Handle(@event)
+ Note over EH: pure event-to-command mapping —
no I/O, no state, just string formatting
(see "Why merged into one class" below)
+ EH-->>W: returns SendNotificationRequest
(Wolverine cascading message)
+ W->>SH: HandleAsync(request, ct)
+
+ Note over SH: minimal email-shape check —
'@' present and length <= 254.
Full RFC 5322 is over-validation —
most RFC-valid addresses are
still wrong in practice.
+
+ alt invalid email shape
+ SH-->>W: throw ArgumentException
+ Note over W: GlobalExceptionHandler returns 400
(if dispatched via HTTP) or
Wolverine retries → DLQ (if via bus)
+ else valid
+ SH->>Send: SendAsync(email, subject, body, channel, ct)
+ Send->>CS: dispatch
+ Note over CS: dev — logs to console.
prod adapter (SendGrid, Twilio,
Amazon SES) is a DI swap —
no handler code changes.
+ CS-->>SH: ok
+ SH-->>W: ok (NotificationsSent counter ++)
+ end
+
+ alt sender throws (transient failure)
+ SH->>SH: log error
+ SH-->>W: re-throw
+ Note over W: Wolverine retry policy fires —
re-throw is what makes that work.
Swallowing the exception would
silently drop the notification.
+ end
+```
+
+**The three event-handler overloads** (all in [NotificationEventHandlers.cs](../../NotificationService/Features/NotificationEventHandlers.cs)):
+
+| Event consumed | Source service | Email subject | Notes |
+|---|---|---|---|
+| `OrderPlacedEvent` | OrderService | "Order Received" | Buyer ID in event → placeholder email |
+| `PaymentFailedEvent` | PaymentService | "Payment Failed" | Reflects raw gateway `Reason` (TODO: translate to user-friendly copy) |
+| `ShipmentDispatchedEvent` | ShippingService | "Order Shipped" | Event has no `BuyerId` — uses `OrderId` as placeholder recipient (TODO: real recipient lookup) |
+
+**Why all three overloads in one class.** Each handler is pure event-to-command mapping with no state and no branching beyond string formatting. Splitting them into separate classes would be uniform with the saga services (OrderService) but doesn't earn its keep here. If one grows real logic (lookup against a user-prefs cache, channel selection, A/B copy), promote it back to its own file at that point. The pattern explicitly allows this in [SendNotification.cs](../../NotificationService/Features/SendNotification.cs) — VSA puts what-changes-together in one place.
+
+**Why no `IRecipientResolver` abstraction.** There used to be a stub `RecipientResolver` returning the same placeholder emails. It was deleted because the stub didn't enforce any contract that mattered — the abstraction wasn't earning its keep (per CLAUDE.md's "Interfaces earn their keep through consumer substitution"). When a real recipient lookup lands (gRPC to a user service, or a local cache hydrated from `UserCreated` events), introduce the seam at that point.
+
+---
+
+## Why no `Domain/` folder
+
+NotificationService is the minimal counter-example to OrderService / PaymentService / ShippingService. Compare:
+
+```mermaid
+graph LR
+ subgraph Saga["OrderService / PaymentService / ShippingService"]
+ D1["Domain/
aggregate + invariants +
state guards"]
+ F1["Features/
commands + handlers"]
+ I1["Infrastructure/
EF + outbox"]
+ end
+
+ subgraph Notif["NotificationService"]
+ F2["Features/
3 event translators
+ SendNotification"]
+ I2["Infrastructure/
1 sender impl"]
+ end
+
+ F1 --> D1
+ F1 --> I1
+ F2 --> I2
+
+ style D1 fill:#dbeafe,stroke:#1e3a5f
+ style F1 fill:#a7f3d0,stroke:#047857
+ style I1 fill:#fed7aa,stroke:#c2410c
+ style F2 fill:#a7f3d0,stroke:#047857
+ style I2 fill:#fed7aa,stroke:#c2410c
+```
+
+What's missing on the right side is **a `Domain/` folder** — because there's nothing for one to hold:
+
+- **No persisted state.** Nothing to put a concurrency token on, nothing to validate, no aggregate to load and mutate.
+- **No business invariants.** The "rule" is "send the email" — that's not an invariant, it's a function.
+- **No state machine.** The notification is a fire-and-forget event; there's no "Pending → Sent → Delivered" lifecycle the service tracks.
+
+Adding a `Notification` entity with `Create()`, status enum, `private set` properties — would be the kind of speculative ceremony CLAUDE.md warns against. The shape matches the complexity; promote later if real domain rules emerge.
+
+---
+
+## File inventory
+
+| Path | Purpose |
+|---|---|
+| [Features/NotificationEventHandlers.cs](../../NotificationService/Features/NotificationEventHandlers.cs) | Three static `Handle(@event)` overloads — each returns a `SendNotificationRequest` (Wolverine cascading) |
+| [Features/SendNotification.cs](../../NotificationService/Features/SendNotification.cs) | The request record + `INotificationSender` port + `SendNotificationHandler` (all in one file — VSA) |
+| [Infrastructure/ConsoleNotificationSender.cs](../../NotificationService/Infrastructure/ConsoleNotificationSender.cs) | Dev-time `INotificationSender` impl — logs instead of sending |
+| [Infrastructure/DependencyInjection.cs](../../NotificationService/Infrastructure/DependencyInjection.cs) | DI wiring (registers the sender impl) |
+| [Program.cs](../../NotificationService/Program.cs) | Composition root — Wolverine + ASB subscriptions + sender |
+
+---
+
+## See also
+
+- [docs/code-flows/orderservice.md](orderservice.md) — produces `OrderPlacedEvent` (Flow input #1)
+- [docs/code-flows/paymentservice.md](paymentservice.md) — produces `PaymentFailedEvent` (Flow input #2)
+- [docs/code-flows/shippingservice.md](shippingservice.md) — produces `ShipmentDispatchedEvent` (Flow input #3)
+- [CLAUDE.md "Rich Domain Entities (when warranted)"](../../CLAUDE.md) — the rule that lets this service skip the `Domain/` folder
+- [docs/event-catalog.md](../event-catalog.md) — every event's shape and producer/consumer
diff --git a/docs/code-flows/orderservice.md b/docs/code-flows/orderservice.md
new file mode 100644
index 00000000..39c4bf6a
--- /dev/null
+++ b/docs/code-flows/orderservice.md
@@ -0,0 +1,248 @@
+# OrderService — code flow walkthrough
+
+> **What this is.** A walk through the code paths a new contributor will hit first in [OrderService](../../OrderService/). OrderService is the **saga orchestrator** — every order placed here triggers a multi-step workflow that fans out to PaymentService, ShippingService, and NotificationService over Azure Service Bus, then comes back through three event handlers that mutate the Order aggregate. The diagrams below show *which files, classes, and interfaces* get touched at each step, in the order they actually execute.
+>
+> **Architecture style:** Vertical Slice Architecture (single csproj). Folders: [`Endpoints/`](../../OrderService/Endpoints), [`Features/`](../../OrderService/Features), [`Domain/`](../../OrderService/Domain), [`Infrastructure/`](../../OrderService/Infrastructure). Composition root: [`Program.cs`](../../OrderService/Program.cs).
+>
+> **Two flows to understand:**
+> 1. **Phase 1 — Request-driven (PlaceOrder):** buyer POSTs an order, OrderService validates against Catalog over gRPC, persists, and publishes `OrderPlacedEvent` via the transactional outbox.
+> 2. **Phase 2 — Event-driven (saga consume):** three events come back over Service Bus — `PaymentCompletedEvent`, `PaymentFailedEvent`, `ShipmentDispatchedEvent` — each one transitions the Order through its state machine.
+
+---
+
+## Phase 1 — Place order (request-driven)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Buyer
+ participant EP as OrderEndpoints
Endpoints/OrderEndpoints.cs
+ participant Bus as IMessageBus
(Wolverine)
+ participant MW as ContextPropagation +
FluentValidation middleware
+ participant H as PlaceOrderHandler
Features/PlaceOrder.cs
+ participant gRPC as ICatalogClient
GrpcCatalogClient.cs
+ participant Cat as CatalogService
(separate service)
+ participant Agg as Order aggregate
Domain/Order.cs
+ participant Repo as IOrderRepository
OrderRepository.cs
+ participant Pub as IEventPublisher
WolverineEventPublisher.cs
+ participant DB as SQL Server +
wolverine.outgoing_envelopes
+ participant ASB as Azure Service Bus
(orders topic)
+
+ Buyer->>EP: POST /api/v1/orders
{ BuyerId, Currency, Lines[] }
+ Note over EP: JWT sub == command.BuyerId?
else 403 Forbid
+ EP->>Bus: bus.InvokeAsync(command, ct)
+ Bus->>MW: opens logger scope
(CorrelationId, UserId, SessionId)
FluentValidation runs
+ MW->>H: HandleAsync(command, ct)
(wrapped by AutoApplyTransactions)
+
+ par for each line — validate
+ H->>gRPC: GetProductAsync(productId)
+ gRPC->>Cat: gRPC call
+ Cat-->>gRPC: ProductDto
+ gRPC-->>H: ProductDto
+ end
+ Note over H: throw InvalidOperationException
if missing / unavailable /
insufficient stock
+
+ par for each line — reserve
+ H->>gRPC: ReserveStockAsync(productId, qty)
+ gRPC->>Cat: gRPC call (writes Catalog DB)
+ Cat-->>gRPC: bool
+ gRPC-->>H: bool
+ end
+
+ H->>Agg: Order.Create(buyerId, currency, lines)
+ Note over Agg: factory validates invariants —
uses CatalogService prices,
NEVER client-submitted prices
+ H->>Repo: AddAsync(order, ct)
+ Repo->>DB: INSERT Orders + OrderLines
+ H->>Pub: PublishAsync(OrderPlacedEvent)
+ Note over Pub,DB: Wolverine stages envelope into
wolverine.outgoing_envelopes
(same DB transaction as entity write —
UseDurableOutboxOnAllSendingEndpoints)
+ DB-->>H: tx commit
+ H-->>Bus: order.Id (Guid)
+ Bus-->>EP: order.Id
+ EP-->>Buyer: 202 Accepted
Location: /api/v1/orders/{id}
+
+ Note over DB,ASB: Wolverine background flush
dispatches envelope to ASB
+ DB->>ASB: OrderPlacedEvent
+```
+
+**Key wiring (in [`Program.cs`](../../OrderService/Program.cs)):**
+
+```csharp
+opts.PersistMessagesWithSqlServer(connectionString, "wolverine");
+opts.UseEntityFrameworkCoreTransactions();
+opts.Policies.AutoApplyTransactions();
+opts.Policies.UseDurableOutboxOnAllSendingEndpoints();
+opts.AddNextAuroraContextPropagation(); // logger scope from ServiceDefaults
+opts.UseFluentValidation();
+opts.AddConcurrencyRetry(); // OnException
+```
+
+**Why two parallel `Task.WhenAll` blocks** (validate, then reserve) instead of one combined pass: a validation failure on line 3 must NOT leave reservations on lines 1 and 2. Splitting the phases makes partial-commit impossible at the validation layer. See [PlaceOrder.cs:73-79](../../OrderService/Features/PlaceOrder.cs#L73) for the rationale in code.
+
+**`DbContext` thread-safety**: the `Task.WhenAll` parallelism is over **gRPC client calls only** — no OrderService `DbContext` is touched in that block. The CLAUDE.md "DbContext is not thread-safe" rule is satisfied. Each gRPC call hits CatalogService where it gets its own per-request DbContext scope.
+
+---
+
+## Phase 2 — Saga consume (event-driven)
+
+Three events come back over Service Bus. They all follow the same shape: ASB → Wolverine consumer → middleware restores logger scope from envelope headers → handler loads the tracked `Order` aggregate → **handler pre-checks status** (idempotency: returns early on duplicate) → calls a named state-transition method on the aggregate (invariant: throws on invalid transition) → `SaveChanges`. **Two layers, two responsibilities** — the handler does idempotency (no-op on duplicate); the aggregate does invariant enforcement (throw on invalid state). See [`PaymentCompletedHandler.cs`](../../OrderService/Features/PaymentCompletedHandler.cs) for the status pre-check, and [`Order.cs:MarkAsPaid`](../../OrderService/Domain/Order.cs) for the invariant throw.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant ASB as Azure Service Bus
(payments topic, shipping topic)
+ participant W as Wolverine consumer +
ContextPropagation middleware
+ participant H as Saga handler
(one of 3 below)
+ participant Repo as IOrderRepository
+ participant Agg as Order aggregate
Domain/Order.cs
+ participant DB as SQL Server
(orders + wolverine schema)
+
+ ASB->>W: PaymentCompletedEvent
(or PaymentFailedEvent /
ShipmentDispatchedEvent)
+ Note over W: reads X-Correlation-Id,
X-User-Id, X-Session-Id
from envelope headers,
opens logger scope
+ W->>H: HandleAsync(@event, ct)
(AutoApplyTransactions wraps)
+
+ H->>Repo: GetByIdAsync(@event.OrderId, ct)
+ Repo->>DB: SELECT * FROM Orders
WHERE Id = @id (tracked)
+ DB-->>Repo: Order entity (tracked) +
RowVersion snapshot
+ Repo-->>H: Order
+
+ alt order missing (at-least-once delivery edge)
+ H-->>W: return (no-op)
+ Note over H: late-arriving event
against deleted order
+ else order found
+ Note over H: HANDLER status pre-check —
if order.Status != expected,
return early (no-op).
This is the idempotency layer:
a duplicate event hits an order
already past the expected state
and is silently skipped.
+ H->>Agg: MarkAsPaid() /
MarkAsPaymentFailed() /
MarkAsShipped()
+ Note over Agg: AGGREGATE invariant —
throws InvalidOperationException
on invalid transition.
Now unreachable in normal flow
because the handler pre-check
filtered duplicates upstream —
throws would indicate a real bug
(true out-of-order arrival).
+ Agg-->>H: void
+
+ H->>Repo: UpdateAsync(order, ct)
+ Repo->>DB: UPDATE Orders
SET ..., RowVersion = NEW
WHERE Id = @id AND RowVersion = @v
+ alt RowVersion matches
+ DB-->>H: 1 row affected (tx commit)
+ else concurrency conflict
+ DB-->>H: 0 rows → DbUpdateConcurrencyException
+ Note over H: AddConcurrencyRetry policy —
3× retry with backoff,
then DLQ
+ end
+ end
+```
+
+**The three saga handlers all sit in [`Features/`](../../OrderService/Features):**
+
+| Event consumed | Handler | State transition | Aggregate method |
+|---|---|---|---|
+| `PaymentCompletedEvent` | [PaymentCompletedHandler.cs](../../OrderService/Features/PaymentCompletedHandler.cs) | `Placed → Paid` | `Order.MarkAsPaid()` |
+| `PaymentFailedEvent` | [PaymentFailedHandler.cs](../../OrderService/Features/PaymentFailedHandler.cs) | `Placed → PaymentFailed` | `Order.MarkAsPaymentFailed()` |
+| `ShipmentDispatchedEvent` | [ShipmentDispatchedHandler.cs](../../OrderService/Features/ShipmentDispatchedHandler.cs) | `Paid → Shipped` | `Order.MarkAsShipped()` |
+
+---
+
+## Order aggregate — state machine
+
+The state machine has **two enforcement layers, each with a different job**. The aggregate methods (`MarkAsPaid`, `MarkAsPaymentFailed`, `MarkAsShipped`) **throw `InvalidOperationException` on any invalid transition** — they're invariant guards, not idempotency guards. The handlers (`PaymentCompletedHandler`, `PaymentFailedHandler`, `ShipmentDispatchedHandler`) **pre-check the aggregate's `Status` and return early if it doesn't match the expected source state** — that's the idempotency layer. A duplicate `PaymentCompletedEvent` hits a handler whose pre-check sees `Status = Paid` (already transitioned) and returns silently; the aggregate's throw is unreachable on the duplicate path. A truly out-of-order event — e.g. `ShipmentDispatchedEvent` arriving before `PaymentCompletedEvent` — would skip the pre-check (the order is in `Placed`, not the expected `Paid`) and also no-op at the handler; the aggregate's throw is the backstop for the case where the handler logic itself is buggy and forgets the pre-check.
+
+```mermaid
+stateDiagram-v2
+ [*] --> Placed: Order.Create()
(PlaceOrderHandler)
+ Placed --> Paid: MarkAsPaid()
(PaymentCompletedHandler)
+ Placed --> PaymentFailed: MarkAsPaymentFailed()
(PaymentFailedHandler)
+ Paid --> Shipped: MarkAsShipped()
(ShipmentDispatchedHandler)
+ Shipped --> [*]
+ PaymentFailed --> [*]
+
+ note right of Placed
+ Initial state on
+ successful PlaceOrder
+ end note
+
+ note right of Paid
+ Awaiting shipment
+ from ShippingService
+ end note
+```
+
+---
+
+## Read-path coexistence (CQRS data-access split)
+
+The same [`IOrderRepository`](../../OrderService/Domain/IOrderRepository.cs) interface carries **both** write-loader methods (used by the sagas above) and DTO-returning read-projection methods (used by the GET endpoints). The split is part of the project's [CQRS data-access rule](../cqrs-data-access.md) — read paths project to DTOs inside the IQueryable to skip entity materialization and avoid parent-cartesian rows from collection includes.
+
+```mermaid
+graph LR
+ subgraph Domain["Domain/IOrderRepository.cs"]
+ I["interface IOrderRepository"]
+ end
+
+ subgraph Impl["Infrastructure/OrderRepository.cs"]
+ WL1["GetByIdAsync → Order
(tracked, Include Lines)"]
+ WL2["AddAsync, UpdateAsync"]
+ RP1["GetSummaryByIdAsync → OrderSummaryDto
(AsNoTracking + projection)"]
+ RP2["GetSummariesByBuyerIdAsync → IReadOnlyList<OrderSummaryDto>
(AsNoTracking + projection)"]
+ end
+
+ subgraph Writers["Saga + command handlers"]
+ SH["PaymentCompletedHandler
PaymentFailedHandler
ShipmentDispatchedHandler
PlaceOrderHandler"]
+ end
+
+ subgraph Readers["Query handlers"]
+ GH["GetOrderByIdHandler
GetOrdersByBuyerHandler"]
+ end
+
+ I --> WL1
+ I --> WL2
+ I --> RP1
+ I --> RP2
+
+ SH -.->|tracked entity| WL1
+ SH -.-> WL2
+ GH -.->|DTO directly| RP1
+ GH -.-> RP2
+
+ style WL1 fill:#dbeafe,stroke:#1e3a5f
+ style WL2 fill:#dbeafe,stroke:#1e3a5f
+ style RP1 fill:#a7f3d0,stroke:#047857
+ style RP2 fill:#a7f3d0,stroke:#047857
+```
+
+The method signature is the contract: anything returning a domain entity is a write loader; anything returning a DTO is a read projection. Mixing the two is the anti-pattern the rule exists to prevent.
+
+---
+
+## File inventory
+
+| Path | Purpose |
+|---|---|
+| [Endpoints/OrderEndpoints.cs](../../OrderService/Endpoints/OrderEndpoints.cs) | HTTP surface: POST/GET buyer-scoped, defense-in-depth JWT check |
+| [Features/PlaceOrder.cs](../../OrderService/Features/PlaceOrder.cs) | Command + validator + handler (the entry to the saga) |
+| [Features/GetOrderById.cs](../../OrderService/Features/GetOrderById.cs) | Single-order read; delegates to read-projection method |
+| [Features/GetOrdersByBuyer.cs](../../OrderService/Features/GetOrdersByBuyer.cs) | Paginated buyer history; delegates to read-projection method |
+| [Features/PaymentCompletedHandler.cs](../../OrderService/Features/PaymentCompletedHandler.cs) | Saga step 2a: payment succeeded → mark paid |
+| [Features/PaymentFailedHandler.cs](../../OrderService/Features/PaymentFailedHandler.cs) | Saga step 2b: payment failed → mark failed |
+| [Features/ShipmentDispatchedHandler.cs](../../OrderService/Features/ShipmentDispatchedHandler.cs) | Saga step 3: shipment dispatched → mark shipped |
+| [Domain/Order.cs](../../OrderService/Domain/Order.cs) | Aggregate root + state transitions + invariants |
+| [Domain/OrderLine.cs](../../OrderService/Domain/OrderLine.cs) | Line-item entity, owned by Order |
+| [Domain/OrderStatus.cs](../../OrderService/Domain/OrderStatus.cs) | Enum: Placed / Paid / PaymentFailed / Shipped |
+| [Domain/IOrderRepository.cs](../../OrderService/Domain/IOrderRepository.cs) | Repository port (write loaders + read projections) |
+| [Domain/ICatalogClient.cs](../../OrderService/Domain/ICatalogClient.cs) | gRPC client port (substituted in tests) |
+| [Domain/IEventPublisher.cs](../../OrderService/Domain/IEventPublisher.cs) | Event publish port (Wolverine implementation) |
+| [Infrastructure/OrderRepository.cs](../../OrderService/Infrastructure/OrderRepository.cs) | EF Core repository (write loaders + read projections) |
+| [Infrastructure/GrpcCatalogClient.cs](../../OrderService/Infrastructure/GrpcCatalogClient.cs) | gRPC adapter to CatalogService |
+| [Infrastructure/WolverineEventPublisher.cs](../../OrderService/Infrastructure/WolverineEventPublisher.cs) | Wolverine `IMessageBus.PublishAsync` adapter |
+| [Infrastructure/Data/OrderDbContext.cs](../../OrderService/Infrastructure/Data/OrderDbContext.cs) | EF Core context; SQL Server `RowVersion` concurrency token |
+| [Program.cs](../../OrderService/Program.cs) | Composition root: Wolverine + EF + auth + transports |
+
+---
+
+## Open questions
+
+**Per-aggregate ordering is handled via handler-level status checks + aggregate-level invariant throws + RowVersion retry, not via bus-level sessions.** Wolverine consumers on the same subscription compete, so two events for the same `OrderId` *can* be processed simultaneously by different replicas. Our defense is layered: each handler pre-checks `Status` and returns early on duplicate (idempotency); the aggregate's `MarkAsX` methods throw on invalid transitions (invariant); the `RowVersion` token rejects the stale writer (`DbUpdateConcurrencyException`); Wolverine's `AddConcurrencyRetry` policy retries 3× with backoff against the now-fresh state; the message lands in the DLQ only if all retries fail. That works in principle, and matches the "model the workflow, don't fight the queue" pattern from [Milan Jovanović's *Solving message ordering from first principles*](https://www.milanjovanovic.tech/blog/solving-message-ordering-from-first-principles). The alternative — Azure Service Bus sessions keyed on `OrderId`, with Wolverine's session-aware consumers — would give us a hard ordering guarantee but doesn't replace any of the above (sessions fix ordering, not duplicate delivery), so it's additive insurance rather than a replacement.
+
+**The validation is undertested.** Our integration tests each create their own order, so the *concurrent same-aggregate* path the post warns about ("a subtle bug that only appears under load") is exactly the path with zero coverage. Two cheap things would change that without committing to bus sessions: (1) an integration test that fires `PaymentCompletedEvent` and `ShipmentDispatchedEvent` against the same `Order` simultaneously and asserts the final state lands at `Shipped` (not `PaymentFailed` or stuck at `Placed`); (2) a `payments_concurrency_retries_exhausted` / `orders_concurrency_retries_exhausted` counter so DLQ-bound retry exhaustion is observable in production, not invisible. If those metrics stay near zero, the state-guard pattern is validated and bus sessions are unnecessary. If they spike, that's the trigger to add sessions — evidence-driven, not architecture-astronaut-driven. There's no Inbox pattern (processed-message-ID table) today either; state guards catch most duplicates because aggregates have few valid transitions, but a proper Inbox would catch any duplicate before it reaches the handler. Add it if duplicates start appearing outside the state-guard-protected windows.
+
+---
+
+## See also
+
+- [docs/architecture.md](../architecture.md) — system-level view
+- [docs/cqrs-data-access.md](../cqrs-data-access.md) — read/write split rule
+- [docs/transactional-outbox.svg](../transactional-outbox.svg) — outbox mechanics diagram
+- [docs/event-catalog.md](../event-catalog.md) — every event's shape and producer/consumer
+- [Milan Jovanović — *Solving message ordering from first principles*](https://www.milanjovanovic.tech/blog/solving-message-ordering-from-first-principles) — external; the source of the "Open questions" framing above
diff --git a/docs/code-flows/paymentservice.md b/docs/code-flows/paymentservice.md
new file mode 100644
index 00000000..1863a277
--- /dev/null
+++ b/docs/code-flows/paymentservice.md
@@ -0,0 +1,229 @@
+# PaymentService — code flow walkthrough
+
+> **What this is.** A walk through the code paths a new contributor will hit first in [PaymentService](../../PaymentService/). PaymentService is the **saga middle step** — it receives `OrderPlacedEvent` from OrderService over Service Bus, charges via a payment gateway (Stripe), and publishes `PaymentCompletedEvent` or `PaymentFailedEvent`. A `BackgroundService` recovery sweeper handles the "stuck in Pending" case where the gateway hangs.
+>
+> **Architecture style:** Vertical Slice Architecture (single csproj). Folders: [`Endpoints/`](../../PaymentService/Endpoints), [`Features/`](../../PaymentService/Features), [`Domain/`](../../PaymentService/Domain), [`Infrastructure/`](../../PaymentService/Infrastructure). Composition root: [`Program.cs`](../../PaymentService/Program.cs).
+>
+> **Three flows to understand:**
+> 1. **Saga consume + cascade** — `OrderPlacedEvent` → tiny translator → `ProcessPaymentCommand` → handler charges gateway → publishes outcome event.
+> 2. **HTTP admin path** — same `ProcessPaymentCommand` reachable from `POST /api/v1/payments/process` for manual processing.
+> 3. **`PaymentRecoveryJob`** — periodic sweeper that catches Pending payments stuck past the stale threshold, using `ExecuteInTransactionAsync` to keep entity write + outbox event atomic outside the Wolverine pipeline.
+
+---
+
+## Flow 1 — Saga: `OrderPlacedEvent` → process payment → publish outcome
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant ASB1 as Azure Service Bus
(orders topic)
+ participant W1 as Wolverine consumer +
ContextPropagation middleware
+ participant OPH as OrderPlacedHandler
Features/OrderPlacedHandler.cs
(static, returns command)
+ participant Val as ProcessPaymentCommandValidator
Features/ProcessPayment.cs
(FluentValidation)
+ participant H as ProcessPaymentHandler
Features/ProcessPayment.cs
+ participant Repo as IPaymentRepository
Infrastructure/PaymentRepository.cs
+ participant Agg as Payment aggregate
Domain/Payment.cs
+ participant GW as IPaymentGateway
Infrastructure/Gateway/
StripePaymentGateway.cs
+ participant Pub as IEventPublisher
Infrastructure/WolverineEventPublisher.cs
+ participant DB as SQL Server +
wolverine.outgoing_envelopes
+ participant ASB2 as Azure Service Bus
(payments topic)
+
+ ASB1->>W1: OrderPlacedEvent
+ Note over W1: restores logger scope from
envelope headers (CorrelationId,
UserId, SessionId)
+ W1->>OPH: Handle(@event)
+ OPH-->>W1: returns ProcessPaymentCommand
(Wolverine cascading message —
no IMessageBus call needed)
+ Note over W1: same Wolverine pipeline,
same DB tx wraps this step too
+ W1->>Val: validate command
+ Val-->>W1: ok
+ W1->>H: HandleAsync(command, ct)
(AutoApplyTransactions wraps)
+
+ H->>Repo: GetByOrderIdAsync(orderId, ct)
+ Repo->>DB: SELECT * FROM payments
WHERE order_id = @id (tracked)
+ DB-->>H: Payment (tracked) or null
+
+ alt existing payment found — idempotency
+ H-->>W1: existing.Id (early return)
+ Note over H: at-least-once delivery,
DLQ replays, double admin POSTs —
all no-op here
+ else no existing — create new
+ H->>Agg: Payment.Create(orderId, buyerId,
amount, currency, "Stripe")
+ H->>Repo: AddAsync(payment, ct)
+ Repo->>DB: INSERT payments (status=Pending)
+
+ H->>GW: ProcessPaymentAsync(amount, currency, ct)
+ GW-->>H: GatewayResult { Success, TransactionId or ErrorMessage }
+
+ alt Success
+ H->>Agg: MarkAsCompleted(transactionId)
+ Note over Agg: throws if status != Pending
(state guard prevents
double-completion)
+ H->>Repo: UpdateAsync(payment, ct)
+ H->>Pub: PublishAsync(PaymentCompletedEvent)
+ else Failed
+ H->>Agg: MarkAsFailed(errorMessage)
+ H->>Repo: UpdateAsync(payment, ct)
+ H->>Pub: PublishAsync(PaymentFailedEvent)
+ Note over Pub: error message kept verbatim for
OrderService's audit trail —
never returned to clients
+ end
+
+ Note over Pub,DB: Wolverine stages envelope into
wolverine.outgoing_envelopes
(same tx as entity write)
+ DB-->>H: tx commit
+ DB->>ASB2: dispatched to ASB
(payments topic)
+ end
+```
+
+**Why two handlers (`OrderPlacedHandler` + `ProcessPaymentHandler`).** The event handler is a 2-line translator that converts an event into a command and returns it. Wolverine's "cascading messages" feature picks up the return value and runs its handler next — the same `ProcessPaymentHandler` reached from the HTTP admin endpoint. One business rule, multiple entry points.
+
+**Idempotency via existence check + unique index.** `GetByOrderIdAsync` short-circuits on existing rows for retry/redelivery scenarios. The unique index on `OrderId` in [PaymentDbContext](../../PaymentService/Infrastructure/Data/PaymentDbContext.cs) is the DB backstop if two redeliveries race past the check at the same instant.
+
+---
+
+## Flow 2 — HTTP `POST /api/v1/payments/process` (admin path)
+
+Same handler, same outcome — different entry point.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Admin
+ participant EP as PaymentEndpoints
Endpoints/PaymentEndpoints.cs
+ participant Bus as IMessageBus
+ participant H as ProcessPaymentHandler
Features/ProcessPayment.cs
+
+ Admin->>EP: POST /api/v1/payments/process
{ OrderId, Amount, Currency, BuyerId }
+ EP->>Bus: bus.InvokeAsync(command, ct)
+ Bus->>H: same handler as Flow 1
+ Note over H: idempotency check + gateway call +
state transition + event publish
(identical to Flow 1)
+ H-->>Bus: payment.Id
+ Bus-->>EP: payment.Id
+ EP-->>Admin: 200 OK + { Id }
+```
+
+The admin endpoint matters because it's how an operator manually triggers a payment when the saga is unavailable (e.g. Service Bus outage). Same code path; same outbox guarantees.
+
+---
+
+## Flow 3 — `PaymentRecoveryJob` (BackgroundService sweeper)
+
+A Pending payment stuck past the stale threshold (default ~5 min — see [PaymentRecoveryOptions](../../PaymentService/Infrastructure/PaymentRecoveryOptions.cs)) is what you get when the gateway call hangs and the process dies mid-transaction. The next pod restart can't recover via the saga because `ProcessPaymentHandler`'s existence check would no-op on the half-written row. The sweeper handles these.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Tick as PaymentRecoveryJob
Infrastructure/PaymentRecoveryJob.cs
(BackgroundService loop)
+ participant Lock as DistributedLock.SqlServer
(sp_getapplock)
+ participant Scope as fresh IServiceScope
per iteration
+ participant Repo as IPaymentRepository
+ participant Agg as Payment aggregate
+ participant Pub as IEventPublisher
+ participant DB as SQL Server +
wolverine.outgoing_envelopes
+ participant ASB as Azure Service Bus
+
+ loop every SweepInterval (default 60s)
+ Tick->>Lock: TryAcquireLockAsync("payments-recovery",
TimeSpan.Zero)
+ alt another replica holds it
+ Lock-->>Tick: null → skip this tick
+ else acquired
+ Tick->>Scope: create fresh DI scope
(NOT reused across iterations —
change-tracker stays small)
+ Scope->>Repo: GetStalePendingPaymentIdsAsync(threshold)
+ Repo->>DB: SELECT id FROM payments
WHERE status = Pending
AND created_at < @threshold
+ DB-->>Repo: Guid[]
+
+ loop foreach stale id
+ Tick->>Repo: GetByIdAsync(id)
+ Repo->>DB: SELECT (tracked) + RowVersion
+ DB-->>Tick: Payment
+
+ alt status != Pending — race already resolved
+ Note over Tick: another sweeper iteration
or ProcessPayment completed
between query and load
+ else still Pending
+ Note over Tick,DB: ExecuteInTransactionAsync wraps —
SAME tx for entity write + envelope.
BackgroundService runs OUTSIDE
Wolverine's handler pipeline so
AutoApplyTransactions does NOT apply
here — must wrap manually.
+ Tick->>Agg: MarkAsFailed("timed out — recovery sweep")
+ Tick->>Repo: UpdateAsync(payment, txCt)
+ Tick->>Pub: PublishAsync(PaymentFailedEvent, txCt)
+ Note over Pub: SaveChangesAsync inside the wrapper
flushes Wolverine's staged envelope
BEFORE commit. Without it, envelope
never reaches outgoing_envelopes —
event silently dropped, saga stalls.
+ DB-->>Tick: tx commit (both rows or neither)
+ DB->>ASB: PaymentFailedEvent dispatched
+ end
+
+ alt DbUpdateConcurrencyException
+ Note over Tick: another process won the
RowVersion race. Roll back —
no further action needed.
+ end
+ end
+ end
+ end
+```
+
+**The outbox-outside-handler trap.** Wolverine's `AutoApplyTransactions` policy wraps **handler chains** — anything dispatched through `IMessageBus.InvokeAsync`/`PublishAsync` into a handler gets the wrap automatically. That includes the admin path in Flow 2 (`POST /payments/process` → `bus.InvokeAsync(command)` → `ProcessPaymentHandler`) — it's still inside the Wolverine pipeline, so the wrap applies. The trap is code that publishes events **without entering a handler at all**: `BackgroundService` sweep loops, cron jobs, or any future code path that does `bus.PublishAsync(@event)` outside an active Wolverine handler context. `PublishAsync` stages an envelope into the in-memory tracker, but the envelope is only persisted to `wolverine.outgoing_envelopes` when `SaveChangesAsync` runs after the publish. The canonical safe wrapper:
+
+```csharp
+public async Task ExecuteInTransactionAsync(Func work, CancellationToken ct = default)
+{
+ await using var tx = await context.Database.BeginTransactionAsync(ct);
+ await work(ct); // entity write + PublishAsync inside here
+ await context.SaveChangesAsync(ct); // flushes Wolverine's staged envelope
+ await tx.CommitAsync(ct);
+}
+```
+
+See [`PaymentRepository.ExecuteInTransactionAsync`](../../PaymentService/Infrastructure/PaymentRepository.cs) and the rationale in [docs/performance-and-data-correctness.md](../performance-and-data-correctness.md). This rule covers any future non-handler code that publishes events.
+
+**Distributed lock.** Multiple PaymentService replicas could each fire their sweep tick at the same second. The `sp_getapplock`-based distributed lock ensures only one replica processes the sweep per tick. `TimeSpan.Zero` (no-wait) means replicas that don't acquire just skip the iteration — they'll try again at the next tick.
+
+**`TimeProvider` injection.** Tests inject `FakeTimeProvider` to advance virtual time and exercise the stale-threshold logic deterministically. The sweep loop never reads `DateTime.UtcNow` directly.
+
+---
+
+## Payment aggregate — state machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> Pending: Payment.Create()
(ProcessPaymentHandler)
+ Pending --> Completed: MarkAsCompleted(transactionId)
(gateway succeeded)
+ Pending --> Failed: MarkAsFailed(reason)
(gateway failed OR
recovery sweep timeout)
+ Completed --> [*]
+ Failed --> [*]
+
+ note right of Pending
+ Initial state after AddAsync
+ commits, BEFORE gateway call
+ end note
+
+ note right of Failed
+ Two callers reach Failed —
+ ProcessPaymentHandler (sync)
+ and PaymentRecoveryJob
+ (async sweep)
+ end note
+```
+
+**Idempotency by throw (not no-op).** Unlike OrderService's `Order.MarkAsX` methods (which no-op on duplicate transitions), `Payment.MarkAsCompleted` and `MarkAsFailed` **throw `InvalidOperationException`** if status is not `Pending`. The reason: PaymentService's idempotency lives at the *handler* level (existence check on `OrderId`), not the aggregate level. By the time you're calling `MarkAsX` here, you've already loaded a freshly-created `Pending` payment in the current request — a non-Pending status means a serious bug, not a duplicate event.
+
+---
+
+## File inventory
+
+| Path | Purpose |
+|---|---|
+| [Endpoints/PaymentEndpoints.cs](../../PaymentService/Endpoints/PaymentEndpoints.cs) | HTTP admin path: `POST /api/v1/payments/process` |
+| [Features/OrderPlacedHandler.cs](../../PaymentService/Features/OrderPlacedHandler.cs) | Static event translator (Wolverine cascading message) |
+| [Features/ProcessPayment.cs](../../PaymentService/Features/ProcessPayment.cs) | Command + validator + handler (idempotency + gateway + state + publish) |
+| [Domain/Payment.cs](../../PaymentService/Domain/Payment.cs) | Aggregate root + state guards (throw on bad transition) |
+| [Domain/PaymentStatus.cs](../../PaymentService/Domain/PaymentStatus.cs) | Enum: Pending / Completed / Failed |
+| [Domain/IPaymentRepository.cs](../../PaymentService/Domain/IPaymentRepository.cs) | Repository port — write loaders + `ExecuteInTransactionAsync` wrapper |
+| [Domain/IPaymentGateway.cs](../../PaymentService/Domain/IPaymentGateway.cs) | Anti-corruption layer port — substituted in tests |
+| [Domain/IEventPublisher.cs](../../PaymentService/Domain/IEventPublisher.cs) | Event publish port (Wolverine impl) |
+| [Infrastructure/PaymentRepository.cs](../../PaymentService/Infrastructure/PaymentRepository.cs) | EF impl + `ExecuteInTransactionAsync` (outbox-atomic wrapper) |
+| [Infrastructure/Gateway/StripePaymentGateway.cs](../../PaymentService/Infrastructure/Gateway/StripePaymentGateway.cs) | Stripe adapter — translates SDK exceptions into `GatewayResult` |
+| [Infrastructure/WolverineEventPublisher.cs](../../PaymentService/Infrastructure/WolverineEventPublisher.cs) | `IMessageBus.PublishAsync` adapter |
+| [Infrastructure/PaymentRecoveryJob.cs](../../PaymentService/Infrastructure/PaymentRecoveryJob.cs) | `BackgroundService` sweep loop + distributed lock + per-iteration scope |
+| [Infrastructure/PaymentRecoveryOptions.cs](../../PaymentService/Infrastructure/PaymentRecoveryOptions.cs) | Config record: SweepInterval, StaleThreshold, LockName |
+| [Infrastructure/Data/PaymentDbContext.cs](../../PaymentService/Infrastructure/Data/PaymentDbContext.cs) | EF context — SQL Server `RowVersion` token, unique index on `OrderId` |
+| [Program.cs](../../PaymentService/Program.cs) | Composition root — Wolverine + EF + `BackgroundService` registration |
+
+---
+
+## See also
+
+- [docs/code-flows/orderservice.md](orderservice.md) — OrderService publishes `OrderPlacedEvent` (Flow 1's input) and consumes `PaymentCompletedEvent`/`PaymentFailedEvent` (Flow 1's output)
+- [docs/transactional-outbox.svg](../transactional-outbox.svg) — diagram of outbox mechanics; the recovery job's `ExecuteInTransactionAsync` is the non-handler variant
+- [docs/performance-and-data-correctness.md](../performance-and-data-correctness.md) — full perf rationale incl. outbox + sweeper patterns
+- [docs/event-catalog.md](../event-catalog.md) — every event's shape and producer/consumer
diff --git a/docs/code-flows/shippingservice.md b/docs/code-flows/shippingservice.md
new file mode 100644
index 00000000..ec7c12b6
--- /dev/null
+++ b/docs/code-flows/shippingservice.md
@@ -0,0 +1,207 @@
+# ShippingService — code flow walkthrough
+
+> **What this is.** A walk through the code paths a new contributor will hit first in [ShippingService](../../ShippingService/). ShippingService is the **saga last step** — it receives `PaymentCompletedEvent` from PaymentService over Service Bus, creates a Shipment + immediately dispatches it (simulation), and publishes `ShipmentDispatchedEvent` for OrderService and NotificationService. Buyers can then query their shipment via a buyer-scoped HTTP endpoint with anti-enumeration IDOR protection.
+>
+> **Architecture style:** Vertical Slice Architecture (single csproj). Folders: [`Endpoints/`](../../ShippingService/Endpoints), [`Features/`](../../ShippingService/Features), [`Domain/`](../../ShippingService/Domain), [`Infrastructure/`](../../ShippingService/Infrastructure). Composition root: [`Program.cs`](../../ShippingService/Program.cs).
+>
+> **Two flows to understand:**
+> 1. **`PaymentCompletedEvent` consume + cascade** — translator returns `CreateShipmentCommand`, handler creates & dispatches the shipment, publishes `ShipmentDispatchedEvent`.
+> 2. **`GET /shipments/order/{orderId}`** — buyer-scoped read with the canonical null → 404 IDOR pattern (see CLAUDE.md "Security Requirements").
+
+---
+
+## Flow 1 — Saga: `PaymentCompletedEvent` → create shipment → publish dispatched
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant ASB1 as Azure Service Bus
(payments topic)
+ participant W as Wolverine consumer +
ContextPropagation middleware
+ participant PCH as PaymentCompletedHandler
Features/PaymentCompletedHandler.cs
(static, returns command)
+ participant H as CreateShipmentHandler
Features/CreateShipment.cs
+ participant Repo as IShipmentRepository
Infrastructure/ShipmentRepository.cs
+ participant Agg as Shipment aggregate
Domain/Shipment.cs
+ participant Pub as IEventPublisher
Infrastructure/WolverineEventPublisher.cs
+ participant DB as Postgres +
wolverine.outgoing_envelopes
+ participant ASB2 as Azure Service Bus
(shipping topic)
+
+ ASB1->>W: PaymentCompletedEvent
+ Note over W: restores logger scope from
envelope headers (CorrelationId,
UserId, SessionId)
+ W->>PCH: Handle(@event)
+ PCH-->>W: returns CreateShipmentCommand
(Wolverine cascading message —
no IMessageBus call needed)
+ W->>H: HandleAsync(command, ct)
(AutoApplyTransactions wraps)
+
+ H->>Repo: GetByOrderIdAsync(orderId, ct)
+ Repo->>DB: SELECT * FROM shipments
WHERE order_id = @id (tracked)
+ Include TrackingEvents
+ DB-->>H: Shipment or null
+
+ alt existing shipment found — idempotency
+ H-->>W: existing.Id (early return)
+ Note over H: PaymentCompletedEvent redelivery,
DLQ replay, or saga rerun —
all no-op here. Unique index on
OrderId is the DB-level backstop.
+ else no existing — create + dispatch
+ Note over H: random carrier pick (sim only):
FedEx / UPS / USPS / DHL
+ H->>Agg: Shipment.Create(orderId, buyerId, carrier)
+ Note over Agg: status = Created
tracking number generated locally
(NVC-XXXXXXXX prefix —
placeholder for carrier API)
+ H->>Agg: shipment.Dispatch()
+ Note over Agg: state guard —
throws if status != Created.
Status → Dispatched.
Auto-adds TrackingEvent
("Package dispatched")
+ H->>Repo: AddAsync(shipment, ct)
+ Repo->>DB: INSERT shipments
+ INSERT tracking_events
+ H->>Pub: PublishAsync(ShipmentDispatchedEvent)
+ Note over Pub,DB: Wolverine stages envelope into
wolverine.outgoing_envelopes
(same DB tx as entity writes)
+ DB-->>H: tx commit
+ DB->>ASB2: ShipmentDispatchedEvent dispatched
+ H-->>W: shipment.Id
+ end
+```
+
+**Two domain operations in one handler.** `Shipment.Create()` (Created state) → `shipment.Dispatch()` (Created → Dispatched). Both before `AddAsync` so a single `SaveChanges` captures the full state transition. The `TrackingEvent` audit row is added inside `Dispatch()` so the audit trail is in place from the very first save.
+
+**Why a thin static event translator.** Same Wolverine "cascading messages" pattern PaymentService uses for `OrderPlacedHandler` — the event handler returns a command, Wolverine invokes the command's handler next. The command handler is also reachable from any future direct trigger (admin endpoint, manual replay) without duplicating logic.
+
+---
+
+## Flow 2 — `GET /api/v1/shipments/order/{orderId}` (IDOR-protected read)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Buyer
+ participant EP as ShippingEndpoints
Endpoints/ShippingEndpoints.cs
+ participant Bus as IMessageBus
+ participant H as GetShipmentByOrderHandler
Features/GetShipmentByOrder.cs
+ participant Repo as IShipmentRepository
(read projection method)
+ participant DB as Postgres
+
+ Buyer->>EP: GET /api/v1/shipments/order/{orderId}
+ Note over EP: extract ClaimTypes.NameIdentifier
from JWT → RequestingBuyerId
passed into the query
(NEVER trusted from URL/body)
+ EP->>Bus: bus.InvokeAsync(
GetShipmentByOrderQuery(orderId,
requestingBuyerId), ct)
+ Bus->>H: HandleAsync(query, ct)
+
+ H->>Repo: GetSummaryByOrderIdAsync(orderId, ct)
+ Repo->>DB: SELECT id, order_id, buyer_id,
carrier, tracking_number, status,
created_at, dispatched_at,
tracking_events (projected)
FROM shipments WHERE order_id = @id
(AsNoTracking + .Select to ShipmentDto)
+ DB-->>Repo: ShipmentDto or null
+ Repo-->>H: ShipmentDto?
+
+ alt shipment is null
+ H-->>EP: null
+ EP-->>Buyer: 404 Not Found
+ else shipment exists — IDOR ownership check
+ Note over H: shipment.BuyerId == requestingBuyerId?
+ alt mismatch — different buyer
+ H-->>EP: null
(NOT throw, NOT 403)
+ Note over H: indistinguishable from
"shipment not found" —
anti-enumeration property
(CLAUDE.md Security Requirements)
+ EP-->>Buyer: 404 Not Found
+ else buyer is owner
+ H-->>EP: ShipmentDto
+ EP-->>Buyer: 200 OK + ShipmentDto
(includes TrackingEventDto[])
+ end
+ end
+```
+
+**Why null → 404 instead of throw → 403.** Returning 403 on owner mismatch tells an attacker "this shipment exists, just not yours" — they can enumerate the order-ID space. 404 is indistinguishable from "no shipment for this order." The canonical IDOR pattern in [CLAUDE.md "Security Requirements"](../../CLAUDE.md) names this exact endpoint as a reference template. The pattern requires three things at once:
+1. Endpoint reads `ClaimTypes.NameIdentifier` from the JWT and passes it as `RequestingBuyerId` into the query (caller can't lie about identity in the URL).
+2. Handler returns `null` on owner mismatch (NOT throws).
+3. Endpoint translates `null` to 404 (NOT 403).
+
+**Why the ownership check lives on the DTO, not the entity.** The projection-in-EF read path (`GetSummaryByOrderIdAsync`) never materializes a `Shipment` entity — it `.Select`s directly into `ShipmentDto`. The DTO carries `BuyerId` precisely so this check can happen on the projection without an entity hop. See [docs/cqrs-data-access.md](../cqrs-data-access.md) for the read/write split rule.
+
+**Denormalized `BuyerId` on Shipment.** The buyer ID flows through the saga: `OrderPlacedEvent` → `Payment` → `PaymentCompletedEvent` → `CreateShipmentCommand` → `Shipment.BuyerId`. Denormalizing it onto Shipment means the IDOR check is one column comparison, not a join across services. The trade-off: the data is duplicated.
+
+---
+
+## Shipment aggregate — state machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> Created: Shipment.Create()
(CreateShipmentHandler)
+ Created --> Dispatched: Dispatch()
(adds TrackingEvent)
+ Dispatched --> Delivered: MarkDelivered()
(future — see Domain/Shipment.cs)
+ Dispatched --> [*]
+ Delivered --> [*]
+
+ note right of Created
+ Only exists briefly inside
+ CreateShipmentHandler — Create()
+ and Dispatch() both run before
+ the first SaveChanges, so persisted
+ rows are always Dispatched or later.
+ end note
+
+ note right of Delivered
+ Currently schema-only —
+ no handler yet writes this
+ transition (see Domain/Shipment.cs
+ DeliveredAt setter comment).
+ end note
+```
+
+**Idempotency by throw.** `Shipment.Dispatch()` throws `InvalidOperationException` if status isn't `Created` — same pattern as `Payment.MarkAsX`. Because `CreateShipmentHandler` does the existence check (`GetByOrderIdAsync`) before reaching `Dispatch()`, hitting `Dispatch()` on a non-Created shipment means a serious bug, not a duplicate event. At-least-once delivery is handled at the handler level via the existence check, not at the aggregate level via a no-op.
+
+---
+
+## Read/write data-access split
+
+ShippingService uses the same VSA-variant read/write split as OrderService: write loaders + read projections live on the same `IShipmentRepository` interface (legal because there's no separate Domain project; the `Domain/` folder is in the same csproj as `Features/` and can reference Contracts).
+
+```mermaid
+graph LR
+ subgraph Domain["Domain/IShipmentRepository.cs"]
+ I["interface IShipmentRepository"]
+ end
+
+ subgraph Impl["Infrastructure/ShipmentRepository.cs"]
+ WL1["GetByOrderIdAsync → Shipment
(tracked, Include TrackingEvents)"]
+ WL2["AddAsync, UpdateAsync"]
+ RP1["GetSummaryByOrderIdAsync → ShipmentDto
(AsNoTracking + projection)"]
+ end
+
+ subgraph Writers["CreateShipmentHandler"]
+ SW["existence check via WL1
+ AddAsync via WL2"]
+ end
+
+ subgraph Readers["GetShipmentByOrderHandler"]
+ SR["IDOR-checked read via RP1"]
+ end
+
+ I --> WL1
+ I --> WL2
+ I --> RP1
+
+ SW -.->|tracked entity| WL1
+ SW -.-> WL2
+ SR -.->|DTO directly| RP1
+
+ style WL1 fill:#dbeafe,stroke:#1e3a5f
+ style WL2 fill:#dbeafe,stroke:#1e3a5f
+ style RP1 fill:#a7f3d0,stroke:#047857
+```
+
+---
+
+## File inventory
+
+| Path | Purpose |
+|---|---|
+| [Endpoints/ShippingEndpoints.cs](../../ShippingService/Endpoints/ShippingEndpoints.cs) | HTTP surface: `GET /shipments/order/{orderId}` (only public route — shipments are created by the saga, not directly) |
+| [Features/PaymentCompletedHandler.cs](../../ShippingService/Features/PaymentCompletedHandler.cs) | Static event translator → `CreateShipmentCommand` (Wolverine cascading) |
+| [Features/CreateShipment.cs](../../ShippingService/Features/CreateShipment.cs) | Command + handler: existence check + create + dispatch + publish |
+| [Features/GetShipmentByOrder.cs](../../ShippingService/Features/GetShipmentByOrder.cs) | Read query + handler with IDOR null → 404 check on DTO |
+| [Domain/Shipment.cs](../../ShippingService/Domain/Shipment.cs) | Aggregate root + tracking-number generation + `Dispatch()` state guard |
+| [Domain/TrackingEvent.cs](../../ShippingService/Domain/TrackingEvent.cs) | Audit row owned by Shipment (1-to-many) |
+| [Domain/ShipmentStatus.cs](../../ShippingService/Domain/ShipmentStatus.cs) | Enum: Created / Dispatched / Delivered |
+| [Domain/IShipmentRepository.cs](../../ShippingService/Domain/IShipmentRepository.cs) | Repository port — write loaders + read projection |
+| [Domain/IEventPublisher.cs](../../ShippingService/Domain/IEventPublisher.cs) | Event publish port (Wolverine impl) |
+| [Infrastructure/ShipmentRepository.cs](../../ShippingService/Infrastructure/ShipmentRepository.cs) | EF impl — write loaders `Include` TrackingEvents; read projection `AsNoTracking + Select` |
+| [Infrastructure/WolverineEventPublisher.cs](../../ShippingService/Infrastructure/WolverineEventPublisher.cs) | `IMessageBus.PublishAsync` adapter |
+| [Infrastructure/Data/ShippingDbContext.cs](../../ShippingService/Infrastructure/Data/ShippingDbContext.cs) | EF context — Postgres `xmin` concurrency token, unique index on `OrderId` |
+| [Program.cs](../../ShippingService/Program.cs) | Composition root — Wolverine + EF + auth + transports |
+
+---
+
+## See also
+
+- [docs/code-flows/paymentservice.md](paymentservice.md) — PaymentService publishes `PaymentCompletedEvent` (Flow 1's input)
+- [docs/code-flows/orderservice.md](orderservice.md) — OrderService consumes `ShipmentDispatchedEvent` (Flow 1's output)
+- [docs/cqrs-data-access.md](../cqrs-data-access.md) — read/write split rule (VSA variant — both methods on same interface)
+- [CLAUDE.md "Security Requirements"](../../CLAUDE.md) — IDOR / null → 404 canonical pattern (this endpoint is a named reference template)
+- [docs/event-catalog.md](../event-catalog.md) — every event's shape and producer/consumer