
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
How I structure a racket sports platform: courts that sell like perishable stock, matches that feel fair, and money that settles to the cent

Concept illustration
A court calendar looks like the product. It isn't. It's the shop window. The product is everything that decides whether a Tuesday 7pm slot gets sold, whether the game on it is any fun, and whether the venue gets paid the right amount at the end of the month.
I built a racket sports platform for a client, and the product itself stays theirs. So this page doesn't describe it. 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.
Racket sports have a booking problem and a matching problem, and they pull in opposite directions.
Booking is a supply problem. A court hour is perishable stock, like an airline seat. An empty slot at 7pm is money that evaporates at 8pm, so venues want every slot sold.
Matching is a fairness problem. Nobody enjoys a game against someone three levels above them. A 6-0, 6-0 wastes four people's evening, and the losing pair doesn't come back. So "find me a game" has to mean "find me a game worth playing", and that has to be measured, not guessed.
Fill every court and you get lopsided games. Protect every game and courts sit empty. The architecture exists to hold both.
| Person | What they need | What I build for them |
|---|---|---|
| Player | Find a court or a game in seconds, at the right level | A mobile app for iOS and Android |
| Venue | Fill courts, set prices, see who owes what | A venue portal that runs on a front-desk screen |
| Organiser | Run a league or a tournament without spreadsheets | Event tools inside the venue and admin apps |
| Operator | See everything, settle disputes, trust the numbers | An admin console with roles and an audit trail |
Players get the shiny app, but venues decide whether the platform lives. A player tolerates a clunky screen. A venue owner who can't see last month's money in one place starts looking elsewhere. That's why I build for the buyer, not just the user.
Every app and service lives in one Turborepo monorepo, the setup I explain in one repo, every app.
Explore the connections · swipe or use arrow keys when focused
Dependencies point down, never up. Apps depend on packages, packages depend on lower packages, and reaching into another domain's internals fails the build. Racket sports domains are tangled on purpose: a match books a court, a court booking creates a charge, a charge lands on a venue's statement. Without hard walls, one change starts breaking something two rooms away.
The API is Hono, for reasons I've written up separately. Every response goes out in the same envelope, so every app checks one field to know whether a call worked.
The classic bug in booking software: two people tap the same slot within a second, both see "confirmed", and one of them turns up to a court that's already taken.
So a booking moves through stages instead of flipping a flag:
Time zones get the same respect. Slots are stored as absolute instants and shown in the venue's local time, so a daylight-saving weekend doesn't quietly double-book an hour.
Illustrative TypeScript, written for this article. These numbers are minutes on a fictional scheduling timeline. They are not a production date or timezone model.
type Slot = { start: number; end: number };
function overlaps(a: Slot, b: Slot): boolean {
if (a.start >= a.end || b.start >= b.end) {
throw new Error("A slot must end after it starts");
}
return a.start < b.end && b.start < a.end;
}
console.log(overlaps({ start: 60, end: 120 }, { start: 120, end: 180 }));
// false: one booking ends exactly when the next beginsChanging the second start to 119 returns true. That one minute belongs to both players; the receptionist would prefer the database to notice first.
The interval includes its start and excludes its end. This is the half-open convention. For a real booking service, store valid instants, account for buffers and closures, and enforce conflicts in the database transaction. An overlap check in JavaScript cannot prevent two concurrent requests from both passing. PostgreSQL’s range exclusion constraints provide the relevant database building block.
A good badminton player isn't automatically a good padel player. So a player doesn't have one rating. They have one per sport, and each rating carries two numbers: an estimate of skill and how sure the system is about it.
New players start uncertain, so their first results move them fast. Regulars move slowly, so one bad evening doesn't wreck a season's worth of evidence. Matchmaking then proposes games where both sides are close enough that the outcome is genuinely open.
A match has a life of its own, and it's a state machine, not a pile of booleans:
Explore the connections · swipe or use arrow keys when focused
A general match lifecycle. Ratings only change once a result is agreed, so nobody games the ladder by reporting a win the other side never confirmed.
In TypeScript each state is a branch of a discriminated union, so a disputed match carries its dispute and a rated one carries its rating changes. Add a state and the compiler walks you to every screen that has to handle it. That's one language across the stack paying rent.
A tournament engine that only knows an eight-player knockout breaks the first time eleven people sign up.
So a bracket is generated from the entry list: the size rounds up to the next power of two, the gaps become byes for the top seeds, and every round is derived rather than written by hand. Round-robin groups, knockouts and leagues are different generators feeding the same match records, so results, ratings and schedules work the same way whichever format an organiser picks.
The tests that matter most here are the odd sizes: three entries, nine entries, a withdrawal halfway through. Those are where a hand-built bracket quietly advances the wrong player.
Venues want to get paid in different ways, and a platform has to support both:
| Model | Money flow | What the venue owes or is owed |
|---|---|---|
| Platform collects | Player pays online, platform pays the venue out | Payout minus the platform's fee |
| Pay at the venue | Player pays at the desk | The platform's commission, invoiced |
Two money trails pointing in opposite directions are a reconciliation headache. A venue using both shouldn't have to compare two documents every month. So both feed one ledger, and the venue gets one statement with one net figure: we owe you, or you owe us.
The rules underneath are the same ones I use for any money:
0.1 + 0.2 never touches a balance.The player app is one Expo codebase for iOS and Android, sharing types with the web apps. It's the trade I keep making, and the shared UI package keeps all of it visually consistent, the way I describe in design systems that earn their keep.
Peak hours for a sports platform are weekend mornings and weekday evenings, exactly when nobody has time to debug. So quality is enforced by machines:
| Guardrail | What it catches |
|---|---|
Strict TypeScript, no any | API and app disagreeing about a shape |
| Shared validation schemas | Client and server disagreeing about what's valid |
| A database constraint on overlapping bookings | Two people on one court |
| Tests on brackets, ratings and money | Wrong winner advanced, ratings drifting, statements that don't add up |
| Boundary checks in CI | One domain reaching into another's internals |
| Real production builds in CI, not only type checks | Shared components that pass tsc and still crash a build |
Imagine two players request the same court from 19:00 to 20:00. Both screens showed it available a moment ago. That screen was a snapshot, not a reservation.
The database must decide who acquires the active hold. The losing request gets an understandable conflict and refreshed availability. Now add the awkward part: the winning player's payment confirmation arrives after the hold expires and someone else acquires it. Do not resurrect the expired reservation automatically. Recheck ownership and send the payment into an explicit recovery or refund path.
This is a hypothetical design drill, not a disclosed client event. Test it with simultaneous requests, delayed notifications and a deliberately stopped expiry worker. Make expired holds transition out of the protected active set; relying on the calendar UI to hide them does not release inventory.
PostgreSQL's range types and exclusion constraints are useful building blocks. Half-open intervals (start included, end excluded) allow 19:00–20:00 and 20:00–21:00 to meet without overlapping. Buffer time, maintenance closures and coaching reservations still need explicit modelling.
Compare this with the food-delivery recovery problem. Both systems coordinate limited capacity and money, but their cancellation and recovery rules should not be copied blindly. The database protects an invariant; the business decides what to do when reality arrives late.
The structure above is my default, not the answer. Before committing to it, I want answers to these:
If you're planning a booking, sports or marketplace product, or the one you have is creaking, 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 food delivery platform: one API, an app for every person in the order, and the quiet machinery customers never see

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