Domain Model and Persistence
Model rooms and bookings as JPA entities, create the schema with Flyway, and stop double bookings with a PostgreSQL exclusion constraint.
@Entity class Booking {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@ManyToOne(fetch = FetchType.LAZY) Room room;
@Enumerated(EnumType.STRING) BookingStatus status;
@Version long version;
}The domain has two entities. A Room has a name and a capacity. A Booking belongs to one room and one user, has a start and an end, and is either CONFIRMED or CANCELLED. The user is stored as the token's subject string: the identity provider owns user records, so there is no users table to keep in sync.
Times are instants
Store start and end as Instant in timestamptz columns. Bookings are absolute moments; a LocalDateTime has no zone, so a server in another region, or a daylight saving change, silently shifts every booking. Clients send UTC and convert for display.
Flyway owns the schema
The migration creates the tables, a check that the end is after the start, and an exclusion constraint. Hibernate runs with ddl-auto=validate, so a mismatch between entity and table fails at startup instead of on the first request. Generating the schema from entities is fine for a spike and wrong for a service with data: it cannot express the constraint this design depends on, and it never removes or renames anything safely.
Why a constraint and not just a query
The service checks for an overlapping booking before inserting, which gives a fast, friendly 409 in the common case. That check cannot be the guarantee. Two requests for the same slot can both run the query, both see nothing, and both insert. The exclusion constraint makes PostgreSQL reject the second insert, whatever the timing:
exclude using gist (room_id with =, tstzrange(starts_at, ends_at) with &&)
where (status = 'CONFIRMED')
It reads: no two confirmed rows may have the same room and overlapping ranges. tstzrange is half-open by default, so a booking ending at 10:00 and another starting at 10:00 do not clash. Cancelled bookings drop out of the constraint, so the slot frees up. The btree_gist extension lets a plain equality on room_id take part in a GiST index.
The violation surfaces in Spring as a DataIntegrityViolationException. The service calls saveAndFlush so the insert is guaranteed to run inside the try block, and turns the violation into the same domain exception the pre-check throws. With IDENTITY keys Hibernate happens to insert straight away, but with sequence keys a plain save leaves the insert until commit, after the method has returned; saveAndFlush keeps the code correct whichever strategy is used.
Mistakes to avoid
- Returning entities from the service. With
spring.jpa.open-in-view=false, a lazyroomtouched by Jackson throws; with it on, every serialised association is a hidden query. Map to records inside the transaction. - A
@Versioncolumn without handling the conflict. Two cancels of the same booking race; one gets an optimistic locking failure, which the error handler maps to 409. - Forgetting an index for the main read. Users list their own bookings by start time, so
(owner_id, starts_at)is indexed.
Example
import java.net.URI;
import java.time.Instant;
import java.util.Optional;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.http.HttpStatus;
import org.springframework.web.ErrorResponseException;
(name = "rooms")
class Room {
(strategy = GenerationType.IDENTITY)
private Long id;
(nullable = false, unique = true)
private String name;
private int capacity;
protected Room() {
}
Room(String name, int capacity) {
this.name = name;
this.capacity = capacity;
}
Long getId() { return id; }
String getName() { return name; }
int getCapacity() { return capacity; }
}
enum BookingStatus { CONFIRMED, CANCELLED }
// Carries its own Problem Details, so the error handler renders it as a 409.
class BookingNotCancellableException extends ErrorResponseException {
BookingNotCancellableException(Long id) {
super(HttpStatus.CONFLICT);
setType(URI.create("https://bookings.example.com/problems/booking-started"));
setTitle("Booking already started");
setDetail("Booking " + id + " has already started.");
}
}
(name = "bookings")
public class Booking {
(strategy = GenerationType.IDENTITY)
private Long id;
(fetch = FetchType.LAZY, optional = false)
(name = "room_id")
private Room room;
(name = "owner_id", nullable = false, updatable = false)
private String ownerId;
(name = "starts_at", nullable = false)
private Instant startsAt;
(name = "ends_at", nullable = false)
private Instant endsAt;
(EnumType.STRING)
(nullable = false)
private BookingStatus status;
private long version;
protected Booking() {
}
Booking(Room room, String ownerId, Instant startsAt, Instant endsAt) {
this.room = room;
this.ownerId = ownerId;
this.startsAt = startsAt;
this.endsAt = endsAt;
this.status = BookingStatus.CONFIRMED;
}
// The rule lives on the entity, so every caller gets it.
void cancel(Instant now) {
if (status == BookingStatus.CANCELLED) {
return; // cancelling twice is harmless
}
if (!startsAt.isAfter(now)) {
throw new BookingNotCancellableException(id);
}
status = BookingStatus.CANCELLED;
}
boolean isOwnedBy(String subject) { return ownerId.equals(subject); }
Long getId() { return id; }
Room getRoom() { return room; }
String getOwnerId() { return ownerId; }
Instant getStartsAt() { return startsAt; }
Instant getEndsAt() { return endsAt; }
BookingStatus getStatus() { return status; }
}
interface BookingRepository extends JpaRepository<Booking, Long> {
("""
select count(b) > 0 from Booking b
where b.room.id = :roomId
and b.status = :status
and b.startsAt < :endsAt
and b.endsAt > :startsAt
""")
boolean existsOverlap(long roomId, BookingStatus status, Instant startsAt, Instant endsAt);
// Loads the room in the same query, so mapping a page to DTOs is one SELECT.
(attributePaths = "room")
Page<Booking> findByOwnerIdAndStartsAtGreaterThanEqual(String ownerId, Instant from, Pageable pageable);
(attributePaths = "room")
Optional<Booking> findWithRoomById(long id);
}
When to use it
- Prevent two teams from booking the same conference room when both click Confirm within the same second.
- Free up a slot immediately when a booking is cancelled, while keeping the cancelled row for usage reports.
- Catch a renamed column at application startup in staging, because Hibernate validates the schema Flyway built.
More examples
The Flyway migration
The constraint names matter: when a violation is logged, <code>bookings_no_overlap</code> tells you at once what happened. Creating an extension needs a privileged role on some managed databases, so check that before the first deployment, or ask the platform team to enable <code>btree_gist</code> once. Never edit this file after it has run anywhere; add <code>V2__...</code> instead, because Flyway checks every applied migration's checksum.
-- src/main/resources/db/migration/V1__rooms_and_bookings.sql
create extension if not exists btree_gist;
create table rooms (
id bigint generated by default as identity primary key,
name text not null unique,
capacity int not null check (capacity > 0)
);
create table bookings (
id bigint generated by default as identity primary key,
room_id bigint not null references rooms (id),
owner_id text not null,
starts_at timestamptz not null,
ends_at timestamptz not null,
status text not null check (status in ('CONFIRMED', 'CANCELLED')),
version bigint not null default 0,
constraint bookings_end_after_start check (ends_at > starts_at),
constraint bookings_no_overlap exclude using gist (
room_id with =,
tstzrange(starts_at, ends_at) with &&
) where (status = 'CONFIRMED')
);
create index bookings_owner_start on bookings (owner_id, starts_at);
The service: pre-check, insert, translate
The pre-check handles the usual case cheaply, and the <code>catch</code> handles the rare race that the check cannot see. The repository has already marked the transaction for rollback when it threw, which is what you want: the service throws its own exception, the transaction rolls back, and the error handler turns <code>BookingConflictException</code> into a 409. The mapping to <code>BookingView</code> happens inside the transaction, where the room is loaded.
import java.time.Clock;
import java.time.Instant;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
// Trimmed copies, so this file compiles on its own. The real exceptions extend
// ErrorResponseException (next lesson), so they become 422 and 409 responses, not 500.
@Entity class Room { @Id Long id; String name; }
@Entity class Booking {
@Id Long id; @ManyToOne Room room; String ownerId; Instant startsAt; Instant endsAt; String status;
protected Booking() { }
Booking(Room room, String ownerId, Instant startsAt, Instant endsAt) {
this.room = room; this.ownerId = ownerId; this.startsAt = startsAt; this.endsAt = endsAt;
this.status = "CONFIRMED";
}
}
interface RoomRepository extends JpaRepository<Room, Long> { }
interface BookingRepository extends JpaRepository<Booking, Long> {
// existsOverlap is not a derivable name, so the query must be spelled out.
@Query("""
select count(b) > 0 from Booking b
where b.room.id = :roomId and b.status = :status
and b.startsAt < :endsAt and b.endsAt > :startsAt
""")
boolean existsOverlap(long roomId, String status, Instant startsAt, Instant endsAt);
}
record BookingView(long id, long roomId, String roomName, Instant startsAt, Instant endsAt) { }
class RoomNotFoundException extends RuntimeException {
RoomNotFoundException(long id) { super("Room " + id + " does not exist"); }
}
class BookingConflictException extends RuntimeException {
BookingConflictException(long roomId) { super("Room " + roomId + " is already booked for part of that time."); }
}
@Service
public class BookingService {
private final BookingRepository bookings;
private final RoomRepository rooms;
private final Clock clock;
BookingService(BookingRepository bookings, RoomRepository rooms, Clock clock) {
this.bookings = bookings;
this.rooms = rooms;
this.clock = clock;
}
@Transactional
public BookingView book(String ownerId, long roomId, Instant startsAt, Instant endsAt) {
Room room = rooms.findById(roomId).orElseThrow(() -> new RoomNotFoundException(roomId));
// Fast path: a clear 409 without touching the constraint.
if (bookings.existsOverlap(roomId, "CONFIRMED", startsAt, endsAt)) {
throw new BookingConflictException(roomId);
}
try {
// Flush now so a concurrent insert that won the race fails here, not at commit.
Booking saved = bookings.saveAndFlush(new Booking(room, ownerId, startsAt, endsAt));
return new BookingView(saved.id, room.id, room.name, saved.startsAt, saved.endsAt);
} catch (DataIntegrityViolationException raceLost) {
throw new BookingConflictException(roomId);
}
}
}
Persistence settings
<code>validate</code> fails fast when an entity and a migration disagree, which is the mistake that otherwise reaches production as a SQL error on one rarely used endpoint. Turning off open-in-view keeps database work inside service transactions, so connections are not held for the whole request while JSON is written. The password comes from the environment, never from the file.
spring.datasource.url=jdbc:postgresql://localhost:5432/bookings
spring.datasource.username=bookings
spring.datasource.password=${DB_PASSWORD}
# Flyway creates and changes tables; Hibernate only checks they match the entities.
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.locations=classpath:db/migration
# No lazy loading during JSON rendering: map to DTOs in the service.
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
Discussion