Food Delivery System Design: Orders, Restaurants, Shippers

1. Overview

A food delivery application has three parties that interact with an order: the customer who orders, the restaurant that confirms, and the delivery partner who executes the delivery. All three actions happen concurrently, over flaky mobile networks, and pile up during lunch rush.

This post walks through the entire design process: requirements, data models, APIs, state machine, how to dispatch orders to delivery partners without duplication, how to keep database and Kafka in sync, and how to push real-time status to customers. After reading this, you’ll have a concrete design blueprint to apply or bring to a system design interview.

2. Requirements

2.1. Functional Requirements

  • Customers browse menus, place orders, pay, and track order status and delivery partner location.
  • Restaurants receive orders, confirm or reject them, then notify when dishes are ready.
  • Delivery partners go online, send periodic location updates, accept or reject order offers, and mark items as picked up and delivered.
  • Customers can cancel orders before the restaurant starts cooking.

2.2. Scale and Constraints

We assume the following numbers as a basis for estimation:

Orders:     1M orders/day, peak hours (11am–1pm) account for ~30%
            → ~42 orders/sec average during peak, bursts up to ~200 orders/sec
Shippers:   50k shippers online, sending location every 4 seconds
            → ~12.5k location writes/sec
Tracking:   when customers open the tracking screen → hundreds of thousands of real-time connections

From these numbers, there are three main constraints:

  • Orders must have strong consistency. No lost orders and no order assigned to two delivery partners.
  • Delivery partner location accepts weak consistency. Losing a location point is okay, but writes must be very fast. We won’t store location in the relational database.
  • Order placement must be idempotent. Mobile networks often cause clients to retry requests.

3. Overall Architecture

 Customer / Restaurant / Shipper App
          │  REST + SSE/WebSocket
          ▼
     API Gateway (rate limit, auth)
          │
 ┌────────┼───────────────┬──────────────────┐
 ▼        ▼               ▼                  ▼
Order   Restaurant     Dispatch           Tracking
Service Service        Service            Service
 │ PostgreSQL           │ Redis GEO         │ Redis (location, pub/sub)
 │ + outbox             │                   │
 └──────────► Kafka (order-events) ◄────────┘

Order Service is the source of truth for orders. Other services react to events on Kafka. Delivery partner location lives only in Redis.

The examples below use the following dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

4. Data Model

CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    restaurant_id   BIGINT      NOT NULL,
    shipper_id      BIGINT,
    status          VARCHAR(20) NOT NULL,
    total_amount    NUMERIC(12,0) NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL,
    version         INT         NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id  BIGINT REFERENCES orders(id),
    dish_id   BIGINT NOT NULL,
    dish_name VARCHAR(200) NOT NULL,
    price     NUMERIC(12,0) NOT NULL,
    quantity  INT NOT NULL
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(50) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;

A few points worth noting:

  • UNIQUE (customer_id, idempotency_key) prevents duplicate orders when the client retries.
  • version enables optimistic locking when multiple parties update an order.
  • order_items preserves dish_name and price at the time of order. Even if the restaurant changes prices later, historical invoices won’t be affected.
  • The outbox table is used for the consistency pattern in section 8.

5. Order State Machine

An order can only transition through valid states. You should encode this rule in one place instead of scattering if statements across services:

public enum OrderStatus {
    PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;

    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
        PLACED,           Set.of(CONFIRMED, CANCELLED),
        CONFIRMED,        Set.of(PREPARING, CANCELLED),
        PREPARING,        Set.of(READY_FOR_PICKUP),
        READY_FOR_PICKUP, Set.of(PICKED_UP),
        PICKED_UP,        Set.of(DELIVERED),
        DELIVERED,        Set.of(),
        CANCELLED,        Set.of()
    );

    public boolean canTransitionTo(OrderStatus next) {
        return TRANSITIONS.get(this).contains(next);
    }
}

TRANSITIONS lists the valid next states for each state. Customers can only cancel at PLACED or CONFIRMED. Once the restaurant starts cooking (PREPARING), the order cannot be cancelled. The final two states have no further transitions.

A test to verify these rules:

class OrderStatusTest {

    @Test
    void customerCanCancelBeforePreparing() {
        assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
        assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
    }

