Delivery platforms have a brutal traffic profile: almost nothing at 4 am, then a wall of orders at 12:00 and 19:00. The backend must absorb these peaks without a single lost order, keep couriers, merchants and customers in sync, and still be simple enough for a small team to run. Here is the shape we use.
Start with the order state machine
Every delivery bug we have ever debugged was, at its root, an illegal state transition. So we make the transitions explicit and unit-testable before writing a single controller.
// Order state machine — explicit, testable transitions
public enum OrderStatus { Placed, Accepted, Preparing, ReadyForPickup, PickedUp, Delivered, Cancelled }
static readonly Dictionary<OrderStatus, OrderStatus[]> Allowed = new()
{
[OrderStatus.Placed] = new[] { OrderStatus.Accepted, OrderStatus.Cancelled },
[OrderStatus.Accepted] = new[] { OrderStatus.Preparing, OrderStatus.Cancelled },
[OrderStatus.Preparing] = new[] { OrderStatus.ReadyForPickup },
[OrderStatus.ReadyForPickup] = new[] { OrderStatus.PickedUp },
[OrderStatus.PickedUp] = new[] { OrderStatus.Delivered },
};
public void Transition(OrderStatus next)
{
if (!Allowed.TryGetValue(Status, out var ok) || !ok.Contains(next))
throw new InvalidOperationException($"{Status} -> {next} is not allowed");
Status = next;
_events.Add(new OrderStatusChanged(Id, next, DateTime.UtcNow));
}Each transition emits a domain event. Those events drive everything else — notifications, courier assignment, analytics — without the order service knowing who is listening.
Publish events reliably: the outbox
Writing to the database and then publishing to a message bus is a classic dual-write problem: if the second step fails you have a paid order nobody delivers. The transactional outbox pattern solves it by writing the event in the same transaction as the order.
// Transactional outbox — publish events reliably
await using var tx = await db.Database.BeginTransactionAsync();
db.Orders.Update(order);
db.OutboxMessages.Add(new OutboxMessage("order.status-changed", JsonSerializer.Serialize(evt)));
await db.SaveChangesAsync();
await tx.CommitAsync();
// A background worker (Hangfire / hosted service) drains the outbox to Azure Service Bus or Amazon SQS.Services, not a monolith — but not too many
We typically split a delivery backend into five deployable services: Orders, Dispatch (courier matching and routing), Catalog (merchants and menus), Payments and Notifications. Each owns its data. Anything smaller than that adds network hops and operational cost without meaningful benefit for a team under twenty engineers.
Mapping the pieces to Azure and AWS
| Need | Microsoft Azure | Amazon Web Services |
|---|---|---|
| Containers | Azure Kubernetes Service / Container Apps | Amazon EKS / ECS Fargate |
| Message bus | Azure Service Bus | Amazon SQS + SNS |
| Relational DB | Azure SQL / Azure Database for MySQL | Amazon RDS / Aurora |
| Cache & geospatial | Azure Cache for Redis | Amazon ElastiCache |
| Files (proof of delivery) | Blob Storage | Amazon S3 |
| Observability | Application Insights | CloudWatch + X-Ray |
The application code is identical on both — .NET with abstractions over the bus and storage — which is exactly why we recommend keeping cloud-specific SDK calls at the edges.
Absorbing the lunchtime peak
- Scale on queue depth, not CPU. Dispatch workers autoscale from the number of unassigned orders.
- Cache the catalog. Menus change rarely and are read constantly; Redis with short TTLs removes 80% of database load.
- Rate-limit the merchant tablet. A restaurant refreshing every second during a rush is a self-inflicted DDoS.
- Warm-up before the peak. Scheduled scaling at 11:30 and 18:30 is cheaper than reactive scaling at 12:05.
Live ETAs and routing
Courier positions flow through Redis geospatial sets; ETAs come from the Google Maps Platform Routes API and are cached per route segment. We batch the calls — one request for many candidate couriers — which keeps the maps bill predictable.
Summary
Explicit state, reliable events, a handful of well-bounded services and queue-driven scaling: that is the recipe behind every delivery platform we have shipped. The cloud provider matters less than the discipline.