Case StudyFull-stack buildArchitectureMobilePayments

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

Client
Confidential client
Role
Architect and full-stack builder
Year
2026
Scope
Full-stack build, Architecture
Prabir Singh, profile photoPrabir Singh·10 min read
Concept illustration of a racket-sports court, racket, ball and booking elements.

Concept illustration

What it is
The general structure behind a racket sports platform I built for a client. Their product, data and numbers stay private.
Who it serves
Players who want a fair game, venues with courts to fill, organisers running leagues and tournaments, and the operator who keeps it honest.
Shape of the build
One monorepo: the apps on top, one typed API, a background worker, and domain packages underneath.
The core idea
The booking calendar is the easy part. The value lives in fair matching, bookings that can't collide, and money that reconciles.
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

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.

Two problems that fight each other

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.

Four people, four jobs

PersonWhat they needWhat I build for them
PlayerFind a court or a game in seconds, at the right levelA mobile app for iOS and Android
VenueFill courts, set prices, see who owes whatA venue portal that runs on a front-desk screen
OrganiserRun a league or a tournament without spreadsheetsEvent tools inside the venue and admin apps
OperatorSee everything, settle disputes, trust the numbersAn 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.

One repo, with the dependencies pointing down

Every app and service lives in one Turborepo monorepo, the setup I explain in one repo, every app.

Sports platform dependency map

Explore the connections · swipe or use arrow keys when focused

Sports 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 PACKAGESPlayer appWeb / mobileVenue toolsTask-specific interfacesOperator consoleRoles + operationsAPI + workerRequests + background jobsDomain packagesBookings · ratings · billingFoundationsTypes · 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.

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.

A booking is a hold, then a promise

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:

  • Hold. Tapping a slot reserves it for a few minutes while the player pays. Holds expire on their own, so abandoned checkouts give the slot back.
  • Confirm. Payment succeeds and the hold becomes a booking in the same transaction.
  • The database has the final word. A constraint makes overlapping bookings on one court impossible to store. App code can have a bad day. The constraint can't.

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.

Try the rule: adjacent slots are not overlapping slots

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 begins

Changing 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.

Fair games need a rating per sport

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:

Match lifecycle

Explore the connections · swipe or use arrow keys when focused

Match lifecycleA general match lifecycle. The rating update follows an agreed result or an operator’s dispute decision. A played match and a verified result are separate states. Double outlines mark terminal states.ProposedPlayers invitedConfirmedAll players acceptPlayedCourt time passesReportedResult submittedExpiredNo answer in timeCancelledBefore playDisputedResult challengedRatedRating changes savedaccepttimeoutcancelresult agreeddisagreeoperator decides
A general match lifecycle. The rating update follows an agreed result or an operator’s dispute decision. A played match and a verified result are separate states. Double outlines mark terminal states.

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.

Tournaments are data, not hard-coded rounds

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.

Two ways to get paid, one statement

Venues want to get paid in different ways, and a platform has to support both:

ModelMoney flowWhat the venue owes or is owed
Platform collectsPlayer pays online, platform pays the venue outPayout minus the platform's fee
Pay at the venuePlayer pays at the deskThe 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:

  • Amounts are integers in minor units, stored with their currency. 0.1 + 0.2 never touches a balance.
  • Every charge and payout carries an idempotency key, so a retried request can't charge twice.
  • Webhooks are signature-checked and recorded once.
  • Refunds and adjustments are new entries, not edits, so any balance can be explained line by line.

Real-time where it earns its place

  • WebSockets for things that change while the app is open: a slot being taken, a match invite, a live score.
  • Push notifications for things that matter when it's closed: "your game starts in an hour", "you've been invited".
  • Plain requests for everything else.

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.

Guardrails that hold on a busy Saturday

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:

GuardrailWhat it catches
Strict TypeScript, no anyAPI and app disagreeing about a shape
Shared validation schemasClient and server disagreeing about what's valid
A database constraint on overlapping bookingsTwo people on one court
Tests on brackets, ratings and moneyWrong winner advanced, ratings drifting, statements that don't add up
Boundary checks in CIOne domain reaching into another's internals
Real production builds in CI, not only type checksShared components that pass tsc and still crash a build

Two customers, one slot: a useful thought experiment

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.

What I ask a founder before building one

The structure above is my default, not the answer. Before committing to it, I want answers to these:

  1. Who's the first customer, players or venues? It decides which app gets polished first.
  2. How do venues want to be paid? One payment model or both changes the ledger from day one.
  3. Which sports, and do they share courts? A padel court and a tennis court are different inventory. A multi-use hall isn't.
  4. Is matchmaking a feature or the product? If people come to find opponents, ratings deserve real investment early.
  5. What can never go wrong? Usually a double booking or a wrong payout. That's where the tests go first.

Building one?

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.

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 restaurant, delivery scooter and apartment connected by an order route.
Architect and full-stack builder·2026

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

Full-stack buildArchitecturePaymentsMobile
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