    @Test
    void cannotCancelOnceKitchenStarted() {
        assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

    @Test
    void deliveredIsTerminal() {
        assertThat(OrderStatus.values())
            .noneMatch(OrderStatus.DELIVERED::canTransitionTo);
    }
}

6. API

POST  /api/orders                         Customer places order (Idempotency-Key header)
GET   /api/orders/{id}                    View order details
POST  /api/orders/{id}/cancel             Customer cancels order
POST  /api/restaurant/orders/{id}/confirm Restaurant confirms
POST  /api/restaurant/orders/{id}/ready   Restaurant marks dishes ready
PUT   /api/shippers/me/location           Delivery partner sends location {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   Delivery partner accepts order
POST  /api/orders/{id}/pickup | /deliver  Delivery partner updates status
GET   /api/orders/{id}/stream             SSE: status + delivery partner location

The API is designed around actions (/confirm, /accept) rather than generic PATCH status=... endpoints. This makes authorization easier: only the restaurant with that order can call /confirm, only the assigned delivery partner can call /pickup.

7. Handling Conflicts

7.1. State Update with Optimistic Locking

Suppose a customer clicks cancel exactly when the restaurant clicks confirm. If both read PLACED, both think they’re valid. We solve this with a conditional UPDATE:

@Repository
public class OrderRepository {

    private final JdbcClient jdbc;

    public OrderRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
        if (!from.canTransitionTo(to)) {
            throw new IllegalStateException(from + " -> " + to + " is invalid");
        }
        int updated = jdbc.sql("""
                UPDATE orders SET status = :to, version = version + 1
                WHERE id = :id AND status = :from AND version = :version
                """)
            .param("to", to.name())
            .param("id", orderId)
            .param("from", from.name())
            .param("version", version)
            .update();
        return updated == 1;
    }
}

The database ensures only one of the two UPDATE statements matches the version condition. The winner gets true. The loser gets false and returns HTTP 409 with the latest status, so the app can show “Restaurant just confirmed the order.”

7.2. Dispatching to Delivery Partners

This is the trickiest part. We need to find a nearby delivery partner and ensure each order is assigned to exactly one.

Step 1: Store location in Redis GEO. Each time a delivery partner sends location, we write to a GEO sorted set and refresh a heartbeat key:

@Service
public class ShipperLocationService {

    private final StringRedisTemplate redis;

    public ShipperLocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void update(long shipperId, double lat, double lng) {
        String id = String.valueOf(shipperId);
        redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
        redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
    }

    public List<String> findNearby(double lat, double lng) {
        var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance().sortAscending().limit(20);
        var results = redis.opsForGeo().search("shipper:geo",
            GeoReference.fromCoordinate(lng, lat),
            new Distance(3, Metrics.KILOMETERS), args);
        return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
            .toList();
    }
}

Point takes longitude before latitude—an easy mistake to make. Members in a GEO set don’t expire automatically. That’s why the shipper:alive:* key with a 30-second TTL is used to filter out delivery partners who went offline or lost signal. A periodic job can ZREM dead members from shipper:geo.

With 12.5k writes per second, a single Redis instance handles it well. When expanding to multiple cities, shard by region: shipper:geo:hcm, shipper:geo:hn.

Step 2: Invite delivery partners sequentially, one offer at a time. Dispatch Service sends offers to nearby partners in order. Each offer expires in 20 seconds. To ensure a delivery partner doesn’t receive two offers simultaneously, we use a SET NX lock:

public boolean reserveShipperForOffer(String shipperId, long orderId) {
    Boolean ok = redis.opsForValue().setIfAbsent(
        "offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
    return Boolean.TRUE.equals(ok);
}

If false, the delivery partner is viewing another offer, so we move to the next person.

Step 3: Finalize assignment in the database. The Redis lock is just coordination. The final decision must be in the database:

public boolean assignShipper(long orderId, long shipperId) {
    return jdbc.sql("""
            UPDATE orders SET shipper_id = :shipperId, version = version + 1
            WHERE id = :orderId AND shipper_id IS NULL
              AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
            """)
        .param("shipperId", shipperId)
        .param("orderId", orderId)
        .update() == 1;
}

The shipper_id IS NULL condition ensures that even if two delivery partners click “Accept” (e.g., if an old offer timed out and was resent), only one wins.

Note: Don’t use Redis locks as the source of truth for assignment. Redis can failover and lose the lock. The database constraint is your final safety net.

8. Database-Kafka Consistency

After creating an order, Order Service must emit an OrderPlaced event so the restaurant gets notified. If you write to the database then call kafkaTemplate.send(), the service can crash between the two steps. The result: the order exists but the restaurant never learns about it. This is the dual write problem.

The solution is the transactional outbox: write events to the outbox table in the same transaction as the order.

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // throws DuplicateKeyException on duplicate key
    jdbc.sql("""
            INSERT INTO outbox (aggregate_id, event_type, payload)
            VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
            """)
        .param("id", orderId)
        .param("payload", toJson(cmd, orderId))
        .update();
    return orderId;
}

Both records commit or both roll back. If the client retries with the same Idempotency-Key, the UNIQUE constraint throws DuplicateKeyException. The controller catches this error, finds the old order, and returns it instead of creating a new one.

A relay process reads the outbox and pushes to Kafka:

@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
    List<OutboxEvent> events = jdbc.sql("""
            SELECT id, aggregate_id, event_type, payload::text AS payload FROM outbox
            WHERE published = false ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED
            """)
        .query(OutboxEvent.class).list();

