
OrderFlow
Event-driven order & fulfillment platform
Order processing built on DDD aggregates and CQRS. Inventory reservation, payment and fulfillment run as asynchronous workflows published through a transactional outbox, with idempotent consumers, retry and dead-letter handling, and order state streamed to clients as it changes.
Aggregate design, transactional consistency and long-running distributed workflows.
- .NET 10
- EF Core 10
- Dapper
- RabbitMQ
- MassTransit
- DDD
- CQRS
- SignalR
Personal build — no public repository yet
The problem it is built to exercise
Industrial integration work is heavy on protocols, throughput and reliability, and light on transactional business process. An order is the opposite: a small amount of data with a large amount of rules attached, moving through a workflow that spans several services and can fail halfway.
So this exists to work the other half of the problem space — aggregate design, transactional consistency, eventual consistency, and the machinery you need when those two have to coexist.
Layers and the dependency rule
- Minimal APIs
- SignalR hubs
- OpenAPI
- DI composition
- EF Core 10
- Dapper
- MassTransit
- Outbox
- Hangfire
- Commands
- Queries
- Handlers
- Validators
- Ports
- Aggregates
- Value objects
- Domain events
- Invariants
The rule is enforced by architecture tests rather than discipline: a test asserts that the Domain assembly references no ASP.NET Core, EF Core, Dapper, MassTransit, Hangfire or Serilog type. A convention nobody can violate accidentally is worth more than a diagram in a README.
The value of the dependency rule is not tidiness, it is that the expensive-to-change part of the system — the business rules — cannot be dragged along by a decision about a database or a message broker. Swapping RabbitMQ is an infrastructure edit. Swapping the meaning of a confirmed order is not, and should not be possible from there.
The Order aggregate
Order is the consistency boundary. Items, totals and status transitions are reached only through it, which means every invariant has exactly one place it can be broken and therefore exactly one place it has to be defended.
- Money is a value object, not a decimal. A currency-less amount is a bug waiting for a second market.
- OrderId, CustomerId and ProductId are distinct types. Passing a customer id where a product id belongs should not compile.
- Status transitions are methods that enforce the legal moves, not a settable property.
- Adding an item to a confirmed order is refused by the aggregate, not by a validator in the application layer.
Commands and queries
Commands express business intent and mutate exactly one aggregate. Queries return read models shaped for the caller and mutate nothing. The split is not ceremony — it is what lets the read side be optimised without negotiating with the write side.
| Side | Examples | Rules |
|---|---|---|
| Commands | CreateOrder, ConfirmOrder, CancelOrder, ReserveInventory | Named for intent, one aggregate, idempotent by key |
| Queries | GetOrder, GetOrderHistory, GetOrdersDashboard | No writes, no domain entities in responses, paginated |
Domain entities never reach an API response. A read model is free to denormalise, flatten and rename for whoever is reading it, and that freedom is exactly why it must not be the same object the domain defends its invariants on.
The write path
Why a transactional outbox
Saving an order and publishing OrderConfirmed are two different systems, and there is no transaction spanning both. Do them naively and you get one of two failures: publish first and crash, and consumers act on an order that does not exist; commit first and crash, and the order exists but nothing downstream ever hears about it.
- 1Write the event with the dataThe handler appends the integration event to an outbox table inside the same transaction as the aggregate change. Either both land or neither does.
- 2Dispatch separatelyA background dispatcher reads unsent outbox rows and publishes them to RabbitMQ, marking them sent only once the broker has accepted them.
- 3Accept at-least-onceA crash between publish and mark-sent means the message goes out twice. That is a deliberate trade: duplicates are a solvable problem, lost messages are not.
The outbox does not make delivery exactly-once. It makes it at-least-once, and moves the remaining problem somewhere it can actually be solved — the consumer.
Crash it yourself
Pick an ordering, then kill the process at any step. The panel shows where the system ends up once it restarts.
- 01Publish to RabbitMQ
- 02Commit the order
After restart
- Order
- nothing
- Outbox row
- —
- Broker
- nothing
- Consumer
- never ran
The integration events
Each event exists because some other component needs to react without the publisher knowing it exists. If an event has exactly one consumer that could have been a direct call, it is a message for the sake of being a message.
| Event | Published when | Who cares |
|---|---|---|
| OrderConfirmed | The order passes confirmation rules | Inventory, fulfillment, notifications |
| InventoryReservationRequested | Confirmation needs stock held | Inventory |
| InventoryReserved | Stock is successfully held | Payments |
| PaymentRequested | Reserved stock needs paying for | Payments |
| PaymentCompleted | Funds captured | Fulfillment |
| OrderCancelled | Cancellation, expiry or failed payment | Inventory (release), notifications |
Idempotent consumers
At-least-once delivery means every consumer will eventually see a message twice, so handling a duplicate has to be normal rather than exceptional. Reserving stock twice for one order is an inventory bug that shows up as a stockout weeks later, which is exactly the sort of bug that never gets traced back to a redelivery.
- Every consumer records the message id it has processed, and a second delivery of the same id is acknowledged without re-running the effect.
- Commands accept an idempotency key from the caller, so a retried HTTP request cannot create a second order.
- Retries use bounded backoff, and messages that exhaust their retries go to an error queue rather than looping forever.
- A correlation id travels from the inbound request through every event and every log line, so one order's whole journey is a single query.
EF Core and Dapper, on purpose
Two data-access libraries in one system usually means nobody decided. Here the split is the decision, and each one is doing something the other is bad at.
| EF Core 10 | Dapper | |
|---|---|---|
| Used for | Aggregate persistence, writes, migrations | Read models, dashboards, reports |
| Why | Change tracking and unit-of-work are what a transactional write side needs | Explicit SQL and projection control, no tracking overhead |
| Never used for | Wide multi-join dashboard reads | Anything that mutates an aggregate |
The rule that keeps it honest: IQueryable never escapes the infrastructure layer. The moment a query object crosses that boundary, the caller owns a database concern and the split has quietly collapsed.
Background work, and what it is not for
Hangfire handles scheduled and recurring work: expiring orders that were never confirmed, reconciling stuck workflows, cleaning up processed outbox rows. All of it is time-driven, and none of it is event-driven.
Real-time order state
An order that moves through reservation, payment and fulfillment asynchronously is exactly the case where polling is worst: mostly nothing changes, and when it does the user wants to know immediately. SignalR pushes state transitions to whoever is watching that order.
Observability
A synchronous request that fails tells you so in the response. A distributed workflow that fails halfway tells you nothing unless you built for it, which is why this is designed in rather than added after the first incident.
- Structured Serilog logging with CorrelationId, TraceId, OrderId, MessageId, Operation and DurationMs as properties, not string interpolation.
- OpenTelemetry traces spanning HTTP, database and message operations, so one order's path is one trace.
- Separate /health/live and /health/ready endpoints — liveness answers 'is the process up', readiness answers 'can it serve traffic'. Conflating them makes an orchestrator restart a process that was only waiting on a dependency.
- No secrets, tokens or payment details in logs, enforced at the logging configuration rather than by reviewer memory.
Testing what would actually break
Coverage is a poor target here. The failures that matter in an event-driven system are concurrency, duplicate delivery and mapping drift, and none of them show up in a unit test with a mocked repository.
| Level | Proves | Against |
|---|---|---|
| Domain | Invariants hold and illegal transitions are refused | Nothing — pure objects |
| Application | Handlers orchestrate correctly, validators reject bad input | Test doubles for ports |
| Integration | Persistence, concurrency and consumers actually work | Real SQL Server and RabbitMQ via Testcontainers |
| Architecture | The dependency rule is not violated | Assembly references |
Mocking the database to test EF Core persistence tests the mock. Mocking the broker to test a consumer tests the mock. Where a container can give a real dependency in seconds, the real dependency is the cheaper choice.
Trade-offs I am accepting
Every decision above costs something, and a design document that only lists benefits is marketing.
| Decision | Cost |
|---|---|
| Eventual consistency between services | The UI must show in-progress states; 'confirmed' is not instantly true everywhere |
| Transactional outbox | A dispatcher to run and monitor, plus outbox rows to clean up |
| Two data-access libraries | Two mental models, and a boundary that has to be policed |
| Aggregates over CRUD | Slower to write the first feature; the payoff is at the tenth |
| Testcontainers | Slower test runs and Docker required in CI |
Want to talk through the design?
It is a design, which means it is still arguable. If you think one of the trade-offs above is wrong, I would rather hear it now than after the build.