Project · Earth
Amrod OMS
The recorded order — auditable, observable, and never dropped.
Essence
For the people who use it
Amrod OMS is an order-management system for SADC-region customers: place an order, take payment, fulfil it, and report on revenue — with a complete, tamper-evident history of who changed what and when. It began life as a take-home for a senior full-stack role, but it is built to a production bar: the patterns here are the ones that keep an order system honest under real traffic and real failure.
The order lifecycle is deliberately strict. An order is created Pending; an admin marks it Paid; a background worker then auto-fulfils it a few seconds later; Cancelled and Fulfilled are terminal. A state machine rejects any illegal jump, and every transition writes an audit row attributed to whoever (or whatever) made it. Money is handled the way accountants expect: when a non-ZAR order is placed, the exchange rate is frozen at creation, so a revenue report always reflects what things were worth when they sold, not today's rate.
There's a live demo. The Login as Admin button mints a demo session with full rights, so you can place an order, mark it paid, and watch the worker fulfil it — while live panels show the FX rates and the RabbitMQ queue depths updating in real time as the outbox drains.
- Auditable status workflow — Pending → Paid → Fulfilled, enforced by a state machine, every transition recorded with actor and timestamp.
- Background auto-fulfilment — a worker polls Paid orders every few seconds and transitions them, attributed to a system actor.
- Frozen-FX revenue — the rate to ZAR is captured at order time, so recognised-revenue reports never drift with the market.
- Multi-tenancy — every customer and order is scoped to its tenant; a platform-admin role sees across all of them.
- Customer & order management — SADC country validation, currency-vs-country checks, server-computed totals, searchable paged lists with per-customer rollups.
- Live operational panels — Server-Sent-Events streams of FX rates and broker queue depths so a reviewer can watch the system work.
- Sign in with Microsoft — real Microsoft Entra in production, with a guest/admin demo fallback so anyone can try it instantly.
Construction
For the engineers
Stack
- Runtime
- .NET 10 LTS
- API
- ASP.NET Core minimal APIs (
MapGroup) · OpenAPI + Scalar UI · RFC 7807 problem details - Data
- EF Core 10 (code-first) → SQL Server 2022+ · optimistic concurrency via
rowversion - Messaging
- Raw
RabbitMQ.Clientv7 + a transactional outbox dispatcher - Auth
- Microsoft Entra via
Microsoft.Identity.Web+ MSAL.js v5; HS256 demo fallback — selected by tokenalg - Resilience
- Polly v8 · EF Core retry-on-failure · transient/poison classification · readiness health checks
- Observability
- Serilog (compact JSON) + OpenTelemetry (AspNetCore · Http · SqlClient · EF Core) over OTLP
- GraphQL
- HotChocolate — read-only schema with DataLoaders (no N+1)
- Frontend
- React 19 · TypeScript 5.7 · Vite 6 · TanStack Query v5 · React Router v6 · Tailwind 4
- Testing
- xUnit · Shouldly · NSubstitute ·
WebApplicationFactory— 47 tests (29 unit, 18 integration) in ~90 ms - Deploy
- Windows Server / IIS + nssm + ARR · a Linux
build-all.shproducing one release bundle
Architecture
Three tiers, cleanly separated. A stateless ASP.NET Core minimal API serves REST and a read-only GraphQL endpoint; a separate Worker process owns the background work; SQL Server is the system of record. The API can scale horizontally without contending for the worker's state, and a misbehaving worker can't take the API down with it. Code is organised as vertical slices — one folder per feature (Customers, Orders, Reports, FX) — with cross-cutting concerns (outbox, observability, security, idempotency) as siblings.
The transactional outbox is the spine. When an order is created, its
OrderCreatedV1 event is written to an outbox table inside the same EF Core
transaction as the order itself — so the event can never be lost even if the broker is down. A
dispatcher polls every couple of seconds, publishes to RabbitMQ with publisher confirms, and only
marks a row processed once the broker acknowledges; a nightly retention service prunes processed
rows older than 30 days. The consumer classifies failures as transient (a DB blip — requeue
once) or poison (an unparseable message — dead-letter), de-duplicates on the event id before
any side effect, and restores the parent trace context so a single W3C traceparent
flows from the HTTP request, through the outbox, into the background worker.
Multi-tenancy without JOIN overhead. Both Customer and
Order carry a TenantId, and an EF Core global query filter scopes every
read to the caller's tenant, resolved per request from the JWT. A platform-admin role bypasses the
filter for the demo admin and the tenant-agnostic worker; an order always inherits its customer's
tenant, so it can never escape. Concurrency is handled by SQL Server rowversion — a
conflicting status change returns a clean 409 rather than a lost update.
One auth pipeline, three login paths. A policy scheme inspects the token's
alg header and routes RS256 Microsoft Entra tokens, HS256 demo tokens, and HS256 dev
tokens into the same authorization stack — two policies (OrdersWriter and
OrdersAdmin) guard the endpoints. Production runs multi-tenant + personal-Microsoft-account
mode: any work or school account in any tenant gets write access with no per-user role assignment,
while personal accounts are detected and held read-only with an explicit banner. Swapping from the
demo fallback to real Entra is entirely config-driven — no code change.
Built to be operated. Serilog emits compact JSON with source-generated log methods
on the hot paths; OpenTelemetry instruments ASP.NET Core, SqlClient and EF Core and exports over
OTLP. /health/ready returns 200 only when both SQL and RabbitMQ are reachable, so a load
balancer drains the node otherwise; a per-IP rate limit (100 requests / 10 s) returns
429 with Retry-After. A single Linux build-all.sh publishes the
API and Worker, builds the SPA, bundles the EF migrations and the deploy scripts into one archive; on
the server a one-button bootstrap provisions SQL, the RabbitMQ topology, IIS sites and ACLs, and the
routine redeploy is idempotent and rolls back automatically if a health check fails.
Notable details
- The outbox publishes with RabbitMQ publisher confirms — on a broker outage, rows simply stay unprocessed and retry next tick, so an integration event is never silently lost.
- Frozen-FX revenue recognition: the ZAR-equivalent total is stored at creation with banker's rounding, and reports sum the stored values rather than re-pricing history.
- A read-only GraphQL endpoint uses HotChocolate DataLoaders to batch nested lookups — three database reads for N orders, not 1 + 2N.
- Distributed tracing survives the async boundary — the request's
traceparentis captured into the outbox row and restored in the worker. - 47 tests run in about 90 ms; integration tests spin a throwaway database per class and mock the broker with a recording publisher.
- Security trade-offs are documented for the reviewer — the open demo-admin token, the anonymous SSE telemetry stream — each with its real-world mitigation.
- The brief's architecture and SQL questions are answered in depth in a companion
ANSWERS.md(10 architecture + 10 SQL).