    for (OutboxEvent e : events) {
        kafka.send("order-events", String.valueOf(e.aggregateId()), e.payload()).join();
        jdbc.sql("UPDATE outbox SET published = true WHERE id = :id")
            .param("id", e.id()).update();
    }
}

FOR UPDATE SKIP LOCKED allows multiple relay instances to run without processing duplicate records. The message key is orderId, so all events for the same order fall into the same partition and maintain order.

The relay guarantees at-least-once delivery. If the service crashes after send but before commit, the event will be resent. So the consumer must be idempotent—for example, by saving processed event_ids or using UPDATE ... WHERE status = ... as shown in section 7.1.

9. Real-time Status for Customers

Customers need two types of updates: order status (infrequent, critical) and delivery partner location (frequent, can miss some). Server-Sent Events is a good fit because data flows one way, from server to client:

@RestController
public class OrderStreamController {

    private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@PathVariable long id) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
        emitter.onCompletion(() -> emitters.get(id).remove(emitter));
        emitter.onTimeout(emitter::complete);
        return emitter;
    }

    public void push(long orderId, String eventName, Object data) {
        emitters.getOrDefault(orderId, List.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name(eventName).data(data));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

Each order has a list of emitters (customers can open on multiple devices). push sends a named event, like status or location. Clients receive:

event:status
data:{"orderId":1024,"status":"PICKED_UP"}

event:location
data:{"lat":10.7769,"lng":106.7009}

When running multiple instances, a customer’s SSE connection lives on one instance. There are two ways to route events correctly:

  • Order status: each Tracking instance consumes order-events with its own group.id, so every instance receives all events but only pushes to its own emitters.
  • Delivery partner location: when the order is in PICKED_UP state, Tracking publishes location to Redis channel track:order:{id}. The instance holding the connection subscribes to that channel. We don’t push 12.5k messages/sec through Kafka when only a small fraction are being viewed.

To reduce load, you can batch location updates: clients only need one point every 4–5 seconds and can interpolate motion on the map.

10. Handling Peak Traffic

During lunch or promotions, traffic can spike 5–10×. Some techniques help the system stay resilient:

  • Cache menus. Menus are read often, written rarely. Cache in Redis by restaurant_id and invalidate when a restaurant updates dishes. Most “view restaurant” requests won’t hit the database.
  • Rate limit by user and by restaurant at the gateway. You can also temporarily stop accepting orders from overwhelmed restaurants (more than N orders in PLACED state) rather than let customers wait 40 minutes.
  • Separate writes from processing. The place-order API only writes to orders and outbox, then returns immediately. Notifications, shipping fees, and promotion recommendations run asynchronously via Kafka.
  • Batch dispatch. When demand exceeds supply, matching orders and delivery partners every 5–10 seconds (batch matching) yields better results than inviting one-by-one immediately. The system can also combine two orders from the same restaurant for one delivery partner.
  • Graceful degradation. If Redis Tracking lags, prioritize the order-placement pipeline and pause location updates. Customers would rather the map freeze for a few seconds than fail to place an order.

11. Common Mistakes

  • Storing delivery partner location in PostgreSQL. 12.5k writes/sec, each updating a row, causes bloat and lock contention. You should only archive trajectory history in batches (e.g., push via Kafka to a data warehouse) if you need it for reconciliation.
  • Checking status with SELECT then UPDATE. There’s always a gap between the two statements for a race condition. Push the condition into the WHERE clause.
  • Sending to Kafka inside @Transactional without an outbox. The transaction rolls back but the message is already gone, or vice versa.
  • Using an SseEmitter without a timeout. Hung connections accumulate and drain resources. Set a timeout and let clients reconnect via GET /api/orders/{id} to fetch the latest status.

Note: A multi-service architecture with Kafka, Redis GEO, and outbox only makes sense at large scale. With a few hundred orders a day, a monolithic Spring Boot app, one PostgreSQL, and 5-second polling is sufficient and far easier to operate.

12. Conclusion

In this post, you’ve designed a food delivery system from requirements to implementation. Four key takeaways:

  • Model orders with a clear state machine.
  • Resolve conflicts with conditional UPDATE and optimistic locking.
  • Dispatch using Redis GEO combined with database constraints.
  • Maintain consistency with Kafka via transactional outbox.

The overarching principle is to classify data by consistency requirements. Orders need strong consistency, so they live in a relational database. Delivery partner locations tolerate loss, so they go through Redis, prioritizing speed. Once you separate these two types, most of the problem’s complexity becomes manageable.

system-designspring-bootrediskafkacase-study