Jet ski marketplace
Jet ski rentals from listing to payout: three applications over a single backend, with the money path treated as the part that allows no rehearsal.
GoJet brings together people with an idle jet ski and people who want a day on the water, and absorbs the tedious part: booking, payment, deposit, verified identity and reviews on both sides. Three applications, web, iPhone and Android, sit on a single Java backend that owns the data and the contracts. Before calling it done, we ran the entire journey against the live API: 22 of 22 steps, payment and refund included.
- 22 of 22Business journey steps executed against the live API
- 792Green automated tests on the backend, architecture tests included
- 225 of 225App tests in the audit's measurement, run serially
- 14Business domains in the backend, from identity to insurance
- 14 minutesFrom push to backend live: continuous integration plus image swap
An idle jet ski on one side, a handshake in the dark on the other
Anyone who owns a jet ski knows it spends more time in the garage than on the water. Anyone who wants to rent one for a weekend usually ends up in a WhatsApp group, agrees on a cash price and hopes for the best. There is no contract, no deposit, nobody checks who is on the other side, and the calendar lives in the owner's head, until the day two people book the same Saturday.
What is missing in that arrangement is not an app, it is trust. And trust, in a two-sided marketplace, is not a slogan: it is order of execution, database constraints and proof of ownership. That is how we treated the problem from day one: the discomfort stays with the platform, not with two people who only wanted a Sunday on the water.
The product was designed around three people: the one who rents, the one who lists, and the team behind the curtain handling moderation, identity checks, insurance and fraud signals. The first two meet at the moment of booking; the third never shows up on a customer's screen, and that is precisely why it has to exist.
A booking only exists once the money is in
The money path allows no rehearsal. A single booking splits value between platform and owner, holds a deposit on the card before pickup, and refunds when it falls through. Getting it wrong here does not produce an ugly log, it produces a double charge on somebody's card, and that is not undone with a deploy.
We chose Stripe Connect with destination charges and an application fee: the same transaction leaves already split between platform and owner, instead of arriving whole and being handed back later. The deposit is an off-session authorization on the card saved as a customer of the provider, and a cancellation reverses the original transfer rather than issuing a fresh payment in the opposite direction.
Booking confirmation does not come from the user's click: it comes from the payment provider's callback, signature-verified before any processing and idempotent by key: a replay of the same event does not confirm twice. The journey of paying, receiving the callback, seeing the booking confirmed and then refunding it was executed end to end in production, still in test mode, on 2 July 2026.
One small detail that only shows up under concurrency: the deposit gained optimistic version locking, and the record is written and flushed to the database before the gateway is called, in a dedicated migration. So when two requests race for the same deposit, the loser gets a conflict error without a single cent having moved. The order of operations is the guarantee; the error message is only the consequence.
The rule the database refuses to break
Two people never book the same dates for the same jet ski. That rule lives in the service that checks date conflicts, as you would expect, but it also lives inside PostgreSQL, as an exclusion constraint over a date range inclusive on both ends, backed by the btree_gist extension. A code path that forgot the check simply could not write the row.
That choice came at a price, and the price is worth telling: the H2 database that runs the fast test suite does not understand that syntax. So the migration moved to a sibling directory, versioned per database vendor: sibling, not subdirectory, because a folder inside the default path would be picked up twice by Flyway's recursive scan and the database would refuse to boot, complaining about a duplicate version.
The backend is hexagonal, organized by business module: 14 domains, from identity and fleet to payments, insurance and fraud. The boundaries between them do not rely on discipline: they are verified by an architecture test that breaks the build on purpose when one module reaches into another's internals. The schema evolves only through numbered, immutable migrations; the next one to be written is number 62, and an applied migration is never renumbered, because Flyway would refuse to boot over a checksum mismatch.
One account, two roles, and authorization that decides by the set
The same person rents one weekend and lists the next. The owner role is switched on whenever they want, after a verification, with no second account and no second login. It is easy to promise on screen and treacherous to hold up underneath.
We learned that from a bug of our own. Authorization was deciding by the active role, the one the interface uses only to know which mode the person is browsing in; the result was that a user holding both roles, in owner mode, got a 403 on every attempt to book. The invariant became: access is decided by the set of roles in the session, through a single predicate, at all three enforcement points: the routing middleware, the page guard and the proxy to the backend. The active role was confined to landing and redirects.
An instruction written in a document is not a technical barrier, so the rule got a mechanical guard: a lint rule fails the check if the role cookie is read outside the files entitled to touch it, and a unit test pins the predicate's behaviour. In the same spirit, identity verification defaults to manual and fail-safe, with no real provider configured, whoever asks to become an owner stays pending until an administrator approves. The previous default approved on its own; we changed it, and pinned the variable explicitly in the production environment.
Three applications, one contract
The backend is Java 21 with Spring Boot over PostgreSQL 16, and it owns the data and the contracts. The website is Next.js acting as a BFF: the browser never talks to the backend directly, the session lives in an httpOnly cookie, converted on the server into an authorization header, and the token never reaches the browser's local storage. The app, in Expo and React Native, talks straight to the API with a token, no intermediary.
Three clients over a single contract have one well-known way of drifting apart: somebody changes the contract and mentions it afterwards. That is why every contract change requires a decision written down in the backend repository, before the code. It is bureaucratic on purpose, and it is what keeps the business rules with one single source.
Some safeguards only surface once the product is live. Every call from the BFF to the backend carries an 8-second timeout that turns into an explicit unavailability error: without it, the function hangs until the platform's own limit and the user gets a generic message that helps nobody. And an ambiguous error code never enters the global dictionary: the backend answers unauthorized both for a wrong password and for an expired session, and translating that in one place would leak the invalid-login phrase into some 20 call sites that have nothing to do with logging in.
We audited our own product before selling it
Before calling it done, we put the ecosystem through a journey audit against a real stack, not a code review. One complete run covered 22 of 22 steps through the API: the owner creates from scratch and publishes, the renter books, pays, posts the deposit, completes and reviews, and a second cycle cancels with a refund. On the backend the suite closes at 792 green tests, architecture tests included; on the app it was 225 of 225 in the audit's measurement.
The part that taught us most was the recorded visual suite: eight videos and a continuous 41-step tour in which the renter rents exactly the jet ski the owner has just published. One of those journeys ends red on purpose: problems are recorded with soft assertions, so the video captures the whole cycle and the report still flags the defect at the end. An audit suite that hides a defect to stay green audits nothing.
The audit found real things, and the most serious one sat on the money path: a checkout endpoint accepted an order identifier without checking ownership and passed the client-supplied idempotency key straight through: an opening for one renter to pay another's order, and a window for a double charge. We fixed it with an ownership check, a uniform 404 so identifiers cannot be enumerated, and a deduplication key derived on the server. We also learned that probing process health is not probing data: the backend reports healthy in about 21 seconds, before the sample data finishes loading, so the script now waits for search to return results.
Where it stands today, and what is left before opening the doors
GoJet is a production pilot, not a mock-up. The backend runs on a VPS with Docker Compose and Caddy behind its own certificate; the website is on Vercel; and the measured ruler between a push and the code being live, on the backend, is 14 minutes: about ten of continuous integration plus four until the container supervisor swaps the image, measured on 31 July 2026. The payment provider is already deployed in production, still with test keys.
What is left is largely commercial and paperwork: switching to live keys, completing the platform's profile with the provider and taking owners back through its onboarding, publishing the app in the stores, and having the terms reviewed by a lawyer. There is a written runbook for that switch, because the time to discover a forgotten step is not with real money on the path.
And there are gaps we would rather declare than dress up. An owner who only uses the app still cannot finish payout activation there: the provider's hosted onboarding is not exposed in it, and today the path goes through the website. A booking created and completed on the same day is also out of reach, because the rule barring a start date in the past and the rule requiring the rental to be over cancel each other out. Both are queued, with a known fix.
A whole marketplace, from the first click to the payout in the owner's account, running in production with the money path treated as the money path: verified step by step against the live API, and with the remaining gaps written down instead of hidden.