Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ride Booking Service

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.


Architecture

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.


The two design patterns, and why they are there

Both were chosen because the domain already had the problem the pattern solves.

Strategy — fare calculation

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.

State — the ride lifecycle

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.


Tech stack

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?"


Running it

Requires Java 21, Maven and Docker.

docker compose up -d          # Postgres on host port 5434
mvn spring-boot:run           # http://localhost:8080

Flyway 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.yml and application.yml if you prefer.

Run the tests — Testcontainers starts its own throwaway Postgres, so docker compose is not required for these:

mvn test

API

All 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.

Example

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"
}

Status codes

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.


Testing

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 Clock bean 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.

Decisions worth defending

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.


What this does not do

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.

About

Ride-hailing REST API built with Java 21 and Spring Boot 3. Layered architecture over PostgreSQL with JPA and Flyway, Strategy-based fare pricing, an enum state machine for the ride lifecycle, and 157 tests written test-first with JUnit 5, Mockito and Testcontainers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages