Case Study
A ride only works if four things that don't trust each other — rider, driver, trip, fare — agree on what happened, in order, without any one of them blocking the rest.
Backend for a ride-hailing platform: rider onboarding, driver matching, trip booking, and fare calculation as Spring Boot services with explicit boundaries.
Request lifecycle — one ride, in order
Where the boundaries are drawn
Why is driver matching a separate service from trip booking?
Matching and Trip are separate Spring Boot service layers with their own controllers, service classes, and data responsibilities. Matching Service owns driver availability; Trip Service owns ride lifecycle. They communicate via a direct service call at the handoff point.
Matching is a search problem — it may need to retry, time out, or fan out to multiple driver queries before finding a result. If matching logic lived inside the trip booking layer, a slow or failed search would block the booking flow even for rides that could otherwise proceed. Keeping them separate means matching can fail and retry without leaving a half-created trip record behind.
The handoff between Matching and Trip is a synchronous call, which means if Trip Service is slow to create the ride record, the driver sees a delayed confirmation. An event-driven handoff (Matching publishes a 'driver accepted' event, Trip consumes it) would decouple this but adds a message broker to the stack — a complexity tradeoff not worth it at this scale.
Why does fare calculation read the trip record instead of the original request?
Fare Service depends only on the completed Trip record. It has no direct dependency on the original booking request or on Rider Service.
A requested ride and the ride that actually happened can differ — the driver might take a longer route, the trip might be cancelled and restarted, or the end time might differ significantly from an estimated duration. Calculating fare from the original request would price what was asked for, not what occurred. Reading from the Trip record means fare always reflects reality.
Fare can only be calculated after Trip Service marks a trip ENDED, which creates a short window where a trip has ended in the real world but no fare exists yet. The frontend polls the fare endpoint after receiving a trip-ended confirmation; if the fare isn't ready within a timeout, it displays a 'calculating fare' state. This eventual-consistency window is acceptable given that the alternative (synchronous fare calculation at trip end) would block the trip-end confirmation response.
Why does Rider Service own the outstanding-balance check instead of Fare Service?
Rider Service is the gatekeeper for all ride requests. It checks outstanding balance as part of rider eligibility, not as part of fare processing.
If fare enforcement lived in Fare Service, a rider could successfully request a new ride while an unpaid fare was still being processed — the two services would have a race condition. Placing the check in Rider Service at request time ensures that eligibility is always evaluated against a consistent, settled state before any downstream work begins.
Rider Service needs to know about fare records to do this check, which means it either calls Fare Service or reads a balance field that Fare Service updates. The current design has Fare Service write back to Rider Service after each fare settlement, which creates a coupling point. A cleaner approach would be an event — Fare Service emits 'fare settled', Rider Service updates balance — but again this requires a message broker.
What broke when a boundary was wrong
Backend
Architecture
Infra
Database