A row in an outbox table is not yet on a queue. A small relay polls the table, publishes each pending OrderPlaced message to the broker, and marks it sent. This is where Queue-Based Load Levelling (Azure Cloud Design Patterns) begins: the API accepts orders at whatever rate the lunch rush throws at it, and the queue absorbs the spike so the workers downstream see a steady stream instead of a flood.
public async Task Relay(CancellationToken ct)
{
foreach (var msg in await _outbox.PendingBatch(ct))
{
await _publisher.Publish(msg.Topic, msg.Body, ct); // GCP Pub/Sub (SQS/SNS · Service Bus)
await _outbox.MarkSent(msg.Id, ct);
}
}OrderPlaced is published once and fanned out to several subscribers: the kitchen, courier assignment, the customer's notifications, and analytics. That is Publish/Subscribe (Azure Cloud Design Patterns) doing the fan-out the marketplace needs. The broker is named cloud-agnostically: GCP Pub/Sub first, with AWS SQS/SNS and Azure Service Bus as the parenthetical equivalents. The relay code does not care which; it talks to an IMessagePublisher. Swapping the broker is an adapter change, not a rewrite.
The workers are separate processes. Run one courier-assignment worker, run twenty; they pull from the same subscription and split the work between them without coordinating. That is Competing Consumers (Azure Cloud Design Patterns), and it is the reason this design scales out instead of up. Dinner rush hits, add workers.
The kitchen worker confirms the order with the restaurant and advances its state. The courier-assignment worker decides who delivers it, and that decision is a Strategy (GoF). A CourierMatchingStrategy picks the courier; nearest, fastest, or cheapest are interchangeable implementations chosen per restaurant or per city. Adding a new matching rule is a new strategy and a registration, not a change to the assignment loop.
public interface ICourierMatchingStrategy
{
Task<CourierId?> Match(Order order, CancellationToken ct); // nearest / fastest / cheapest
}
public sealed class CourierAssignment(ICourierMatchingStrategy strategy)
{
public async Task<CourierId?> Assign(Order order, CancellationToken ct) =>
await strategy.Match(order, ct); // swap the rule without touching this caller
}The lifecycle of an order is a State machine (GoF). It moves Placed → Confirmed → Preparing → Ready → PickedUp → Delivered, with Cancelled reachable from the early states, and the transitions are explicit rather than a pile of boolean flags. Each transition is a fact worth recording, which is why this pattern ties so cleanly to the event log behind the live-board view. The kitchen worker drives the cooking transitions; the courier worker drives pickup and delivery.
public async Task Handle(OrderPlaced evt, CancellationToken ct)
{
var order = await _gateway.Load(evt.OrderId, ct);
await _gateway.Save(order.Confirm(), ct); // State: Placed → Confirmed
await _gateway.Save(order.StartPreparing(), ct); // State: Confirmed → Preparing
}When a message fails past its retries, it goes to a dead-letter queue rather than vanishing or blocking the line behind it. Backpressure and the dead-letter queue (Enterprise Integration Patterns) are what stop one malformed order from stalling every kitchen worker. The failure is visible, parked, and replayable, which matters more than it sounds at the peak of a Friday-night rush.
Download the full PDF for free?
Free download — no account required