
RacketMania
Booking, matchmaking, and tournaments for eight racket sports, from one codebase
A solo, full-stack rebuild of a live food, grocery, meal-subscription and dine-out business, built multi-country from the first commit

A live food-delivery business had outgrown the software it was built on. One country. One admin. A codebase that couldn't imagine a second market existing. I rebuilt the whole thing as a multi-country, multi-franchise commerce platform, solo, with an AI-assisted workflow, holding a senior engineering bar the entire way.
Client, brand, markets, and vendors are abstracted under NDA. The architecture and engineering are described faithfully.
If you only take one thing from this page: the hard part was never the food-ordering screen. The hard part was building a spine strong enough that seven more product lines could hang off it without cracking.
Picture the position the business was in. Orders were flowing. Customers were happy enough. And every time someone said "let's open a second country," the engineering answer was a quiet wince.
The legacy stack had four walls closing in:
Here is the part most rebuilds get wrong. They treat "add a second country" as a feature. It is not a feature. It is a property of every single row in the database. Retrofit it later and you are repricing the foundation of a house that already has people living in it.
So the brief I set was blunt: don't bolt multi-country on. Assume it from the first table. Assume a franchise owns cities, cities own zones, and every order, payment, menu item, and audit row knows which country, franchise, and city it belongs to before it knows anything else.
The business made one decision that shaped everything: no MVP. The relaunch had to land in the new market feature-complete, because a franchise partner and a regulated European launch don't get excited about a half-built app.
That turned "success" into a checklist a lot longer than a startup's:
Constraints like these look heavy. I read them the way the Jugaad builders do: a constraint is a design input. "Reconcilable against Stripe" isn't a burden, it's the thing that told me to build a double-entry ledger instead of a balance column. More on that below.
The whole system is one Turborepo monorepo with a strict rule: dependencies point downward, and nothing ever points back up. Apps depend on packages. Packages depend on lower-layer packages. No package imports an app. Ever. It's the same discipline I lean on everywhere, and I've written about why one repo beats a folder full of them.
apps/ → every runnable process (the API, the worker, 5 web apps, 3 mobile apps)
packages/ → 93 bounded contexts + shared foundation (money, ids, dates, contracts...)
tools/ → dev-time utilities (seed runners, dispatch simulator, migration tooling)
docs/ → 62 ADRs, architecture notes, runbooks, incident postmortemsThe reason there are 93 packages and not one giant src/ folder is the reason this project stayed sane. Each domain (orders, payments, dispatch, catalog, wallet, subscriptions, promos, dine-out, ads, and dozens more) is its own package with one public door. You can import what a package chooses to expose. Try to reach past that door into its internals and the compiler stops you before the code ever runs.
Tempting shortcut: spin up a separate backend per market. It feels clean for a week. Then a pricing bug needs fixing in five places, and five copies drift.
I chose one global API and one consumer app, host-routed. The country is resolved from the hostname at the edge, using a mapping that lives in exactly one file shared by the API and the web app. One promo engine. One dispatch engine. One place to fix a bug. The countries are data, not deployments.
Countries are rows and config, not separate deployments. The bug you fix once stays fixed everywhere.
A stack table tells you what. The interesting part is always why. Here's the table, then the three decisions that mattered most.
| Layer | Choice |
|---|---|
| Monorepo / tooling | Turborepo + pnpm workspaces, Node 22 runtime, tsx for TypeScript scripts |
| Backend | Hono + tRPC with an OpenAPI surface generated from the same schemas |
| Web | Next.js 16 App Router, React 19, Tailwind v4, shadcn/ui, Motion.dev, next-intl |
| Mobile | React Native + Expo + NativeWind |
| Data shapes | Zod as the single source of truth, feeding tRPC, OpenAPI, the ORM, and every form |
| Database | PostgreSQL 18 + Drizzle ORM |
| Auth | Better Auth (email/password + Google OAuth) plus phone OTP for riders |
| State | TanStack Query via tRPC for server state; Zustand for ephemeral UI only |
| Jobs + events | BullMQ + Redis, with Postgres LISTEN/NOTIFY as the internal event bus |
| Payments | Stripe, EU-first, SCA/PSD2 native |
| Real-time | Socket.IO (typed events) for in-app; Firebase push for out-of-app |
| Media | A CDN with EU data residency for images and video; S3-compatible storage for documents |
| Hosting | Self-hosted on a VPS via a container platform, not a serverless lock-in |
The obvious choice would have been the big opinionated backend framework everyone reaches for. I passed on it. Its decorator-and-dependency-injection model adds ceremony, and a platform with a dozen domains each wanting its own shape fights that opinion constantly. Hono plus tRPC plus disciplined package boundaries gives the same separation with far less machinery, and it starts fast enough to split into lean worker processes. The framework should get out of the way of the domain. This one does, and I've laid out the full case for Hono over Express elsewhere.
I evaluated the fashionable alternative honestly, and it won on a few things: typed routes, a unified router-and-query cache, fast dev server. Real advantages. I wrote up that whole comparison separately. I still picked Next.js 16, for reasons specific to this product:
I wrote the tradeoff down in an ADR, including what I gave up. Honest tradeoffs age better than pretending a choice was free.
Every order placement, state change, and payout fans out to notifications, analytics, and the audit trail. The textbook answer is a dedicated message broker. I started with Postgres LISTEN/NOTIFY plus a domain_events table instead.
Why the humble option? Because the events are persisted in the same transaction as the business change, which means replay and audit come for free, and there's no second system to keep alive at 3am. The ADR says plainly: move to a dedicated streaming broker when measured throughput demands it, and not one day sooner. Frugal by default. Upgrade on evidence, not on résumé anxiety.
Anyone can build an order form. The value in a platform like this is in the parts customers never see: the money being provably correct, the state machines that refuse to lose an order, the seam that lets delivery routing change without a redeploy.
The compiler is the cheapest test you will ever run. So I made it do real work, the same way I argue for one language across the whole stack.
Money is never a number. A naked number for a price is a floating-point bug waiting for a busy Friday. Every amount in the system is a Money value: a bigint in minor units (cents) plus its currency, together, always.
const tenEuros: Money = { amount: 1000n, currency: "EUR" };Add two Money values in different currencies and the helper throws, because silently converting would quietly lose real money. There is no float anywhere near a total.
IDs are branded, not bare strings. A customer ID and a merchant ID are both strings at runtime, but the type system treats them as different species.
assignOrderToRider(orderId, customerId) // ← compile error, not a 2am production incidentState machines are discriminated unions, not boolean flags. An order isn't a bag of isAccepted, isPickedUp booleans that can contradict each other. It's a tagged union where "Delivered" carries a proof-of-delivery URL and "Cancelled" carries a reason. Add a new state later and the compiler marches you to every place that now has to handle it. The machine refuses to forget a case.
Zod at every boundary, from one source. Request bodies, environment variables, third-party webhook payloads, queue messages, and form inputs all pass through Zod schemas that live in a shared contracts package. From one schema I get the TypeScript type, the tRPC input and output, the OpenAPI spec entry, and the client-side form validation. Change a field once and the compiler walks every consumer. The client validates against byte-for-byte the same rule the server enforces.
And any is a build error. Not a warning. A failure. If you need an escape hatch, you write down why, and it gets read in review.
Here's a decision I'm proud of, because it looks like overkill until the moment it saves you.
The naive wallet is one balance number per customer and a flat list of transactions. It can tell you what you moved. It cannot tell you whether what you moved is internally consistent, or whether it matches the payment processor. The day finance asks "does our money actually add up," the naive design has no answer that isn't a guess.
So the wallet is a proper double-entry ledger. Every money movement is a journal entry made of postings that sum to zero. A seven-account chart (customer wallets, a processor clearing account, promo funding, referral funding, gift-card float, manual adjustments, revenue control) means every euro has a source and a destination. A reconciliation job runs every fifteen minutes and checks three invariants in plain SQL: every entry sums to zero, every stored balance matches the sum of its postings, and every account has the sign it should. A second pass walks the processor's charges and opens a case when the ledger and the processor disagree.
The idempotency key sits at the entry level, not the posting level, so a retried webhook can't sneak half a transaction in. A reversal isn't a delete. It's a new mirrored entry, so the trail stays append-only and honest.
Delivery is where a delivery platform lives or dies, and it's also where requirements shift constantly. During a market migration, some zones ride on an external courier network and some run the platform's own fleet, and the switch is a per-zone decision, not a global flag. A daily-meals kitchen batches thirty lunch orders into one multi-drop route. A courier-on-demand errand has no merchant at all, just a pickup address.
Three completely different fulfillment shapes. One clean answer: a DispatchProvider interface with concrete implementations behind it (own fleet, external courier, and a route-optimized variant). Provider selection is keyed on (country, merchant type, fulfillment mode) and lives in a table, so operations can flip a zone from external courier to own riders by changing a row, no deploy required. Every provider emits the same downstream event, so billing, referrals, and franchise finance don't know or care which one ran.
I even built a standalone dispatch simulator as a dev tool, so I could tune the scoring and assignment logic against scenarios without touching production.
Some rules are non-negotiable and they're enforced at the data layer, not left to good intentions:
Five web apps. Three mobile apps. Consumer ordering, a super-admin console, a merchant portal, a fleet-operations dashboard, a corporate marketing site, plus the customer, rider, and merchant mobile apps. All of them share one design system. Not "similar styling." One.
Every interactive element (a button, a card, a dialog, a data table, an empty state) is a primitive from a single shared UI package. If a card needs a new variant, the variant is added to the package, never inlined onto one screen. That rule is why a fix to how a card lifts on hover lands everywhere at once, instead of in the one app someone remembered. That's the whole argument for a design system that earns its keep, proven across eight surfaces.
The visual language is consistent and specific: soft, rounded cards with restrained shadows, generous forty-four-pixel touch targets, semantic color tokens instead of raw hex, color-coded section icons so a settings screen reads at a glance. Motion is budgeted (short, respectful of a reduced-motion preference, never blocking a tap) rather than sprinkled for decoration.
WCAG 2.2 AA is the bar, checked by automated accessibility tests in the pipeline. Here's a small decision that shows the level: the brand's own accent teal fails AA as body text on white. So it's constrained to accents and large headings, and it never carries small text. The brand color bends to the contrast rule, not the other way around.
Three languages ship together in every change. Numbers, currencies, dates, relative times, and plurals all go through the platform's internationalization primitives, never hand-rolled, because one of the launch languages has plural rules that English-only logic quietly breaks. Layouts are tested against the longest translation, since one language runs about a third longer than English and a button that fits in English can overflow in the wild.
Every route has a budget: largest-contentful-paint under 2.5 seconds on mid-tier mobile, interaction latency under 200 milliseconds, layout shift under 0.1, JavaScript under 200 KB per route on the consumer app. These aren't aspirations taped to a wall. They're checked on every pull request, and a regression blocks the merge. Server calls carry their own latency budgets, and a database query slower than 100 milliseconds trips an alert and a review.
The three mobile apps are real React Native, built as development clients because they lean on native modules: maps, push, gestures, and reanimated motion. One codebase, both app stores, which is exactly the trade I keep making. Safe areas on every screen. Haptic feedback on the moments that matter (order placed, rider arriving). Bottom sheets for primary actions instead of modals. An offline queue so a cart change or a rating survives a dead spot in the subway and syncs on reconnect.
The platform isn't one product. It's a stack of them sharing one cart, one checkout, one identity, and one order history. A partial map:
| Surface | What it does |
|---|---|
| Food ordering | Full storefront, search, restaurant pages, cart, checkout, live order tracking |
| Grocery | A separate opt-in module with its own catalog, out-of-stock handling, and slots |
| Meal subscriptions | A recurring-meal engine with pause, skip, and plan management |
| Dine-out | Table reservations and native pre-order, as its own surface |
| B2B / corporate | Company accounts, allowances, and consolidated monthly invoicing |
| Delivery pass | A membership tier with member perks across the verticals |
| Dispatch | Own-fleet offers, batching, cascading declines, and the provider seam above |
| Rider ops | Shift booking, earnings, a demand heatmap, turn-by-turn navigation, an SOS safety tier |
| Retail advertising | Sponsored-listing auction, consent-gated serving, and click attribution |
| Wallet + payments | The double-entry ledger, refunds, gift cards, referrals, tips, and payouts |
| Franchise ops | City and zone management, settlement runs, and per-franchise finance |
| Trust + safety | Audit everywhere, soft-delete retention, fraud blocklists, and privacy tooling |
Each of those is a real domain package with its own contracts, state machine, background jobs, and tests. This is what "no MVP" looks like when you actually mean it.
Moving fast is easy for a week. Moving fast for three months across ~680,000 lines without the codebase turning to mud takes guardrails. Mine:
A case study that only lists wins is a brochure. Here are real potholes, because the recovery is where the judgment shows.
The database that wouldn't boot. A local Postgres upgrade started restart-looping with no clear error. The cause: a data volume written by an older major version that the new one flat-out refuses to read. Not a code bug, an operational trap. It went straight into a runbook so it costs the next person ten minutes instead of an afternoon.
The route collector that 404'd the entire app. A framework config option had quietly moved to the top level between versions. Left in its old spot, it was silently ignored, the route manifest was generated as "no routes exist," and every page returned a 404. The only signal was one soft warning buried in boot output. I documented the landmine in the coding rules so it can never eat a day twice.
The order alerts that reached the owner but not the staff. A merchant's live-order socket room was joined by the account owner only. Managers and staff on the same shop got silence. It was the fourth variant of the same underlying mistake: treating "owns the shop" and "is a member of the shop" as the same check when they are not. The fix was a shared helper that resolves every membership, and a note to myself that this class of bug hides in plain sight.
A payout path that could pay twice. In a settlement batch, a specific edge could double-count a payout. That one didn't get a quick patch. It got a failing test first, then the fix, then a pre-flight check before the migration could touch production money. Payments are exactly where "move fast" has to yield to "move provably."
Every one of these is now a runbook entry, a rule, or a regression test. That's the point. A bug you only fix is a bug you'll meet again. A bug you write down is a bug that pays rent.
I'm holding the production performance numbers as budgets the build was engineered and gated against, not as marketing metrics, because I'd rather show you the discipline than quote a figure I can't put in front of you.
If I ran it again, I'd stand up the dedicated end-to-end test suite earlier. It arrived once the flows stabilized, and a handful of the potholes above would have been caught a week sooner if it had been there from the start. I'd also invest in on-device mobile automation earlier, because the gap between "works in the simulator" and "works on a real phone in a real dead spot" is where the sneaky bugs live.
What came next was the unglamorous, decisive work: migrating a live operation off a decade-old monolith without dropping an order, reconciling real wallet balances against real charges, and flipping zones from an external courier to an owned fleet one at a time. The rebuild was the interesting part. The migration was the part that had to be right.
The screen a customer taps is maybe ten percent of a platform like this. The other ninety is the ledger that proves the money, the state machines that refuse to lose an order, the boundaries that keep a dozen domains from tangling, and the seams that let the business change its mind without a rewrite. That's the work. That's what I build.
If you're staring at a product that has outgrown the code it was born on, and you'd rather rebuild it once, correctly, than patch it forever, tell me what you're wrestling with. I'll give you an honest read on whether it's worth doing and how I'd approach it, whether or not we end up working together.
Prabir Singh
Full-stack engineer from Mumbai. Twenty years in sales, now I build products that sell. More about me or start a project.

Booking, matchmaking, and tournaments for eight racket sports, from one codebase

A family of business tools, organised around the work people do every day