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.

Visit Amrod OMS Use “Login as Admin” to exercise the full lifecycle

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.Client v7 + a transactional outbox dispatcher
Auth
Microsoft Entra via Microsoft.Identity.Web + MSAL.js v5; HS256 demo fallback — selected by token alg
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.sh producing 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