A REST API for a ride-hailing platform, built with Spring Boot 3 and Java 21 on Postgres. Riders request rides, the system prices them, captains accept and drive them, and the ride moves through a lifecycle that refuses to go backwards.
The point of the project is not CRUD. It is a domain with real rules — pricing that varies by demand and promotion, and a lifecycle where "complete a ride nobody started" has to be impossible rather than merely discouraged — implemented with clean layering and covered by tests written before the code.
Four layers, each depending only on the one beneath it:
HTTP
│
▼
┌─────────────────────────────────────────────────┐
│ api controllers, DTOs, error handling │ no business logic
├─────────────────────────────────────────────────┤
│ service use cases, transaction boundaries │ orchestration only
├─────────────────────────────────────────────────┤
│ domain entities, state machine, rules │ where decisions live
│ pricing fare strategies │
├─────────────────────────────────────────────────┤
│ repository Spring Data JPA interfaces │ persistence
└─────────────────────────────────────────────────┘
│
▼
Postgres (schema owned by Flyway migrations)
JPA entities never cross the API boundary. Requests arrive as DTOs and responses leave
as DTOs, so a column rename cannot silently rewrite the public JSON contract, and a
caller cannot set id, status or fareAmount by putting them in a request body.
Both were chosen because the domain already had the problem the pattern solves.
Fares depend on vehicle tier, demand and promotions. The naive implementation is one
calculateFare method whose if/else chain grows every time the business invents a
pricing rule, and which has to be retested end to end each time.
Instead there is a FareStrategy
interface with three implementations:
| Strategy | Applies when | Calculation |
|---|---|---|
PromoFareStrategy |
a recognised promo code is present | discounts the surge or standard fare |
SurgeFareStrategy |
surgeMultiplier > 1 |
standard fare × multiplier |
StandardFareStrategy |
always — the fallback | base + (rate × km), floored at the tier minimum |
FareStrategyResolver
receives every implementation as an injected List<FareStrategy>, already sorted by each
one's @Order, and picks the first whose supports() returns true. It names no concrete
strategy. Adding a fourth pricing rule means adding one annotated class and modifying
nothing — the Open/Closed Principle demonstrated rather than recited.
Surge composes Standard, and Promo composes both, rather than extending them. Composition
keeps the coupling at the public calculate contract instead of at each other's internals.
REQUESTED ──▶ ACCEPTED ──▶ ONGOING ──▶ COMPLETED
│ │
└─────────────┴────────▶ CANCELLED
A trip already underway cannot be cancelled — it completes, or it is settled outside this machine.
RideStatus is an enum where
each constant overrides allowedTransitions() with its own body. Every status change in
the system funnels through one private transitionTo method on the Ride entity, and
each public lifecycle method (accept, start, complete, cancel) also stamps its
matching timestamp — so state and timestamps cannot drift apart.
There is no setStatus. That is the entire point: the alternative is if (status == ACCEPTED || status == REQUESTED) checks scattered across the service layer, where two of
them eventually disagree.
This keeps Ride a rich domain model rather than an anemic bag of getters. The entity
enforces its own invariants instead of trusting every caller to remember the rules.
| Language | Java 21 (LTS) |
| Framework | Spring Boot 3.5.3 — Web, Data JPA, Validation |
| Database | PostgreSQL 16, schema versioned by Flyway |
| Build | Maven |
| Tests | JUnit 5, Mockito, AssertJ, MockMvc, Testcontainers |
No Lombok. DTOs and value objects are Java records, which give immutability,
constructor, accessors, equals/hashCode and toString as a language feature. Entities
are written out longhand because JPA requires a no-arg constructor and mutable fields —
which is also the honest answer to "why aren't the entities records?"
Requires Java 21, Maven and Docker.
docker compose up -d # Postgres on host port 5434
mvn spring-boot:run # http://localhost:8080Flyway applies the schema on startup. Hibernate runs with ddl-auto: validate, so it
verifies the entities match the migrated schema and fails fast on drift — it never
alters the schema itself.
Port 5434 rather than the usual 5432 because this project was developed on a machine where 5432 and 5433 were already taken. Change it in
docker-compose.ymlandapplication.ymlif you prefer.
Run the tests — Testcontainers starts its own throwaway Postgres, so docker compose
is not required for these:
mvn testAll paths are prefixed /api/v1.
| Method | Path | Purpose |
|---|---|---|
POST |
/riders |
Register a rider |
GET |
/riders/{id} |
Fetch a rider |
POST |
/captains |
Register a captain |
GET |
/captains/{id} |
Fetch a captain |
POST |
/captains/{id}/online |
Go on duty |
POST |
/captains/{id}/offline |
Go off duty |
GET |
/captains/available?vehicleType= |
List dispatchable captains |
POST |
/rides/quote |
Price a trip without booking |
POST |
/rides |
Book a ride → 201 + Location |
GET |
/rides/{id} |
Fetch a ride |
GET |
/rides?riderId= |
A rider's history, newest first |
POST |
/rides/{id}/accept |
Captain accepts |
POST |
/rides/{id}/start |
Trip begins |
POST |
/rides/{id}/complete |
Trip ends |
POST |
/rides/{id}/cancel |
Cancel, with optional reason |
Lifecycle changes are POSTs to named sub-resources, not a PATCH that sets a status
field. POST /rides/7/cancel says what happened; PATCH /rides/7 {"status":"CANCELLED"}
invites callers to invent their own transitions.
curl -X POST localhost:8080/api/v1/rides/quote \
-H 'Content-Type: application/json' \
-d '{
"vehicleType": "ECONOMY",
"pickup": {"latitude": 25.1972, "longitude": 55.2796},
"dropoff": {"latitude": 25.0805, "longitude": 55.1403},
"surgeMultiplier": 2.0,
"promoCode": "WELCOME10"
}'{
"vehicleType": "ECONOMY",
"distanceKm": 19.105,
"fareAmount": 60.59,
"currency": "AED",
"pricingStrategy": "PROMO"
}| Code | Meaning |
|---|---|
400 |
Validation failure, malformed JSON, or a trip that goes nowhere |
404 |
No such rider, captain or ride |
409 |
Conflicts with current state: illegal transition, busy captain, wrong vehicle class, duplicate phone, lost optimistic-lock race |
500 |
Unexpected — logged in full, never echoed to the caller |
Every error returns the same shape, so clients need no per-endpoint special cases:
{
"timestamp": "2026-08-11T09:00:00Z",
"status": 409,
"error": "Conflict",
"message": "Cannot move a ride from REQUESTED to COMPLETED",
"path": "/api/v1/rides/7/complete"
}Validation failures add a fieldErrors map reporting every invalid field at once,
rather than failing on the first.
157 tests, written test-first: the failing test came before the implementation.
| Layer | Approach | What it proves |
|---|---|---|
| Pricing & domain | Plain JUnit, no Spring | Rules are correct. Milliseconds to run. |
| Repository | @DataJpaTest + Testcontainers |
Mapping, entity graphs and constraints work against real Postgres |
| Service | Mockito | Orchestration is right, with only I/O mocked |
| Controller | @WebMvcTest + MockMvc |
Status codes, JSON shape, validation, error mapping |
| End-to-end | @SpringBootTest + TestRestTemplate |
The whole stack works at the seams |
Deliberate choices worth naming:
- Testcontainers, not H2. An in-memory database is faster, but it has different type coercion and constraint behaviour — passing tests against a database nobody deploys.
- Mock only the boundaries. Service tests use the real pricing strategies; they are pure functions, and mocking them would test nothing but the mocks.
- Time is injected. A
Clockbean means tests assert exact timestamps instead of sleeping or accepting flakiness. - The end-to-end test is not
@Transactional. Requests cross a socket to a servlet container on other threads, so a test-managed transaction would be invisible to them and rollback would hide the commit behaviour under test. It cleans the database explicitly.
BigDecimal, never double, for money. 0.1 + 0.2 in binary floating point is
0.30000000000000004. Across millions of fares that drift is real money. Money
normalises scale in its compact constructor, because BigDecimal.equals compares scale —
so 10.0 and 10.00 would otherwise be unequal, and the bug would first appear as a
baffling test failure.
Optimistic locking on Ride. A @Version column makes Hibernate add
WHERE version = ? to every update. Two captains accepting the same ride means the second
transaction matches zero rows and fails loudly, instead of silently overwriting the first.
@EntityGraph on ride lookups. Both associations are LAZY, so a plain findById
followed by reading the rider fires a second query — and doing that in a loop is the
classic N+1. The entity graph joins them up front. The repository tests assert this with
Hibernate.isInitialized, so the guarantee is verified rather than assumed.
EnumType.STRING, never the default ORDINAL. Ordinal stores an enum's position,
so reordering the enum silently rewrites the meaning of every existing row.
Flyway, not ddl-auto: update. The schema is checked-in SQL, reviewable in a pull
request and identical in every environment.
Entity equals/hashCode by id, with a constant hashCode. Deriving the hash from
the id would change it the moment the database assigns one, making an entity added to a
HashSet before saving unfindable in that set afterwards.
Honest scope, since a reviewer will notice:
- No authentication or authorisation — every endpoint is open.
- Captain assignment is manual. There is no dispatch algorithm choosing the nearest driver.
- Distance is straight-line (haversine), not routed. Swapping in a routing service is a
one-class change to
DistanceCalculator, which is why it is behind its own class. - Surge multipliers arrive in the request. A real system computes them from live demand.
- Promo codes are an in-memory map, not a table with budgets and expiry dates.
- No pagination on ride history.