A course project that evolves an e-commerce order system from a monolith into production-style microservices, phase by phase.
- Phase roadmap
- System at a glance
- Phase 1 — Monolith (baseline, preserved)
- Repository structure
- Phase 2 — Microservices
- Phase 3 — API Gateway, BFF & Load Balancing
- Prerequisites
- Phase 4 — Async Messaging, Saga & Caching
- Phase 5 — Monitoring & Observability
- Author
- Phase 1 (done): a single .NET 8 WebAPI monolith + one SQL Server database.
- Phase 2 (done): split into 4 microservices with database-per-service and polyglot persistence.
- Phase 3 (done): API Gateway (YARP), a BFF, and load balancing (2 ProductCatalog replicas behind Nginx).
- Phase 4 (done): async order saga over RabbitMQ (choreography), happy + compensation paths, idempotent consumers, and Redis cache-aside for ProductCatalog reads.
- Phase 5 (current): monitoring & observability — structured logging
(Serilog) in every service, aggregated to Seq,
/healthendpoints wired into docker-compose healthchecks, and a Correlation ID that traces one order end-to-end across HTTP and the RabbitMQ saga.
CI/CD and the bonus phases are out of scope for now.
The current system (Phases 3–5): one API Gateway as the only public entry point, a BFF for aggregation, a load-balanced catalog, four services each owning their own database, an async RabbitMQ saga, and Seq collecting correlated logs from every hop.
flowchart TB
Client(["Client"])
subgraph Edge["Public edge — :8080"]
GW["API Gateway (YARP)"]
end
BFF["WebBffService (BFF)<br/>aggregation"]
subgraph CatalogLB["Catalog · load balanced"]
LB["catalog-lb (Nginx)"]
PC1["productcatalog #1"]
PC2["productcatalog #2"]
LB --> PC1
LB --> PC2
end
INV["InventoryService"]
ORD["OrderService"]
NOT["NotificationService"]
EX{{"RabbitMQ<br/>ecommerce.events"}}
SEQ[/"Seq · logs<br/>:5341"/]
Client -->|all traffic| GW
GW -->|/catalog| LB
GW -->|/inventory| INV
GW -->|/orders| ORD
GW -->|/notifications| NOT
GW -->|/bff| BFF
BFF -->|order| ORD
BFF -->|products| LB
ORD -. order.placed .-> EX
EX -. order.placed .-> INV
INV -. inventory.reserved/rejected .-> EX
EX -. inventory.* .-> ORD
ORD -. order.confirmed/rejected .-> EX
EX -. order.* .-> NOT
PC1 --> M[("MongoDB")]
PC2 --> M
INV --> P[("PostgreSQL")]
ORD --> S[("SQL Server")]
NOT --> R[("Redis")]
GW -.->|logs| SEQ
ORD -.->|logs| SEQ
INV -.->|logs| SEQ
NOT -.->|logs| SEQ
Legend: solid arrows = synchronous HTTP · dotted arrows = async RabbitMQ events · dashed → Seq = structured logs carrying a shared
X-Correlation-ID.
The original monolith lives in src/ECommerce.Monolith.Api/ and its compose file is docker-compose.phase1.yml. It is kept for "before vs. after" comparison. To run it on its own:
dotnet publish ./src/ECommerce.Monolith.Api/ECommerce.Monolith.Api.csproj -c Release -o ./src/ECommerce.Monolith.Api/publish
docker compose -f docker-compose.phase1.yml up --buildMonolith docs: docs/monolith-architecture.md.
docker-compose.yml # Phase 4: gateway + bff + nginx LB + RabbitMQ + 4 services + 4 DBs
docker-compose.phase1.yml # Phase 1 monolith (preserved)
publish-all.sh / .ps1 # publish all services before compose
infra/
catalog-lb/nginx.conf # Nginx load balancer for catalog replicas
src/
ECommerce.Monolith.Api/ # Phase 1 baseline
Shared.Messaging/ # Phase 4: RabbitMQ contracts + publish/consume helpers
# + Phase 5: CorrelationContext (survives broker hops)
Shared.Observability/ # Phase 5: Serilog/Seq setup, correlation middleware,
# HTTP propagation handler, health-probe
ProductCatalogService/ # MongoDB (2 replicas) + Redis cache-aside (Phase 4)
InventoryService/ # PostgreSQL + saga consumer (Phase 4)
OrderService/ # SQL Server + saga publisher/consumer (Phase 4)
NotificationService/ # Redis + saga consumer (Phase 4)
WebBffService/ # BFF (aggregation, no DB)
ApiGateway/ # YARP gateway (single entry point)
docs/
monolith-architecture.md
microservices-architecture.md # includes the Phase 3 + Phase 4 sections + diagrams
adr/ # one ADR per database choice
The four services and their databases from Phase 2 are unchanged. In Phase 3 their direct host ports are no longer exposed — reach them through the gateway (see the Phase 3 section above).
| Service | Responsibility | Database | Family | Gateway prefix |
|---|---|---|---|---|
| ProductCatalogService | products: create/list/get/update | MongoDB | document | /catalog |
| InventoryService | stock: get/update/reserve/release | PostgreSQL | relational | /inventory |
| OrderService | orders: place/list/get + orchestration | SQL Server | relational | /orders |
| NotificationService | record/"send" notifications | Redis | key-value | /notifications |
Each service owns its own database and never accesses another service's database — the only cross-service access is HTTP. Database design rationale is in the ADRs in docs/adr/; architecture details in docs/microservices-architecture.md.
Order placement (now also reachable via the gateway): OrderService validates each product against ProductCatalogService, reserves stock via InventoryService, persists a Confirmed/Rejected order, and records a notification via NotificationService.
| Component | URL / Port | Exposed to host? |
|---|---|---|
| API Gateway | http://localhost:8080 | Yes (only app entry) |
| RabbitMQ management UI | http://localhost:15672 (guest/guest) | Yes (dev) |
| Seq log aggregator (Phase 5) | http://localhost:5341 | Yes (dev) |
| ProductCatalogService (×2), Inventory, Order, Notification, BFF, catalog-lb | — | No (internal) |
| MongoDB / PostgreSQL / SQL Server / Redis / RabbitMQ-AMQP | 27017 / 5432 / 1433 / 6379 / 5672 | Yes (dev convenience) |
Everything is reached through the API Gateway at http://localhost:8080. The
individual services are no longer exposed to the host.
| Through the gateway | Goes to |
|---|---|
http://localhost:8080/catalog/api/products |
ProductCatalogService (via Nginx LB → 2 replicas) |
http://localhost:8080/inventory/api/inventory/{productId} |
InventoryService |
http://localhost:8080/orders/api/orders |
OrderService |
http://localhost:8080/notifications/api/notifications |
NotificationService |
http://localhost:8080/bff/api/order-details/{orderId} |
WebBffService (BFF) |
- Gateway (YARP): single entry point + generic, domain-agnostic routing (and future edge concerns like rate limiting). One path prefix → one service.
- BFF (WebBffService): client-specific aggregation —
order-detailscombines an order (OrderService) with each item's product (ProductCatalogService) into one response. This domain logic belongs in the BFF, not the gateway.
./publish-all.sh # or .\publish-all.ps1
docker compose up --build # starts gateway + bff + nginx LB + 4 services + 4 DBsdocker compose up honors deploy.replicas: 2 for ProductCatalogService. To use
more replicas: docker compose up -d --scale productcatalog=3.
# 1) create a product (note the returned id)
curl -X POST http://localhost:8080/catalog/api/products -H "Content-Type: application/json" \
-d '{"name":"Mechanical Keyboard","price":75.00,"category":"Accessories","isActive":true,"attributes":{"switch":"blue"}}'
# 2) set inventory (use the id)
curl -X PUT http://localhost:8080/inventory/api/inventory/<ID> -H "Content-Type: application/json" \
-d '{"quantityAvailable":20,"quantityReserved":0}'
# 3) place an order (use the id) -> note the order id
curl -X POST http://localhost:8080/orders/api/orders -H "Content-Type: application/json" \
-d '{"customerEmail":"buyer@example.com","items":[{"productId":"<ID>","quantity":2}]}'
# 4) BFF aggregated order details (order + live product data)
curl http://localhost:8080/bff/api/order-details/<ORDER_ID># Repeated calls alternate between the two replica container ids:
for i in $(seq 1 10); do curl -s http://localhost:8080/catalog/api/products/instance; echo; done
# Resilience: kill one replica, requests still succeed from the other:
docker stop project-ai-productcatalog-1
curl -s http://localhost:8080/catalog/api/products/instance
docker start project-ai-productcatalog-1- Gateway runs and is reachable at http://localhost:8080/health
- Client can access all APIs through the gateway (catalog/inventory/orders/notifications)
- Internal services still run (and are no longer exposed directly to the host)
- BFF aggregates order + product data at
/bff/api/order-details/{id} - ProductCatalogService runs 2+ replicas
- Load-balancing proof works (alternating
instanceId/X-Instance-Id) -
docker compose upruns everything from the root - README and architecture docs are updated
⚠️ Temporary build workaround. NuGet restore fails inside Docker on this machine (NU1301), so we publish each service on the host first and the Docker images only run the published output. Once Docker can reach NuGet again, the Dockerfiles can return to normal multi-stage builds.After any code change:
./publish-all.sh && docker compose up -d --build --force-recreate. To stop:docker compose down(add-vto delete the data volumes).
Order placement is now asynchronous. POST /orders returns a Pending
order immediately; the final status is decided by a RabbitMQ choreography saga.
sequenceDiagram
autonumber
participant C as Client
participant O as OrderService
participant Q as RabbitMQ
participant I as InventoryService
participant N as NotificationService
C->>O: POST /orders
O-->>C: 202 · order = Pending
O->>Q: order.placed
Q->>I: order.placed
alt stock available
I->>Q: inventory.reserved
Q->>O: inventory.reserved
O->>Q: order.confirmed
Q->>N: order.confirmed
N->>N: record "Confirmed"
else out of stock
I->>Q: inventory.rejected
Q->>O: inventory.rejected
O->>Q: order.rejected
Q->>N: order.rejected
N->>N: record "Rejected" (inventory unchanged)
end
The RabbitMQ management UI (http://localhost:15672, guest/guest) — the broker is up with its exchanges, the three durable queues and the consumers connected:
- Broker: RabbitMQ, durable topic exchange
ecommerce.events, durable queues, persistent messages, management UI at http://localhost:15672 (guest/guest). - Idempotency: Inventory has a
ProcessedOrderstable; OrderService only acts whilePending; NotificationService uses RedisSET ... NX. Consumers are prefetch=1. (Details in docs/microservices-architecture.md.) - Cache-aside:
GET /api/products/{id}caches to Redis keycatalog:product:{id}(logical DB 1);PUTinvalidates it.
⚠️ Upgrading from a previous phase? Phase 4 adds a table to the Inventory database and the app usesEnsureCreated(not migrations), which won't alter an existing DB. Run a one-timedocker compose down -vbeforeupso all schemas are recreated. (On a fresh machine this is automatic.)
./publish-all.sh # or .\publish-all.ps1
docker compose down -v # only when upgrading from an earlier phase's volumes
docker compose up --build # gateway + bff + nginx LB + RabbitMQ + 4 services + 4 DBs# create a product + stock
PID=... # id returned by POST /catalog/api/products, then PUT /inventory/api/inventory/$PID {"quantityAvailable":5,...}
# place order — returns status "Pending" immediately
curl -X POST http://localhost:8080/orders/api/orders -H "Content-Type: application/json" \
-d '{"customerEmail":"a@b.com","items":[{"productId":"'$PID'","quantity":2}]}'
# poll — becomes "Confirmed" within ~1-2s; inventory available drops, reserved rises
curl http://localhost:8080/orders/api/orders/<ORDER_ID>
curl http://localhost:8080/notifications/api/notifications # a "Confirmed" record appears# product with only 1 in stock, order 10:
curl -X POST http://localhost:8080/orders/api/orders -H "Content-Type: application/json" \
-d '{"customerEmail":"a@b.com","items":[{"productId":"'$PID'","quantity":10}]}'
curl http://localhost:8080/orders/api/orders/<ORDER_ID> # becomes "Rejected" with a reason
# inventory stays UNCHANGED; a "Rejected" notification is recorded# GET the same product twice, then update it, then GET again:
curl http://localhost:8080/catalog/api/products/<PID> # x2
curl -X PUT http://localhost:8080/catalog/api/products/<PID> -H "Content-Type: application/json" -d '{...}'
curl http://localhost:8080/catalog/api/products/<PID>
# inspect logs of both replicas:
docker logs project-ai-productcatalog-1 | grep CACHE
docker logs project-ai-productcatalog-2 | grep CACHE
# expect: CACHE MISS, then CACHE HIT, then CACHE INVALIDATE, then CACHE MISS- RabbitMQ runs (UI at http://localhost:15672, guest/guest)
- OrderService publishes
OrderPlaced; order returns as Pending - InventoryService consumes
OrderPlacedand reserves stock - InventoryService publishes
InventoryReserved/InventoryRejected - OrderService confirms/rejects the order asynchronously
- NotificationService records the final notification from events
- Out-of-stock order becomes Rejected, inventory unchanged
- Cache-aside works:
CACHE MISSthenCACHE HITin catalog logs - Gateway and BFF still work
-
docker compose upruns everything from the root - README and architecture docs updated
Every service now logs structured events with Serilog to the console and to a central Seq aggregator, and a single Correlation ID ties one order's whole journey together — including across the message broker.
- Serilog → Seq. All 7 apps write structured logs to console + Seq. Browse and
filter them at http://localhost:5341. Each event carries
Service,CorrelationId, and (where relevant)OrderId. - Correlation ID.
X-Correlation-IDis created (or accepted) at the API Gateway boundary and flows down every hop:- over HTTP via a middleware (
UseCorrelationId) + a propagation handler on outgoing calls (Order→Catalog, BFF→Order/Catalog), and - over RabbitMQ via the message's native
BasicProperties.CorrelationId— the publisher stamps it, the consumer reads it back, so the same id survives the broker hops, not just HTTP headers.
- over HTTP via a middleware (
- Healthchecks. Every .NET service exposes
/healthand has a docker-composehealthcheck(a self-probe:dotnet <Service>.dll --healthcheck, no extra image tooling needed).docker psshows each app as(healthy).
flowchart LR
C(["Client"]) -->|"X-Correlation-ID?"| GW["API Gateway<br/>creates id if absent"]
GW -->|same id| O["OrderService<br/>PUBLISH order.placed"]
O -->|same id via broker| I["InventoryService<br/>PUBLISH inventory.*"]
I -->|same id via broker| O2["OrderService<br/>PUBLISH order.*"]
O2 -->|same id via broker| N["NotificationService<br/>record + log"]
GW -.-> SEQ[/"Seq"/]
O -.-> SEQ
I -.-> SEQ
O2 -.-> SEQ
N -.-> SEQ
The same correlation id rides HTTP headers and the RabbitMQ message's native
BasicProperties.CorrelationId, so one order is a single searchable timeline in Seq — across every service and every broker hop.
# Send your own id so it's easy to find, or let the gateway generate one:
curl -X POST http://localhost:8080/orders/api/orders \
-H "Content-Type: application/json" -H "X-Correlation-ID: PHASE5-DEMO-001" \
-d '{"customerEmail":"obs@demo.com","items":[{"productId":"<PID>","quantity":3}]}'- Open http://localhost:5341.
- In the filter box enter:
CorrelationId = 'PHASE5-DEMO-001' - You see one timeline spanning ApiGateway → OrderService → InventoryService →
OrderService → NotificationService, including the
PUBLISH/CONSUMEevents that crossed RabbitMQ — all sharing that one id.
You can also see it on the console:
docker logs ecommerce-order 2>&1 | grep PHASE5-DEMO-001
docker logs ecommerce-inventory 2>&1 | grep PHASE5-DEMO-001
docker logs ecommerce-notification 2>&1 | grep PHASE5-DEMO-001- Seq runs (UI at http://localhost:5341)
- Every service writes structured logs (console + Seq)
-
/healthexists per service - docker-compose healthchecks exist (apps show
(healthy)) - Correlation ID is created or accepted at the Gateway/API boundary
- Correlation ID appears in HTTP logs
- Correlation ID appears in the RabbitMQ message flow (survives broker hops)
- One saga can be traced end-to-end by one CorrelationId
- README and architecture docs updated