Case StudyFull-stack buildArchitecturePaymentsMobile

Food Delivery Platform

How I structure a food delivery platform: one API, an app for every person in the order, and the quiet machinery customers never see

Client
Confidential client, under NDA
Role
Architect and full-stack builder
Year
2026
Scope
Full-stack build, Architecture
Prabir Singh, profile photoPrabir Singh·8 min read
Concept illustration of a restaurant, delivery scooter and apartment connected by an order route.

Concept illustration

What it is
The general structure behind a food delivery platform I built under NDA. The client's product, data and numbers stay private.
Who it serves
Four people in every order: the customer, the restaurant, the rider and the operator. Each gets an interface built for their job.
Shape of the build
One monorepo: the apps on top, one typed API, a background worker, and domain packages underneath.
The core idea
The order screen is the easy part. The value lives in the state machine, the money and the dispatch logic.
Stack in one breath
TypeScript end to end, Next.js on the web, React Native and Expo on mobile, Hono for the API, PostgreSQL with Drizzle.
StackTypeScriptTurborepoNext.jsHonoReact Native / ExpoPostgreSQLDrizzle

The order screen is maybe ten percent of a food delivery platform. Everything a founder pitches lives on that screen. Everything that keeps the business alive lives behind it.

I built a food delivery platform for a client under a non-disclosure agreement. So this page doesn't describe their product. It describes the structure I use when I build one: the decisions, the shapes and the reasons. No client names, no data, no numbers from their business.

Four people, one order

A delivery order isn't a transaction between two parties. Four people touch it, and each one wants something different from the software.

PersonWhat they needWhat I build for them
CustomerFind food, pay in seconds, know where it isA web storefront and a mobile app
RestaurantAccept orders fast, manage the menu, see the moneyA merchant portal that works on a kitchen tablet
RiderKnow where to go next and what they've earnedA mobile app built for one hand and bad signal
OperatorSee everything, fix anything, trust the numbersAn admin console with roles and an audit trail

A delivery build can look convincing for customers while leaving the other three interfaces underdeveloped. That's backwards. A customer can forgive a slow animation. A kitchen that misses an order on a Friday night doesn't forgive anything. That's the same reason I build for the buyer, not just the user.

One repo, with the dependencies pointing down

Every app and service lives in one Turborepo monorepo. I've written about why one repo beats a folder full of them, and delivery is the strongest case for it.

Delivery platform dependency map

Explore the connections · swipe or use arrow keys when focused

Delivery platform dependency mapA general dependency map. The apps and services depend on shared domain packages; the packages depend on shared foundations. Connectors describe code dependencies, not individual network requests. No private client service names or infrastructure are shown.APPLICATIONS AND SERVICESSHARED PACKAGESCustomer appsWeb / mobileMerchant + riderTask-specific interfacesOperator consoleRoles + operationsAPI + workerRequests + background jobsDomain packagesOrders · menus · dispatchFoundationsTypes · validation · datadepends on
A general dependency map. The apps and services depend on shared domain packages; the packages depend on shared foundations. Connectors describe code dependencies, not individual network requests. No private client service names or infrastructure are shown.

One rule holds the whole thing together: dependencies point down, never up. Apps depend on packages. Packages depend on lower packages. A package exposes one public door, and reaching past it into another domain's internals is a build failure, not a code-review argument.

Why so strict? Because delivery domains are tangled by nature. An order touches payments, a payment touches refunds, a refund touches the restaurant's payout. Without hard walls, those domains melt into one another within a few months, and every change starts breaking something two rooms away.

The order is a state machine, not a pile of flags

The tempting design is an order row with booleans: isAccepted, isPickedUp, isDelivered. It works until two of them are true at the same time and nobody can say which one is lying.

So an order is a state machine with named states and explicit transitions:

Order lifecycle

Explore the connections · swipe or use arrow keys when focused

Order lifecycleA general lifecycle, not a client system specification. Follow the main path across the top and back along the middle. Rejection, cancellation and refund are explicit branches. Double outlines mark states where this flow can end.PlacedAwaiting restaurantAcceptedRestaurant agreesPreparingKitchen startsReadyAwaiting pickupPicked upRider has the orderDeliveredDelivery recordedRefundedSupport decisionCancelledRejected or cancelledacceptrider collectssupport decisionreject / time outcancel after acceptance
A general lifecycle, not a client system specification. Follow the main path across the top and back along the middle. Rejection, cancellation and refund are explicit branches. Double outlines mark states where this flow can end.

A general order lifecycle. Every arrow is a function with rules, not a field anyone can overwrite.

In TypeScript, each state is a branch of a discriminated union. A delivered order carries its proof of delivery. A cancelled one carries its reason. Add a new state and the compiler walks you to every screen and job that now has to handle it. That's one language across the whole stack doing real work.

Try the rule: one collection, even after a retry

Illustrative TypeScript, written for this article. This tiny model covers just the collection step. It uses fictional riders and contains no client implementation.

type Collection =
  | { status: "ready" }
  | { status: "picked-up"; riderId: string };
 
function collect(order: Collection, riderId: string): Collection {
  if (order.status === "picked-up") {
    if (order.riderId !== riderId) throw new Error("Already collected");
    return order;
  }
  return { status: "picked-up", riderId };
}
 
