Project Overview: A Bookings API
Plan a meeting-room bookings API end to end: the endpoints, the rules that matter, the packages and the dependencies, before writing any code.
com.example.bookings
BookingsApplication
room/ Room, RoomRepository, RoomController
booking/ Booking, BookingService, BookingController
web/ ApiExceptionHandler
security/ SecurityConfig
db/migration/V1__rooms_and_bookings.sqlThis stage builds one complete API from the pieces you have learnt: a bookings service where people reserve meeting rooms for a time slot. It is small enough to read in an afternoon and has the problems real APIs have: two people trying to book the same room at once, users who must only see their own data, input that has to be rejected cleanly, and a deployment that must survive restarts.
The requirements
- Anyone can list rooms. Only administrators can add them.
- A signed-in user books a room from a start time to an end time. Bookings for the same room must never overlap, even under concurrent requests.
- A booking must start at least 15 minutes in the future and last at most 8 hours. Both limits are configuration, not code.
- Users list, read and cancel their own bookings. Someone else's booking is reported as not found.
- Every error is a Problem Details (RFC 9457) document with a stable
typeURI clients can switch on.
The endpoints
| Method and path | Who | Success |
|---|---|---|
GET /api/rooms | anyone | 200 |
POST /api/rooms | ADMIN | 201 with Location |
POST /api/bookings | signed in | 201 with Location, 409 on overlap |
GET /api/bookings | signed in | 200, a page of the caller's bookings |
GET /api/bookings/{id} | owner | 200, else 404 |
POST /api/bookings/{id}/cancel | owner | 200 with the cancelled booking |
Cancelling is a state change on a resource, not a deletion, so it is a POST to a sub-resource and the row stays for history and reporting. A DELETE would invite clients to believe the booking is gone.
The design decisions
PostgreSQL holds the data, and a database exclusion constraint is the final guard against double booking: a check in Java alone cannot see a row another transaction is inserting at the same moment. Flyway owns the schema, and Hibernate only validates it. Tokens come from an external identity provider, so the API is an OAuth2 resource server that verifies JWTs and never stores passwords. Tests run against a real PostgreSQL in Docker through Testcontainers, because an in-memory database does not support the constraint the whole design rests on.
How the code is organised
Packages follow features, not layers: room, booking, plus shared web and security packages. Each feature holds its entity, repository, service, controller and DTOs. Controllers speak records, services own transactions and rules, and entities never leave the service layer, which keeps lazy loading and accidental field exposure out of the JSON.
The following lessons build it in order: persistence, endpoints and errors, security, tests, then production configuration. Each code block compiles on its own, so blocks repeat a trimmed copy of a type they depend on.
Example
import java.time.Clock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
public class BookingsApplication {
public static void main(String[] args) {
SpringApplication.run(BookingsApplication.class, args);
}
// Every rule that depends on "now" asks this clock, so tests can fix the time.
Clock clock() {
return Clock.systemUTC();
}
}
When to use it
- Kick off a new internal API by agreeing the endpoint table and error types with the front-end team before any Java is written.
- Use the bookings project as a reference layout when a team argues about package-by-layer versus package-by-feature.
- Run a training sprint where each developer implements one lesson's slice and the shared tests show when the slices fit together.
More examples
The dependencies
No entry has a <code>version</code>: the Spring Boot parent manages all of them. Flyway's PostgreSQL support is a separate module, and leaving out <code>flyway-database-postgresql</code> fails at startup with an unsupported database error. The <code>prometheus</code> actuator endpoint used in production only exists when <code>micrometer-registry-prometheus</code> is on the classpath. The test starters are split by technology in Spring Boot 4, so the web, JPA and security test support each need their own entry.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The package layout
Everything about bookings lives in one package, so a change to the booking rules touches one folder and the types can stay package-private. Only the controllers, the DTOs and the exceptions the error handler maps need to be public. If <code>room</code> starts reaching into <code>booking</code> internals, that is the signal to add a small public service method instead.
src/main/java/com/example/bookings/
BookingsApplication.java
room/
Room.java entity
RoomRepository.java
RoomController.java GET/POST /api/rooms
RoomView.java, CreateRoomRequest.java
booking/
Booking.java entity with cancel() rule
BookingStatus.java
BookingRepository.java overlap query, paged lookups
BookingPolicy.java notice and length rules, uses Clock
BookingRules.java @ConfigurationProperties("app.bookings")
BookingService.java transactions, mapping to BookingView
BookingController.java /api/bookings
BookingConflictException.java, BookingNotFoundException.java
web/
ApiExceptionHandler.java @RestControllerAdvice, Problem Details
security/
SecurityConfig.java SecurityFilterChain, JWT roles
src/main/resources/
application.yaml, application-prod.yaml
db/migration/V1__rooms_and_bookings.sql
A client's view of the main flow
Writing the conversation down first fixes the contract: status codes, the <code>Location</code> header, UTC timestamps in ISO-8601 and the error shape. Front-end developers can start against this before the server exists, and the tests in a later lesson assert exactly these details.
POST /api/bookings HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"roomId": 3, "startsAt": "2026-11-02T09:00:00Z", "endsAt": "2026-11-02T10:00:00Z"}
HTTP/1.1 201 Created
Location: https://bookings.example.com/api/bookings/118
Content-Type: application/json
{"id": 118, "roomId": 3, "roomName": "Lovelace", "startsAt": "2026-11-02T09:00:00Z",
"endsAt": "2026-11-02T10:00:00Z", "status": "CONFIRMED"}
POST /api/bookings HTTP/1.1 (a second user, same room, overlapping slot)
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type": "https://bookings.example.com/problems/booking-conflict",
"title": "Room already booked", "status": 409,
"detail": "Room 3 is already booked for part of that time.", "instance": "/api/bookings"}
Discussion