Skip to content
Back to work
Architecture planPersonal projectIn progress11 min read14 sections

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

01

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.

02

Layers and the dependency rule

APIoutermost — knows everything, is known by nothing
  • Minimal APIs
  • SignalR hubs
  • OpenAPI
  • DI composition
Infrastructureimplements the ports the application declares
  • EF Core 10
  • Dapper
  • MassTransit
  • Outbox
  • Hangfire
Applicationorchestrates — no persistence, no transport
  • Commands
  • Queries
  • Handlers
  • Validators
  • Ports
Domaininnermost — depends on nothing
  • 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.

Dependencies point inward. Nothing in the inner two bands can name anything in the outer two.

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.

03

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.
04

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.

SideExamplesRules
CommandsCreateOrder, ConfirmOrder, CancelOrder, ReserveInventoryNamed for intent, one aggregate, idempotent by key
QueriesGetOrder, GetOrderHistory, GetOrdersDashboardNo 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.

05

The write path

HTTP commandminimal API
validate
FluentValidationshape
handle
Command handlerapplication
mutate
Order aggregateinvariants
one transaction
SQL ServerEF Core 10
Outbox tablesame commit
publish
RabbitMQMassTransit
consume
Inventoryconsumer
Paymentsconsumer
Fulfillmentconsumer
One transaction covers the aggregate and the outbox row. Everything after that is asynchronous.
06

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.

  1. 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.
  2. 2Dispatch separatelyA background dispatcher reads unsent outbox rows and publishes them to RabbitMQ, marking them sent only once the broker has accepted them.
  3. 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.

Publishing strategy

Tell the world first and write it down afterwards.

  1. 01Publish to RabbitMQ
  2. 02Commit the order

After restart

Order
nothing
Outbox row
—
Broker
nothing
Consumer
never ran
A model of the reasoning above, not a measurement — OrderFlow is a design. Kill the process anywhere and read the end state.
07

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.

EventPublished whenWho cares
OrderConfirmedThe order passes confirmation rulesInventory, fulfillment, notifications
InventoryReservationRequestedConfirmation needs stock heldInventory
InventoryReservedStock is successfully heldPayments
PaymentRequestedReserved stock needs paying forPayments
PaymentCompletedFunds capturedFulfillment
OrderCancelledCancellation, expiry or failed paymentInventory (release), notifications
08

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.
09

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 10Dapper
Used forAggregate persistence, writes, migrationsRead models, dashboards, reports
WhyChange tracking and unit-of-work are what a transactional write side needsExplicit SQL and projection control, no tracking overhead
Never used forWide multi-join dashboard readsAnything 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.

10

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.

11

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.

12

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.
13

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.

LevelProvesAgainst
DomainInvariants hold and illegal transitions are refusedNothing — pure objects
ApplicationHandlers orchestrate correctly, validators reject bad inputTest doubles for ports
IntegrationPersistence, concurrency and consumers actually workReal SQL Server and RabbitMQ via Testcontainers
ArchitectureThe dependency rule is not violatedAssembly 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.

14

Trade-offs I am accepting

Every decision above costs something, and a design document that only lists benefits is marketing.

DecisionCost
Eventual consistency between servicesThe UI must show in-progress states; 'confirmed' is not instantly true everywhere
Transactional outboxA dispatcher to run and monitor, plus outbox rows to clean up
Two data-access librariesTwo mental models, and a boundary that has to be policed
Aggregates over CRUDSlower to write the first feature; the payoff is at the tenth
TestcontainersSlower 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.