const picked = collect({ status: "ready" }, "rider-demo");
console.log(collect(picked, "rider-demo") === picked); // true

The same rider retrying gets the existing result. Another rider gets an explicit conflict. Dinner should travel once, even if the request travels twice.

This is an in-memory rule, not a concurrency solution. A real service must authenticate the rider, validate the order, and enforce the transition atomically in durable storage. TypeScript’s discriminated unions explain how the status field makes the valid data shape visible to the compiler.

Money never touches a float

0.1 + 0.2 is not 0.3 in JavaScript. On a busy weekend, that rounding error is somebody's money.

So every amount is an integer in minor units (cents, paise, whatever the currency's smallest coin is), stored together with its currency. Adding two amounts in different currencies throws instead of guessing. A few more rules ride along:

  • Every payment and order mutation carries an idempotency key. A retried request can't charge twice.
  • Every incoming webhook is signature-checked and recorded once, so a replay is rejected, not processed again.
  • A refund is a new record, not an edit. The history stays append-only, so you can always explain how a balance got where it is.

Delivery sits behind an interface

How an order reaches the customer changes more often than anything else in the business. Own riders, a courier partner, customer pickup, a scheduled batch: the business will try several of them over its life.

So dispatch sits behind a single interface. Each fulfilment model is one implementation, and which one runs is configuration, not code. Billing, notifications and reporting only see one event, "order dispatched," and don't care which model produced it. When the business changes how it delivers, it changes a setting, not the architecture.

Real-time where it earns its place

Live tracking is the feature customers notice most, and it's easy to overbuild. My split:

  • WebSockets for things that change by the second while the app is open: the rider's position, the order status, a restaurant's incoming order alert.
  • Push notifications for things that matter when the app is closed: "your order is on its way."
  • Plain requests for everything else. Not every screen needs a live connection.

The rider app gets extra care. It has to survive a dead zone in a stairwell, so actions queue on the device and sync when the signal comes back. That's the mobile version of building the web app like an appliance. One Expo codebase covers iOS and Android, which is the trade I keep making.

Guardrails that hold when everyone's tired

A delivery platform runs at its busiest when everyone involved is most tired: Friday night, holiday weekends, a rainstorm. So the quality bar can't depend on memory. It's enforced by machines:

GuardrailWhat it catches
Strict TypeScript, no anyShape mismatches between the API and every app
Shared validation schemasClient and server disagreeing about what's valid
Tests on the money and state pathsDouble charges, stuck orders, wrong refunds
Boundary checks in CIOne domain reaching into another's internals
Accessibility and translation checksScreens that fail people or fall back to the wrong language
Migrations that expand before they contractA deploy that breaks a live database

Walk through the order that arrives out of order

Consider a fictional failure drill: a customer pays, the browser loses the response, the customer retries, and a payment notification arrives after the kitchen has stopped accepting orders. This illustrates the architecture; it does not describe a confidential client incident.

A robust design has to answer four separate questions. Was money captured? Does an order already exist? Can the restaurant still fulfil it? If not, which recovery or refund workflow owns the next step? “Payment succeeded” is not permission to send food nobody agreed to make.

Use a stable operation identifier for retries, record provider events durably, and let the order's current state decide which transitions are allowed. Repeated notifications should not create repeated effects. A reconciliation job should find payments that lack a corresponding resolved order; the happy-path webhook cannot repair an event it never received.

Stripe's webhook documentation provides a concrete provider example of duplicate delivery and ordering considerations; check the equivalent rules for whichever provider you actually use. Database constraints enforce local invariants, while the domain workflow handles the gap between your database and an external payment system.

The lesson to borrow is the recovery model. An operator should see what happened, what is uncertain and which action is safe. A reassuring green tick with an unresolved payment behind it is just expensive decoration.

What I ask a founder before building one

The architecture above is the default, not the answer. Before I commit to it, I want five answers:

  1. Who delivers? Your own riders, a partner, pickup only, or a mix. This shapes dispatch more than anything else.
  2. Who gets paid, and when? Restaurants weekly, riders daily, refunds instantly? Payout rules decide the money model.
  3. How many restaurants on day one? Twenty restaurants and two thousand need very different onboarding.
  4. Where do orders come from? App, web, phone, or a partner marketplace. Each one is a different integration.
  5. What can never go wrong? Usually it's charging someone twice or losing an order. That's where the tests go first.

Building one?

If you're planning a delivery or ordering product, or you've outgrown the one you have, tell me what you're building. I'll tell you honestly which parts of this structure you need now, which can wait, and whether I'm the right person to build it.

ShareXLinkedIn
Prabir Singh, profile photo

Prabir Singh

Full-stack engineer in Mumbai. Twenty years in sales taught me why people buy; seven years of code taught me how to build what they'll pay for. I still read error messages the way I once read objections: as the most honest feedback of the day. More about me or start a project.

More work

Concept illustration of a racket-sports court, racket, ball and booking elements.
Architect and full-stack builder·2026

Racket Sports Platform

How I structure a racket sports platform: courts that sell like perishable stock, matches that feel fair, and money that settles to the cent

Full-stack buildArchitectureMobilePayments
Screenshot of the Kyloth public website, showing its Commerce introduction and product-family navigation.
Own product · In development·2026

Kyloth

A family of business tools I'm building, organised around everyday work

Full-stack buildArchitectureDesign systems