Worked problems · Food delivery dispatch

Worked problems

Food delivery dispatch

A DoorDash-shaped system: orders from many stores, a pool of Dashers, and assignment that stays correct when everyone retries.

3 min read system-design

What you are designing

A customer orders from a store. The kitchen prepares the food. A Dasher picks it up and delivers it. You choose the Dasher. This is the three-sided version of ride matching: store, Dasher, customer. Assume a city has thousands of online Dashers and that dinner is a sharp peak. One order is one store. Batching multiple pickups is a follow-up, not the first drawing.

Requirements

  • Place an order once, even if the client retries.
  • Show state: confirmed, being prepared, assigned, picked up, delivered.
  • Assign a Dasher who is online, under capacity, and plausibly on time.
  • Reassign if the Dasher declines or goes silent.
  • Do not mark an order delivered twice, and do not double-pay.

A late assignment is bad. Two Dashers for one order is worse.

Entities and API

Order, Store, Dasher, Assignment(order, dasher, state, lease), Location.

POST /v1/orders                 Idempotency-Key  -> order
POST /v1/assignments/{id}/accept
POST /v1/assignments/{id}/decline
POST /v1/orders/{id}/events     { picked_up | delivered }

Create is idempotent on the client’s key, so a double tap is one order and one charge authorization. Accept and decline belong to the assignment, not the order: accepting an expired lease is 409, not a second Dasher on the same food. Event posts are idempotent on (order, event type) so a retried “delivered” does not pay the Dasher twice. 404 means that id is not this caller’s. Do not return another customer’s order in the error body.

High-level design

flowchart LR
  Cust --> API
  API --> Orders[(Orders)]
  API --> Pay[Payments]
  Sched[Dispatcher] --> Orders
  Sched --> Geo[(Dasher geo index)]
  Sched --> Push[Dasher push]
  Store[Store tablet] --> API

Create order and payment auth are a saga: if auth fails, the order is not confirmed. The store sees confirmed orders on its tablet (poll or push). The dispatcher wakes up when the predicted ready time is near, not necessarily at create. It queries nearby Dashers, claims one, and pushes an offer.

Data model

CREATE TABLE orders (
  id            BIGSERIAL PRIMARY KEY,
  customer_id   BIGINT NOT NULL,
  store_id      BIGINT NOT NULL,
  idem_key      TEXT NOT NULL,
  state         TEXT NOT NULL,
  ready_at      TIMESTAMPTZ,
  UNIQUE (customer_id, idem_key)
);
CREATE TABLE assignments (
  id          BIGSERIAL PRIMARY KEY,
  order_id    BIGINT NOT NULL,
  dasher_id   BIGINT NOT NULL,
  state       TEXT NOT NULL, -- offered | accepted | canceled
  lease_until TIMESTAMPTZ,
  UNIQUE (order_id, dasher_id)
);
CREATE UNIQUE INDEX one_live_assignment ON assignments (order_id)
  WHERE state IN ('offered','accepted');

The partial unique index is the double-assignment lock. A second dispatcher loses the insert.

Deep dives

Who is eligible. Geo-query Dashers near the store, drop anyone offline, at bag capacity, or with an offer lease still running. Rank by ETA to the store plus slack against ready_at. Call routing only for the top handful. If the best ETA cannot make the promise, say so to the customer instead of assigning a Dasher who will be late by design.

Offer loop. Insert the offered assignment, push to the Dasher, and set a lease of a few seconds. Accept updates the assignment where state is still offered and fills orders.dasher_id conditionally. Decline or expiry cancels that assignment and lets the dispatcher try the next Dasher. The customer-facing state stays “finding a Dasher” until accept.

Capacity and batching. A simple capacity is a max concurrent orders. A better one accounts for route: adding a pickup that is on the way is different from adding a pickup across town. For v1, refuse to batch. For the follow-up, only batch when the extra stop fits the existing promise, and recompute rather than stacking greedily without a check.

Payout. A ledger row keyed by (assignment_id, type) so a retry of “pay for this dropoff” does not double-pay. Tips can arrive later as a second ledger line.

Levels

  • Mid-level. Idempotent create, durable order, one assignment constraint, a geo candidate set.
  • Senior. Lease and decline, ready-time scheduling, capacity, payment separated from dispatch.
  • Staff. Batching trade-offs, hot stores, stale locations, and how you degrade ETA when routing is down. Mentions fairness to Dashers.

Further reading

Check yourself

Answer the questions. Multiple choice is scored on this device only.

1. The customer double-taps 'place order.' You should:

2. When should dispatch run relative to a 25-minute kitchen?

3. How is this different from ride matching?