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.

Syntaxcom.example.bookings BookingsApplication room/ Room, RoomRepository, RoomController booking/ Booking, BookingService, BookingController web/ ApiExceptionHandler security/ SecurityConfig db/migration/V1__rooms_and_bookings.sql

This 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 type URI clients can switch on.

The endpoints

Method and pathWhoSuccess
GET /api/roomsanyone200
POST /api/roomsADMIN201 with Location
POST /api/bookingssigned in201 with Location, 409 on overlap
GET /api/bookingssigned in200, a page of the caller's bookings
GET /api/bookings/{id}owner200, else 404
POST /api/bookings/{id}/cancelowner200 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

Example · java
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;

@SpringBootApplication
@ConfigurationPropertiesScan
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.
    @Bean
    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.

Example · xml
<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.

Example · text
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.

Example · http
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

  • Be the first to comment on this lesson.