A consulting-style response to: "Downstream systems disagree about shipment state because courier events arrive late, out of order, duplicated, or with conflicting data."
The submission is document-first. The executable slice is a thin, targeted demonstration of the data-integrity strategy — not a platform.
| Path | Purpose |
|---|---|
docs/strategy-memo.md |
The technical strategy memo (the main read) |
docs/delivery-plan.md |
Phased delivery plan + risk register + success signals |
docs/architecture.md |
Component + sequence diagrams (Mermaid) |
docs/adr/0001-event-sourced-audit-with-projected-state.md |
ADR: audit log + materialised projection |
docs/adr/0002-idempotent-ingestion-and-conflict-resolution.md |
ADR: dedup + conflict-resolution rules |
docs/ai-process-note.md |
How AI was used, what I overrode |
docs/stakeholder-note.md |
Half-page trade-off note for a non-technical sponsor |
docs/demo-runbook.md |
Pre-demo dry-run script — six scenarios that exercise every reviewer-relevant property |
scripts/ |
DB reset scripts: reset-soft.sql (DELETE), reset-hard.sql (DROP TABLE), reset-nuke.sql (DROP DATABASE). Run via sqlcmd -S "LAPTOP-DELL\SQLEXPRESS" -d ShipmentTracker -i scripts\reset-*.sql — see docs/demo-runbook.md for usage |
src/ShipmentTracker.Api |
ASP.NET Core 10 Web API (controller-based) — the slice |
tests/ShipmentTracker.Tests |
xUnit v3 tests (30 tests, all green) |
- .NET 10 / ASP.NET Core 10 (MVC controllers) — required
- EF Core 10 over SQL Server Express (
LAPTOP-DELL\SQLEXPRESS). Read paths (the threeGET /shipments/{id}/…endpoints and the projector's re-projection query) useAsNoTracking()to skip change-tracker hydration — the change tracker is only carrying its weight on the write path whereSaveChangesAsyncneeds it. - Microsoft.AspNetCore.OpenApi for spec generation + Swagger UI viewer served at the site root (
/) - Serilog for structured operational logging (console + rolling daily file under
logs/). Distinct from the domainAuditEntrylog — Serilog answers "what did the process do at 03:14?",AuditEntryanswers "why did the system decide X about this shipment?" - Hangfire with SQL Server storage — for the 30-day raw-payload retention job introduced by the change request (
RawEventRetentionJob, registered daily at 03:00 UTC). Dashboard at/hangfire. - xUnit v3 for tests (assertions use the built-in
Assert.*API — no third-party assertion library) - Not used: RabbitMQ (overkill for the slice — see ADR 0002 for the evolution path), Web frontend (not asked for)
- .NET 10 SDK (
dotnet --version≥10.0.300) - SQL Server Express reachable at
LAPTOP-DELL\SQLEXPRESSwith Windows auth, or editappsettings.json→ConnectionStrings:Tracker - The database
ShipmentTrackeris created automatically on first run (EnsureCreated).
cd src/ShipmentTracker.Api
dotnet runOpen http://localhost:5049/ (or whatever Kestrel logs) for Swagger UI, and http://localhost:5049/hangfire for the Hangfire dashboard.
Send a webhook via Swagger or curl:
curl -X POST http://localhost:5049/webhooks/dhl `
-H "Content-Type: application/json" `
-d '{
"eventId":"evt-123","partner":"dhl","shipmentId":"456",
"status":"IN_TRANSIT","occurredAt":"2026-03-10T12:00:00Z",
"receivedAt":"2026-03-10T12:00:05Z","location":"Amsterdam"
}'
curl http://localhost:5049/shipments/456
curl http://localhost:5049/shipments/456/events
curl http://localhost:5049/shipments/456/auditSend a batched Acme payload (the change-request scenario — deliberately out of order):
curl -X POST http://localhost:5049/webhooks/acme/batch `
-H "Content-Type: application/json" `
-d '{
"batchId":"b-001",
"events":[
{"id":"a3","trackingRef":"200","state":"OUT_FOR_DELIVERY","at":"2026-03-10T15:00:00Z","city":"Munich"},
{"id":"a1","trackingRef":"200","state":"LABEL","at":"2026-03-10T08:00:00Z"},
{"id":"a2","trackingRef":"200","state":"IN_TRANSIT","at":"2026-03-10T10:00:00Z","city":"Berlin"}
]
}'
curl http://localhost:5049/shipments/200The Hangfire dashboard is at http://localhost:5049/hangfire — find the raw-event-retention recurring job there. You can trigger it manually for an end-to-end demo.
Send the same event again — observe "outcome":"DuplicateIgnored".
Send an earlier-timestamped event — observe "outcome":"AcceptedNoStateChange" (state does not regress).
Send DELIVERED then a later IN_TRANSIT — observe "outcome":"RejectedAfterTerminal".
cd tests/ShipmentTracker.Tests
dotnet testTests use SQL Server LocalDB ((localdb)\MSSQLLocalDB) — one fresh database per test, dropped on process exit. Real FK enforcement, unique constraints, and RowVersion concurrency catch the same classes of bug your production SQL Server would. LocalDB ships with Visual Studio and the .NET SDK on Windows, so no separate install needed.
⚠ Do not pass
--nologotodotnet testhere. xUnit v3 runs onMicrosoft.Testing.Platform, which doesn't recognise the legacy VSTest flag —dotnet testforwards it to the test app and the run exits with code 5 having discovered zero tests.dotnet build --nologois unaffected.
These were considered and skipped to honour the brief's "no bloat" guidance — discussed in the strategy memo:
- Authentication / signature verification on the webhook (production must add HMAC per partner)
- A real message broker (in-process Hangfire is sufficient for the slice; ADR 0002 covers when to introduce RabbitMQ/Kafka)
- Hangfire dashboard authentication (currently open — required before prod, tracked in delivery plan Phase 2 exit criteria)
- Containerisation, CI pipelines, observability stack (OpenTelemetry hooks noted in memo, not wired)
- A Web frontend (brief does not call for one)
- Multiple production environments / migrations bundle (uses
EnsureCreated— production would usedotnet ef migrations)
-
Three git commits are intentional:
- Initial strategy + slice (
feat: initial strategy memo, delivery plan, and executable slice) - Response to the mandatory change request (
feat: 2nd courier, batch ingestion, retention) - Post-review polish (OpenAPI generator switch, Serilog, explicit types in hot paths,
AsNoTrackingon read queries, FK constraint on the projection, test harness moved from EF InMemory to SQL Server LocalDB, reset scripts underscripts/, demo runbook + launch profiles, xUnit v3 cancellation-token propagation)
The diff between commits 1 and 2 is itself an artefact — it shows how the strategy adapted to the change request. Commit 3 is the kind of self-review pass I'd expect to do after sleeping on a piece of work; the per-commit story is in
docs/ai-process-note.md. - Initial strategy + slice (