Make the impossible move a refused call
A ride-hailing trip looks simple from the rider’s seat. Underneath, it’s a value that a rider app, a captain app, an ops console and an API all hold opinions about, at the same moment, while one of them drives through a tunnel and drops its connection. The engineering problem isn’t any single app. It’s keeping all of them agreeing on one trip.
The trip is a state machine, written down
In FuratGo the trip is the aggregate root, and its lifecycle is an explicit state machine:
pending → searching → accepted → en route → arrived → in progress → completed. Each transition is
a method that validates itself and stamps its own timestamp.
The point of that shape is where failure lands. An impossible move — completing a trip that was never accepted, accepting one that’s already been taken — becomes a refused call, not a bad row that someone discovers in a report a week later. Invalid states aren’t caught; they can’t be written.
One trip, one winner
Matching offers a trip to nearby captains from Redis GEO sequentially: one at a time, with a reservation so two concurrent requests can’t both be asked, and a per-offer timeout that advances the queue. Contested trips are the whole risk. If two captains can win the same trip, you have two cars at one kerb and an argument about who gets paid.
So “exactly one winner” isn’t an assumption. A command, app:concurrency-check, forks real parallel
processes against PostgreSQL and asserts one winner — and no overdraft on a wallet being debited from
several directions at once.
Why the test suite alone proved nothing
The suite runs on SQLite, where lockForUpdate is a no-op. Every concurrency test passed, and none
of them was testing concurrency. That’s the trap with locking code: the tests look the same whether
the lock works or not, because a single-threaded test never contends. The only honest check is real
processes against the real database.
Rules that fell out of it
Two rules in the shared mobile package are load-bearing, and both come from the same idea:
- The live channel is an optimisation; REST is the truth. Live updates arrive over Centrifugo, but on reconnect the app re-reads the trip instead of trusting the events it may have missed while it was in the tunnel.
- Never retry a POST. The retry interceptor is GET-only, because a duplicated settlement is money.
And the fare is computed from the recorded GPS trail, not the routing estimate. That’s also why I rejected batching trip points through Kafka later: settlement reads the points the moment the captain taps complete, and a batch window would race completion against the last point landing. The failure mode is silent under-charging, the worst kind, because nobody complains.
The principle
Put the rules where they can refuse. A state machine refuses impossible moves; a lock refuses a second winner; a GET-only retry refuses to charge twice. The design question for every write in the system is the same: when this goes wrong, is it a refused call, or a bad row?