
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
How I structure a food delivery platform: one API, an app for every person in the order, and the quiet machinery customers never see

Concept illustration
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.
A delivery order isn't a transaction between two parties. Four people touch it, and each one wants something different from the software.
| Person | What they need | What I build for them |
|---|---|---|
| Customer | Find food, pay in seconds, know where it is | A web storefront and a mobile app |
| Restaurant | Accept orders fast, manage the menu, see the money | A merchant portal that works on a kitchen tablet |
| Rider | Know where to go next and what they've earned | A mobile app built for one hand and bad signal |
| Operator | See everything, fix anything, trust the numbers | An 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.
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.
Explore the connections · swipe or use arrow keys when focused
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 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:
Explore the connections · swipe or use arrow keys when focused
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.
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); // trueThe 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.
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:
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.
Live tracking is the feature customers notice most, and it's easy to overbuild. My split:
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.
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:
| Guardrail | What it catches |
|---|---|
Strict TypeScript, no any | Shape mismatches between the API and every app |
| Shared validation schemas | Client and server disagreeing about what's valid |
| Tests on the money and state paths | Double charges, stuck orders, wrong refunds |
| Boundary checks in CI | One domain reaching into another's internals |
| Accessibility and translation checks | Screens that fail people or fall back to the wrong language |
| Migrations that expand before they contract | A deploy that breaks a live database |
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.
The architecture above is the default, not the answer. Before I commit to it, I want five answers:
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.
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.

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

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