02 Case study Beta
Ludi
Book a five-a-side pitch in a few taps, fill the team with an open match, and give venue owners a calendar instead of a WhatsApp thread. A player app, an owner dashboard in Greek and a lobby TV, all on one Postgres database.
01 Problem
Five-a-side football in Cyprus runs on WhatsApp. Players chase a pitch, a time and enough people through group chats and Facebook events. Venue owners take bookings by phone and message, keep the calendar in their heads, and watch mid-week slots go empty.
Ludi ("games" in Latin) is a two-sided marketplace for that: an app where players find a pitch, book and pay, and fill the team, and a dashboard where owners run their week. It started in Larnaca, with five-a-side, in English and Greek from day one.
02 What I built
- The player app (iOS and Android, in beta): discover pitches (on a map on iOS), book and pay, share a fixture with an invite link that also works for friends without the app, open a game to the community board, and keep a record and player card with an Apple Wallet pass.
- The owner dashboard, in Greek or English and built for a phone: a week grid and printable day sheet, walk-ins logged from a free cell, standing bookings, earnings and payouts, venue enrolment, and staff access.
- The lobby TV: a public screen at a private link showing today's kick-offs and pitches in use, never with a player's name, and a QR code that opens the same screen on a phone.
- The money: card payments and venue payouts through Stripe Connect, built end to end and running in test mode until payments are switched on.
03 Architecture
A "fat database". Postgres does most of the work: availability, permissions, and the rule that a pitch can't be booked twice. Small edge functions handle the parts that move money or must happen atomically. Three surfaces sit on top, one per side of the market.
04 Key decisions
The invariant
Double-booking is impossible, not unlikely
- Context
- Two players tapping "book" on the same slot at the same moment is the one failure a booking app can't have.
- Decision
- Enforce it in Postgres with an exclusion constraint over pitch and time range, not in application code. A load test fires 100 simultaneous bookings at one slot: exactly one wins.
- Trade-off
- Every booking path must handle the database's refusal gracefully. That's far cheaper than a refund conversation.
ADR-0003
A fat database with row-level security
- Context
- One person building three surfaces can't afford a hand-written API layer for every read.
- Decision
- Let row-level security cover about 80% of operations directly, and keep edge functions for anything that moves money or must be atomic.
- Trade-off
- RLS has sharp edges, and one bit (see below). It's covered by 426 pgTAP tests.
ADR-0028
An open match is just a booking
- Context
- Players wanted to post "we need three more" and let strangers join.
- Decision
- No new tables: an open match is the booking row with one flag, surfaced through a view. Turning it off is a flag flip.
- Trade-off
- Match-only data such as results has nowhere to live yet. That's deliberate until results are built.
ADR-0033
Standing bookings without a scheduling engine
- Context
- Academies book the same pitch every week, and owners needed that on their calendar.
- Decision
- Materialise each standing booking as ordinary walk-in rows eight weeks ahead. The calendar, the invariant and the TV just work, and skipping a week is cancelling one row.
- Trade-off
- A rolling horizon to regenerate, instead of a recurrence engine everything would have to understand.
05 Hard problems
A refund that would have cost money
Two Stripe refund flags were misread. As shipped, the platform would have funded refunds while the venue kept its payout. The first fix, made from the documentation, swung it the other way and put the whole refund on the venue. A test-mode script that walks the money through every account caught that before a single real refund, and now every claim about money gets a run like it.
A view that ignored the rules
A reporting view ran with its owner's permissions, so row-level security didn't apply and any signed-in user could read daily revenue. The fix was one setting. The lesson became a pgTAP regression test, and a rule: test the claim "views inherit RLS" instead of trusting it.
A pre-launch audit
Before launch, a full audit pass found an anonymous key that could touch an internal sequence and trigger cleanup jobs, magic links that always failed, and paid extras pre-selected at checkout, which EU consumer law forbids. All were fixed before real money. The audit itself became the launch gate.
06 Outcome & what I'd do differently
The site went live at the end of August, and the apps are in beta: iOS on TestFlight, approved by Apple with the public release pending, and Android as a direct download. Two partner venues are enrolled on the owner dashboard. Payments are fully built and run in test mode until they are switched on. The repo is private, and I'm happy to do a walkthrough.
- Test the claims, not just the code. "Views inherit RLS" and a payment library's flag semantics were both trusted until they were tested.
- Migrations meet production data CI never sees. Constraint lists are now derived from the live definition.
- Close the gaps the audit named. The next step is a mobile test runner and an end-to-end payment test.