Build a Ticket Reservation API
Build a PostgreSQL API that prevents double booking in concurrent ticket sales.
Introduction
30 Second Summary
During a popular ticket release, two buyers can click the same seat at almost the same moment. If both are told it is available, the platform can sell one seat twice.
In this project, you will build a Spring Boot ticket reservation API whose PostgreSQL transactions protect each seat from competing buyers. A deterministic test of this race condition takes you from a visible double booking to database-backed proof that one seat has one confirmed owner.
What You'll Build
When you run the finished demo, you'll see the unsafe path commit two reservations for one seat before the corrected API returns one winner plus one rejected competitor.
By the end of this project, you'll have:
- A runnable reservation API that carries a seat from availability to a 30-second hold. You can confirm the purchase, release the hold, or retrieve the final reservation.
- A side-by-side concurrency demo where the unsafe route commits two reservations for one seat. The PostgreSQL-protected route produces one accepted hold plus one rejected competitor.
- A repeatable proof suite backed by Testcontainers that checks committed database state after each contest. Its results become evidence in your GitHub-ready architecture explanation.
- Secret Mission: Use optimistic locking in a hot-seat experiment where @Version produces one winner plus one explicit version conflict.
Are there any prerequisites?
You should have completed the Ticket Drop Concurrency Lab or have equivalent Java concurrency experience.
Before We Start
Before the hands-on work begins, commit to one correctness rule: for every seat ID, the reservations table must contain at most one confirmed owner. PostgreSQL must protect that rule because an in-process Java lock reaches only one application instance.
Start the API on PostgreSQL
The one-owner invariant only matters when every application instance makes its decision against the same PostgreSQL schema. An in-memory substitute cannot reveal how competing transactions lock database rows.
In this step, you'll assemble a Spring Boot API that validates a real schema managed by Flyway. Docker Compose supplies the PostgreSQL process that makes those guarantees observable.
In this step, get ready to:
- Create a fresh Git repository in Visual Studio Code with the Spring Boot project files.
- Run PostgreSQL 18.6 locally with Docker Compose.
- Apply the reservation schema with Flyway before starting the API.
Create the Maven application foundation
A Maven build keeps the framework versions consistent across development machines. The project also needs a predictable folder structure before Spring Boot can discover its application class.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into system search.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder... from the menu.
- Choose your Desktop in the folder dialog.
- Select New Folder to create a folder named ticket-reservation-api.
- Select Open on macOS or Select Folder on Windows.
Trust Your Own Workspace
The Workspace Trust dialog can appear when you open the folder. Select Yes, I trust the authors because you created this empty workspace yourself.
- Press Ctrl+` to open the integrated terminal.
- Initialize the repository by running this command:
git init
What Does This Command Do?
The git init command creates the repository metadata inside ticket-reservation-api. Git can now track every project file you add.
The terminal confirms that an empty Git repository was initialized in your ticket-reservation-api folder.
Repository Not Initialized?
Confirm that Visual Studio Code shows ticket-reservation-api at the top of the Explorer view. A different folder means the integrated terminal started in the wrong location.
Share the terminal output with help me initialize Git in my ticket-reservation-api folder.
- Create pom.xml with the New File... action in the Explorer view.
- Configure the Maven project by pasting this code into pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.nextwork</groupId>
<artifactId>ticket-reservation-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>25</java.version>
<flyway.version>13.10.0</flyway.version>
<postgresql.version>42.7.14</postgresql.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</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><version>${flyway.version}</version></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-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><version>${testcontainers.version}</version><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><version>${testcontainers.version}</version><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
What Does This Build File Do?
- The Spring Boot parent pins the application framework to 4.1.1.
- The Java property tells Maven to compile the project for Java 25.
- The runtime dependencies provide HTTP endpoints through Spring Web.
- The persistence dependencies connect Spring Data JPA to PostgreSQL.
- The test dependencies prepare the later concurrency suite for Testcontainers 2.0.5 with JUnit 5.
- Save pom.xml.
You should see pom.xml listed directly beneath ticket-reservation-api in the Explorer view.
Seeing XML Errors?
Confirm that the first line begins with the XML declaration. Check that the final line closes the project element.
Ask for targeted help with help me find the XML structure error in my pom.xml.
- Create .gitignore with the New File... action in the Explorer view.
- Keep generated files out of Git by pasting these ignore rules into .gitignore:
target/
.vscode/
.DS_Store
*.log
What Do These Rules Exclude?
- The target/ rule excludes Maven build output.
- The .vscode/ rule excludes local editor settings.
- The remaining rules exclude operating-system metadata plus generated log files.
- Save .gitignore.
You should see .gitignore beside pom.xml in the Explorer view.
Cannot See .gitignore?
Check that the file name starts with a single full stop. Confirm that Visual Studio Code created it inside ticket-reservation-api.
Use help me locate my .gitignore file in Visual Studio Code if the file remains hidden.
- Create src/main/java/com/nextwork/ticketing/TicketReservationApplication.java with the New File... action in the Explorer view.
- Add the Spring Boot entry point by pasting this code:
package com.nextwork.ticketing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TicketReservationApplication {
public static void main(String[] args) {
SpringApplication.run(TicketReservationApplication.class, args);
}
}
What Does This Class Do?
- The package places the class at the top of the application's component-scanning tree.
- The SpringBootApplication annotation activates Spring Boot configuration.
- The main() method starts the application process.
- Save TicketReservationApplication.java.
The Explorer view now shows the application class beneath src/main/java/com/nextwork/ticketing.
Package Path Does Not Match?
Confirm that the folders beneath src/main/java spell com/nextwork/ticketing. The package declaration must mirror that path.
Ask help me align my Java package with its folder path for help with a mismatch.
- Create src/main/java/com/nextwork/ticketing/config/TimeConfiguration.java with the New File... action in the Explorer view.
- Expose the authoritative application clock by pasting this code:
package com.nextwork.ticketing.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TimeConfiguration {
@Bean
Clock clock() { return Clock.systemUTC(); }
}
Why Inject a Clock?
The clock() bean gives reservation logic one authoritative source of time. Later tests can replace that bean with a controllable clock to reproduce expiration races.
- Save TimeConfiguration.java.
The Explorer view now shows a config package containing TimeConfiguration.java.
Clock Import Still Red?
Confirm that Clock comes from java.time. Save pom.xml so Visual Studio Code can load the Maven project model.
Use help me resolve the Clock import in TimeConfiguration.java if the imports stay unresolved.
- Create src/main/resources/application.properties with the New File... action in the Explorer view.
- Configure the local database connection by pasting these properties:
spring.application.name=ticket-reservation-api
spring.datasource.url=jdbc:postgresql://localhost:5432/tickets
spring.datasource.username=tickets
spring.datasource.password=tickets
spring.jpa.hibernate.ddl-auto=validate
What Do These Properties Control?
- The application name identifies the service as ticket-reservation-api.
- The datasource URL points Spring Boot at the local tickets database on port 5432.
- The username plus password match the local Compose service.
- The validate setting requires the database schema to match the entity mappings.
- Save application.properties.
You should see application.properties beneath src/main/resources in the Explorer view.
Properties File in the Wrong Folder?
Confirm that the path begins with src/main/resources. A file beneath the Java package tree does not become Spring Boot configuration.
Ask help me place application.properties in the correct Maven folder if the configuration is not detected.
Run PostgreSQL with Docker Compose
The API needs a database process before it can validate its schema. The Compose file makes the PostgreSQL image plus its storage repeatable on any machine with Docker.
- Create compose.yaml with the New File... action in the Explorer view.
- Define the PostgreSQL service by pasting this configuration:
services:
postgres:
image: postgres:18.6-alpine3.24
environment:
POSTGRES_DB: tickets
POSTGRES_USER: tickets
POSTGRES_PASSWORD: tickets
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql
volumes:
postgres-data:
What Does This Service Provide?
- The postgres:18.6-alpine3.24 image pins the database environment.
- The initialization variables create the tickets database with matching local credentials.
- The port mapping exposes PostgreSQL at localhost:5432.
- The postgres-data volume preserves database files at /var/lib/postgresql.
- Save compose.yaml.
You should see compose.yaml beside pom.xml in the Explorer view.
Compose File Shows Indentation Errors?
Use spaces for every indentation level. Confirm that postgres-data appears beneath the top-level volumes key.
Ask help me fix the indentation in compose.yaml for a focused YAML check.
The first start can take longer while Docker downloads the PostgreSQL image.
- Start PostgreSQL in the background by running this command:
docker compose up -d
What Does This Command Do?
The docker compose up -d command creates the service plus its network. Detached mode leaves the terminal available for the next check.
Docker reports that the PostgreSQL service has started. The named volume is also ready to preserve its data.
PostgreSQL Did Not Start?
Confirm that Docker is running on your machine. Check whether another local database already uses port 5432.
Share the Compose output with help me start the PostgreSQL Compose service.
- Check the PostgreSQL service state by running this command:
docker compose ps
What Does This Check Show?
The docker compose ps command lists the Compose service state plus its exposed port. This confirms whether the API can reach PostgreSQL before it starts.
You should see the postgres service in a running state with port 5432 exposed.
Service Not Running?
Return to the output from the start command. A port conflict or malformed Compose file prevents the service from staying active.
Use help me diagnose why docker compose ps does not show PostgreSQL running for environment-specific help.
Apply the schema and start the API
Flyway applies versioned migrations before Hibernate validates the database. This ordering makes the SQL schema the starting point for every local run.
- Create src/main/resources/db/migration/V1__create_reservation_schema.sql with the New File... action in the Explorer view.
- Create the reservation schema by pasting this migration:
CREATE TABLE seats (
id UUID PRIMARY KEY,
event_id UUID NOT NULL,
label VARCHAR(20) NOT NULL,
state VARCHAR(16) NOT NULL,
hold_id UUID UNIQUE,
held_by VARCHAR(100),
hold_expires_at TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uq_event_seat UNIQUE (event_id, label),
CONSTRAINT ck_seat_state CHECK (state IN ('AVAILABLE', 'HELD', 'SOLD')),
CONSTRAINT ck_hold_fields CHECK (
(state = 'HELD' AND hold_id IS NOT NULL AND held_by IS NOT NULL AND hold_expires_at IS NOT NULL)
OR (state IN ('AVAILABLE', 'SOLD') AND hold_id IS NULL AND held_by IS NULL AND hold_expires_at IS NULL)
)
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
seat_id UUID NOT NULL UNIQUE REFERENCES seats(id),
buyer_id VARCHAR(100) NOT NULL,
confirmed_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE unsafe_reservations (
id UUID PRIMARY KEY,
seat_id UUID NOT NULL REFERENCES seats(id),
buyer_id VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
INSERT INTO seats (id, event_id, label, state) VALUES
('10000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000001', 'A1', 'AVAILABLE'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001', 'A2', 'AVAILABLE');
What Does This Migration Protect?
- The seats table stores the authoritative state for each event seat.
- The state checks reject inconsistent hold fields at the database boundary.
- The unique reservations.seat_id value provides a final backstop against two confirmed owners.
- The unconstrained unsafe_reservations table isolates the deliberate race experiment from the safe workflow.
- The seed rows provide available seats A1 plus A2 for the shared event.
- Save V1__create_reservation_schema.sql.
The Explorer view now shows the migration beneath src/main/resources/db/migration.
Migration File Not Detected?
Confirm that the path is exactly src/main/resources/db/migration. Check that the filename starts with V1__ using two underscores.
Ask help me fix Flyway migration discovery if the migration remains undiscovered.
Before you start the API, what do you expect Flyway to create before Spring Boot finishes loading?
- Start the application against PostgreSQL by running this command:
mvn spring-boot:run
What Does This Command Do?
The Spring Boot Maven plugin compiles the application before launching it. Flyway connects to PostgreSQL during startup to apply V1__create_reservation_schema.sql.
Hibernate then validates the resulting schema against the application's persistence configuration. A successful startup proves that the API plus database agree on their foundation.
You'll see Flyway complete the first migration before Spring Boot reports that the application started. No schema-validation failure should appear.
Application Did Not Start?
- Confirm that docker compose ps still shows PostgreSQL running.
- Check that the database name plus local credentials are tickets in both configuration files.
- Verify that the migration uses two underscores after V1.
- Share the startup output with help me debug my Spring Boot PostgreSQL startup failure.
✔️ Awesome, I've got everything!
Great work. Your PostgreSQL schema is live through Flyway. Your Spring Boot application now starts against it.
ⓧ I'd like to double check the full code
Compare each saved file with these complete versions. Every path must match the label above its code block.
target/
.vscode/
.DS_Store
*.log
services:
postgres:
image: postgres:18.6-alpine3.24
environment:
POSTGRES_DB: tickets
POSTGRES_USER: tickets
POSTGRES_PASSWORD: tickets
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql
volumes:
postgres-data:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.nextwork</groupId>
<artifactId>ticket-reservation-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>25</java.version>
<flyway.version>13.10.0</flyway.version>
<postgresql.version>42.7.14</postgresql.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</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><version>${flyway.version}</version></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-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><version>${testcontainers.version}</version><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><version>${testcontainers.version}</version><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
package com.nextwork.ticketing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TicketReservationApplication {
public static void main(String[] args) {
SpringApplication.run(TicketReservationApplication.class, args);
}
}
package com.nextwork.ticketing.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TimeConfiguration {
@Bean
Clock clock() { return Clock.systemUTC(); }
}
spring.application.name=ticket-reservation-api
spring.datasource.url=jdbc:postgresql://localhost:5432/tickets
spring.datasource.username=tickets
spring.datasource.password=tickets
spring.jpa.hibernate.ddl-auto=validate
CREATE TABLE seats (
id UUID PRIMARY KEY,
event_id UUID NOT NULL,
label VARCHAR(20) NOT NULL,
state VARCHAR(16) NOT NULL,
hold_id UUID UNIQUE,
held_by VARCHAR(100),
hold_expires_at TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uq_event_seat UNIQUE (event_id, label),
CONSTRAINT ck_seat_state CHECK (state IN ('AVAILABLE', 'HELD', 'SOLD')),
CONSTRAINT ck_hold_fields CHECK (
(state = 'HELD' AND hold_id IS NOT NULL AND held_by IS NOT NULL AND hold_expires_at IS NOT NULL)
OR (state IN ('AVAILABLE', 'SOLD') AND hold_id IS NULL AND held_by IS NULL AND hold_expires_at IS NULL)
)
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
seat_id UUID NOT NULL UNIQUE REFERENCES seats(id),
buyer_id VARCHAR(100) NOT NULL,
confirmed_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE unsafe_reservations (
id UUID PRIMARY KEY,
seat_id UUID NOT NULL REFERENCES seats(id),
buyer_id VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
INSERT INTO seats (id, event_id, label, state) VALUES
('10000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000001', 'A1', 'AVAILABLE'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001', 'A2', 'AVAILABLE');
Your database-backed API foundation is running. Next, you'll use it to reproduce a double-booking race that a single-request test would miss.
Prove the Double-Booking Race
Your Spring Boot application now starts against a real PostgreSQL schema. That gives this lab the same transaction boundary that protects a production reservation system.
A read-then-insert workflow can look safe during one request. When two transactions read before either inserts, both can observe an available seat.
In this step, get ready to:
- Map the isolated unsafe reservation table through JPA.
- Synchronize two requests inside the vulnerable reservation window.
- Run an integration test that measures the committed database state.
Map the unsafe reservation data
A JPA entity maps each lab reservation to the existing unsafe_reservations table. A repository provides the availability count that the unsafe workflow trusts.
- Select src/main/java/com/nextwork/ticketing in the VS Code Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter lab/UnsafeReservation.java as the file path.
- Map the lab reservation fields by pasting this code:
package com.nextwork.ticketing.lab;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.*;
@Entity
@Table(name = "unsafe_reservations")
public class UnsafeReservation {
@Id private UUID id;
@Column(name = "seat_id", nullable = false) private UUID seatId;
@Column(name = "buyer_id", nullable = false) private String buyerId;
@Column(name = "created_at", nullable = false) private Instant createdAt;
protected UnsafeReservation() {}
public UnsafeReservation(UUID id, UUID seatId, String buyerId, Instant createdAt) {
this.id = id; this.seatId = seatId; this.buyerId = buyerId; this.createdAt = createdAt;
}
public UUID getId() { return id; }
public UUID getSeatId() { return seatId; }
public String getBuyerId() { return buyerId; }
}
What does this entity do?
- The @Table annotation connects the entity to the isolated unsafe_reservations table.
- The seatId field records which seat each buyer attempts to reserve.
- The constructor captures a generated identifier. It also captures the buyer identifier. It records the creation time.
- The getters provide the values returned by the lab endpoint.
- Save UnsafeReservation.java.
- Compile the new entity through the test phase by running:
mvn test
What does this check prove?
Maven compiles the application before it looks for tests. A BUILD SUCCESS result confirms that the entity matches the project dependencies.
Seeing a compilation error?
- Confirm that UnsafeReservation.java is inside the com/nextwork/ticketing/lab folder.
- Check that the package line reads package com.nextwork.ticketing.lab;.
- Compare every field type with the code block above.
- Help me diagnose the entity compilation error.
- Select the lab folder in the VS Code Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter UnsafeReservationRepository.java as the file name.
- Add the seat-count query by pasting this code:
package com.nextwork.ticketing.lab;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UnsafeReservationRepository extends JpaRepository<UnsafeReservation, UUID> {
long countBySeatId(UUID seatId);
}
What does this repository do?
- The JpaRepository parent provides persistence operations for UnsafeReservation records.
- The countBySeatId() method counts committed lab rows for one seat.
- The unsafe service uses a zero count as its permission to continue.
- Save UnsafeReservationRepository.java.
- Confirm that the repository compiles by running:
mvn test
What does this build confirm?
A successful build confirms that Spring Data can derive the seat-count query from countBySeatId(). The unsafe availability check now has its database operation.
Repository method not compiling?
- Check that the interface extends JpaRepository<UnsafeReservation, UUID>.
- Confirm that countBySeatId uses the same capitalization as the entity field.
- Help me fix the repository declaration.
✔️ Awesome, I've got everything!
Great. Your entity now maps the lab table. Your repository can count its committed rows.
ⓧ I'd like to double check the full code
Compare both files with these complete versions.
package com.nextwork.ticketing.lab;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.*;
@Entity
@Table(name = "unsafe_reservations")
public class UnsafeReservation {
@Id private UUID id;
@Column(name = "seat_id", nullable = false) private UUID seatId;
@Column(name = "buyer_id", nullable = false) private String buyerId;
@Column(name = "created_at", nullable = false) private Instant createdAt;
protected UnsafeReservation() {}
public UnsafeReservation(UUID id, UUID seatId, String buyerId, Instant createdAt) {
this.id = id; this.seatId = seatId; this.buyerId = buyerId; this.createdAt = createdAt;
}
public UUID getId() { return id; }
public UUID getSeatId() { return seatId; }
public String getBuyerId() { return buyerId; }
}
package com.nextwork.ticketing.lab;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UnsafeReservationRepository extends JpaRepository<UnsafeReservation, UUID> {
long countBySeatId(UUID seatId);
}
Coordinate and expose the unsafe path
A CyclicBarrier pauses each request until two buyers reach the same point. Placing it after the count creates a repeatable race window.
Why use a barrier?
Normal thread scheduling makes concurrency failures unpredictable. The barrier forces both transactions to finish their availability checks before either transaction inserts.
Each HTTP request still enters its own transaction. The test controls timing without sharing one transaction across child threads.
- Select the lab folder in the VS Code Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter UnsafeReservationService.java as the file name.
- Implement the synchronized reservation workflow by pasting this code:
package com.nextwork.ticketing.lab;
import java.time.Clock;
import java.util.UUID;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class UnsafeReservationService {
private final UnsafeReservationRepository repository;
private final Clock clock;
private final CyclicBarrier barrier = new CyclicBarrier(2);
public UnsafeReservationService(UnsafeReservationRepository repository, Clock clock) {
this.repository = repository; this.clock = clock;
}
@Transactional
public UnsafeReservation reserve(UUID seatId, String buyerId) {
if (repository.countBySeatId(seatId) > 0)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Seat already reserved");
awaitBuyer();
return repository.saveAndFlush(new UnsafeReservation(UUID.randomUUID(), seatId, buyerId, clock.instant()));
}
private void awaitBuyer() {
try { barrier.await(2, TimeUnit.SECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
catch (Exception e) { throw new IllegalStateException("Two buyers did not reach the race window", e); }
}
}
How does the race window work?
- The @Transactional annotation gives each request its own database transaction.
- The countBySeatId() check reads the committed reservation count before the barrier.
- The two-party barrier releases only after both request threads arrive.
- The saveAndFlush() call sends each insert to PostgreSQL before the service method returns.
- Save UnsafeReservationService.java.
- Compile the synchronized workflow by running:
mvn test
What does this build confirm?
A BUILD SUCCESS result confirms that the service can use the shared clock bean. It also confirms that the barrier APIs are available in Java 25.
Service not compiling?
- Confirm that UnsafeReservationService is in the same package as its repository.
- Check that the barrier is created with 2 parties.
- Verify that reserve() carries the @Transactional annotation.
- Help me diagnose the unsafe service error.
- Select the lab folder in the VS Code Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter UnsafeReservationController.java as the file name.
- Expose the lab workflow through its isolated endpoint by pasting this code:
package com.nextwork.ticketing.lab;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/lab/unsafe-reservations")
public class UnsafeReservationController {
private final UnsafeReservationService service;
public UnsafeReservationController(UnsafeReservationService service) { this.service = service; }
@PostMapping
public ResponseEntity<Response> reserve(@RequestBody Request request) {
var r = service.reserve(request.seatId(), request.buyerId());
return new ResponseEntity<>(new Response(r.getId(), r.getSeatId(), r.getBuyerId()), HttpStatus.CREATED);
}
public record Request(UUID seatId, String buyerId) {}
public record Response(UUID reservationId, UUID seatId, String buyerId) {}
}
What does this controller do?
- The @RequestMapping annotation isolates the experiment at /api/lab/unsafe-reservations.
- The Request record accepts the seat identifier. It also accepts the buyer identifier.
- The POST handler passes those values into reserve().
- A successful insert returns an HTTP 201 response containing the new reservation details.
- Save UnsafeReservationController.java.
- Confirm that the complete unsafe path compiles by running:
mvn test
What does this build confirm?
Maven now compiles the entity. It compiles the repository. It also compiles the service and controller.
Controller not compiling?
- Confirm that the class uses @RestController.
- Check the endpoint path for the lab segment.
- Verify that the request record declares seatId before buyerId.
- Help me fix the lab controller.
✔️ Awesome, I've got everything!
Your unsafe endpoint is wired to a transaction that pauses two buyers inside the same vulnerable window.
ⓧ I'd like to double check the full code
Compare the service and controller with these complete versions.
package com.nextwork.ticketing.lab;
import java.time.Clock;
import java.util.UUID;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class UnsafeReservationService {
private final UnsafeReservationRepository repository;
private final Clock clock;
private final CyclicBarrier barrier = new CyclicBarrier(2);
public UnsafeReservationService(UnsafeReservationRepository repository, Clock clock) {
this.repository = repository; this.clock = clock;
}
@Transactional
public UnsafeReservation reserve(UUID seatId, String buyerId) {
if (repository.countBySeatId(seatId) > 0)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Seat already reserved");
awaitBuyer();
return repository.saveAndFlush(new UnsafeReservation(UUID.randomUUID(), seatId, buyerId, clock.instant()));
}
private void awaitBuyer() {
try { barrier.await(2, TimeUnit.SECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
catch (Exception e) { throw new IllegalStateException("Two buyers did not reach the race window", e); }
}
}
package com.nextwork.ticketing.lab;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/lab/unsafe-reservations")
public class UnsafeReservationController {
private final UnsafeReservationService service;
public UnsafeReservationController(UnsafeReservationService service) { this.service = service; }
@PostMapping
public ResponseEntity<Response> reserve(@RequestBody Request request) {
var r = service.reserve(request.seatId(), request.buyerId());
return new ResponseEntity<>(new Response(r.getId(), r.getSeatId(), r.getBuyerId()), HttpStatus.CREATED);
}
public record Request(UUID seatId, String buyerId) {}
public record Response(UUID reservationId, UUID seatId, String buyerId) {}
}
Drive two requests through the race
A Testcontainers test starts a disposable PostgreSQL 18.6 database. A random-port server sends both requests through the real HTTP and transaction layers.
The test uses Java virtual threads to launch both clients together. Direct database assertions reveal the state that actually committed.
- Select src/test/java/com/nextwork/ticketing in the VS Code Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter TicketReservationIntegrationTest.java as the file name.
- Add the test package and imports by pasting this code:
package com.nextwork.ticketing;
import static org.junit.jupiter.api.Assertions.*;
import java.net.URI;
import java.net.http.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
What do these imports support?
- The HTTP imports create requests against the random-port server.
- The concurrency imports provide virtual-thread executors and futures.
- The Testcontainers imports manage the disposable PostgreSQL database.
- The Spring test imports provide dependency injection and direct SQL access.
- Save TicketReservationIntegrationTest.java.
- Confirm that the test imports compile by running:
mvn test
What does this first test build prove?
The Java file is a valid compilation unit with the required imports. Maven should finish with BUILD SUCCESS.
Test imports not compiling?
- Confirm that the test file sits under src/test/java.
- Check that the package is com.nextwork.ticketing.
- Help me resolve the test import error.
- Add the test class below the imports in TicketReservationIntegrationTest.java:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TicketReservationIntegrationTest.TestClockConfiguration.class)
class TicketReservationIntegrationTest {
static final UUID EVENT = UUID.fromString("20000000-0000-0000-0000-000000000001");
static final UUID SEAT = UUID.fromString("10000000-0000-0000-0000-000000000001");
static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
@TestConfiguration(proxyBeanMethods = false)
static class TestClockConfiguration {
@Bean @Primary MutableClock testClock() { return new MutableClock(START); }
}
static final class MutableClock extends Clock {
Instant current;
MutableClock(Instant current) { this.current = current; }
void advance(Duration duration) { current = current.plus(duration); }
void reset() { current = START; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return current; }
}
}
What does the test shell provide?
- The random-port setting starts the complete web application for HTTP testing.
- The event constant identifies the seeded event.
- The seat constant identifies seat A1.
- The mutable clock gives tests a repeatable timestamp.
- Save TicketReservationIntegrationTest.java.
- Confirm that the test shell compiles by running:
mvn test
What does this build confirm?
Maven can now discover the test class. The class does not contain a test method at this point, so the build confirms its configuration and clock types compile.
- Insert the container fields and reset method below the START constant:
@Container @ServiceConnection
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18.6-alpine3.24");
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired MutableClock clock;
final HttpClient client = HttpClient.newHttpClient();
@BeforeEach void reset() {
jdbc.update("DELETE FROM unsafe_reservations");
jdbc.update("DELETE FROM reservations");
jdbc.update("UPDATE seats SET state='AVAILABLE', hold_id=NULL, held_by=NULL, hold_expires_at=NULL, version=0");
clock.reset();
}
How is each run isolated?
- The PostgreSQL container supplies its connection details to Spring Boot.
- The random server port is injected into port.
- The JdbcTemplate field runs setup queries and committed-state assertions.
- The reset method removes earlier reservations. It restores each seat to the same available state.
- Save TicketReservationIntegrationTest.java.
- Compile the container setup by running:
mvn test
What should the test setup do?
The build compiles the container declaration and reset queries. Maven should finish with BUILD SUCCESS.
Container setup not compiling?
- Confirm that POSTGRES uses the postgres:18.6-alpine3.24 image.
- Check that @ServiceConnection appears on the container field.
- Help me fix the Testcontainers setup.
- Insert the concurrent HTTP helpers below reset():
List<HttpResponse<String>> posts(Spec a, Spec b) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<HttpResponse<String>> x = executor.submit(() -> post(a.path(), a.body()));
Future<HttpResponse<String>> y = executor.submit(() -> post(b.path(), b.body()));
return List.of(x.get(), y.get());
}
}
HttpResponse<String> post(String path, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
How do the requests overlap?
- The executor starts one virtual thread for each HTTP request.
- Each future calls the same random-port Spring Boot server.
- The service barrier holds both transactions after their count queries.
- The helper waits for both HTTP responses before returning.
- Add the response and JSON helpers below post():
static List<Integer> statuses(List<HttpResponse<String>> responses) {
return responses.stream().map(HttpResponse::statusCode).sorted().toList();
}
static String body(String buyer) {
return "{\"seatId\":\"%s\",\"buyerId\":\"%s\"}".formatted(SEAT, buyer);
}
static UUID uuid(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]+)\"").matcher(json);
if (!m.find()) throw new IllegalArgumentException(json);
return UUID.fromString(m.group(1));
}
record Spec(String path, String body) {}
What do these helpers measure?
- The statuses() helper sorts the two HTTP status codes for a deterministic assertion.
- The body() helper creates a request for seeded seat A1.
- The uuid() helper supports later reservation tests in the same integration class.
- The Spec record keeps each path beside its request body.
- Save TicketReservationIntegrationTest.java.
- Confirm that the HTTP helpers compile by running:
mvn test
What does this helper build prove?
A successful build confirms that Java can create both virtual-thread tasks. It also confirms that each task can construct an HTTP request.
- Insert the race test below reset() and above posts():
@Test void unsafePathDoubleBooksOneSeat() throws Exception {
var responses = posts(new Spec("/api/lab/unsafe-reservations", body("buyer-a")),
new Spec("/api/lab/unsafe-reservations", body("buyer-b")));
assertEquals(List.of(201, 201), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM unsafe_reservations WHERE seat_id=?", Integer.class, SEAT);
assertEquals(2, count); System.out.println("Unsafe reservations for one seat: " + count);
}
What does the test prove?
- The test sends one request for buyer-a.
- It sends another request for buyer-b.
- The response assertion checks the result observed by both buyers.
- The SQL assertion checks the committed rows for seat A1.
- Save TicketReservationIntegrationTest.java.
Before you run the proof, do you expect the availability count to protect the seat once both transactions have already read it?
- Run only the unsafe race test with this command:
mvn -Dtest=TicketReservationIntegrationTest#unsafePathDoubleBooksOneSeat test
What did the race reveal?
The test prints Unsafe reservations for one seat: 2. Maven finishes with BUILD SUCCESS because the test reproduced the defect successfully.
Both transactions observed zero committed rows before either insert became visible. The unconstrained table accepted both writes.
Race test not passing?
- Confirm that Docker is running so Testcontainers can start PostgreSQL.
- Check that awaitBuyer() runs after countBySeatId().
- Verify that the migration leaves unsafe_reservations.seat_id without a uniqueness constraint.
- Help me diagnose the deterministic race test.
✔️ Awesome, I've got everything!
Excellent. You now have deterministic database evidence that the unsafe read-then-insert workflow double-books seat A1.
ⓧ I'd like to double check the full code
Compare your complete integration test with this version.
package com.nextwork.ticketing;
import static org.junit.jupiter.api.Assertions.*;
import java.net.URI;
import java.net.http.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TicketReservationIntegrationTest.TestClockConfiguration.class)
class TicketReservationIntegrationTest {
static final UUID EVENT = UUID.fromString("20000000-0000-0000-0000-000000000001");
static final UUID SEAT = UUID.fromString("10000000-0000-0000-0000-000000000001");
static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
@Container @ServiceConnection
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18.6-alpine3.24");
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired MutableClock clock;
final HttpClient client = HttpClient.newHttpClient();
@BeforeEach void reset() {
jdbc.update("DELETE FROM unsafe_reservations");
jdbc.update("DELETE FROM reservations");
jdbc.update("UPDATE seats SET state='AVAILABLE', hold_id=NULL, held_by=NULL, hold_expires_at=NULL, version=0");
clock.reset();
}
@Test void unsafePathDoubleBooksOneSeat() throws Exception {
var responses = posts(new Spec("/api/lab/unsafe-reservations", body("buyer-a")),
new Spec("/api/lab/unsafe-reservations", body("buyer-b")));
assertEquals(List.of(201, 201), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM unsafe_reservations WHERE seat_id=?", Integer.class, SEAT);
assertEquals(2, count); System.out.println("Unsafe reservations for one seat: " + count);
}
List<HttpResponse<String>> posts(Spec a, Spec b) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<HttpResponse<String>> x = executor.submit(() -> post(a.path(), a.body()));
Future<HttpResponse<String>> y = executor.submit(() -> post(b.path(), b.body()));
return List.of(x.get(), y.get());
}
}
HttpResponse<String> post(String path, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
static List<Integer> statuses(List<HttpResponse<String>> responses) {
return responses.stream().map(HttpResponse::statusCode).sorted().toList();
}
static String body(String buyer) {
return "{\"seatId\":\"%s\",\"buyerId\":\"%s\"}".formatted(SEAT, buyer);
}
static UUID uuid(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]+)\"").matcher(json);
if (!m.find()) throw new IllegalArgumentException(json);
return UUID.fromString(m.group(1));
}
record Spec(String path, String body) {}
@TestConfiguration(proxyBeanMethods = false)
static class TestClockConfiguration {
@Bean @Primary MutableClock testClock() { return new MutableClock(START); }
}
static final class MutableClock extends Clock {
Instant current;
MutableClock(Instant current) { this.current = current; }
void advance(Duration duration) { current = current.plus(duration); }
void reset() { current = START; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return current; }
}
}
You have turned a timing bug into repeatable evidence. Next, you will move the decision boundary onto the authoritative seat row so competing buyers produce one winner.
Serialize Competing Holds in PostgreSQL
Your unsafe endpoint proves that two buyers can pass the same availability check before either transaction inserts a reservation. That evidence exposes application timing as an unreliable correctness boundary.
The safe path moves the decision into PostgreSQL. A pessimistic row lock serializes competing decisions on the authoritative seat row.
In this step, get ready to:
- Map seat state and confirmed reservations to the existing database schema.
- Lock the seat row before the service checks availability or creates a hold.
- Send two concurrent HTTP requests and prove that only one buyer wins.
Map the safe reservation state
The migration already defines the legal seat states and hold fields. JPA entities give the service a transaction-aware representation of those rows.
- Create the `seat` folder inside `src/main/java/com/nextwork/ticketing` using the new-folder control in the Visual Studio Code Explorer sidebar.
- Create `SeatState.java` inside `src/main/java/com/nextwork/ticketing/seat` using the new-file control.
- Paste the seat-state enum into `SeatState.java`:
package com.nextwork.ticketing.seat;
public enum SeatState { AVAILABLE, HELD, SOLD }
What does this enum represent?
- `AVAILABLE` marks a seat that a buyer can hold.
- `HELD` marks a seat with a temporary owner and expiration time.
- `SOLD` marks a seat whose hold has become a confirmed reservation.
- Save `SeatState.java`.
- Create `Seat.java` inside `src/main/java/com/nextwork/ticketing/seat` using the new-file control.
- Paste the seat entity into `Seat.java`:
package com.nextwork.ticketing.seat;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "seats")
public class Seat {
@Id private UUID id;
@Column(name = "event_id", nullable = false) private UUID eventId;
@Column(nullable = false) private String label;
@Enumerated(EnumType.STRING) @Column(nullable = false) private SeatState state;
@Column(name = "hold_id") private UUID holdId;
@Column(name = "held_by") private String heldBy;
@Column(name = "hold_expires_at") private Instant holdExpiresAt;
@Version @Column(nullable = false) private long version;
protected Seat() {}
public UUID getId() { return id; }
public String getLabel() { return label; }
public SeatState getState() { return state; }
public String getHeldBy() { return heldBy; }
public Instant getHoldExpiresAt() { return holdExpiresAt; }
public long getVersion() { return version; }
public boolean isExpired(Instant now) {
return state == SeatState.HELD && holdExpiresAt != null && !holdExpiresAt.isAfter(now);
}
public boolean isAvailableAt(Instant now) { return state == SeatState.AVAILABLE || isExpired(now); }
public void expireIfNeeded(Instant now) { if (isExpired(now)) release(); }
public void placeHold(UUID newHoldId, String buyerId, Instant expiresAt) {
state = SeatState.HELD; holdId = newHoldId; heldBy = buyerId; holdExpiresAt = expiresAt;
}
public void confirm() { state = SeatState.SOLD; clearHoldFields(); }
public void release() { state = SeatState.AVAILABLE; clearHoldFields(); }
private void clearHoldFields() { holdId = null; heldBy = null; holdExpiresAt = null; }
}
What does this entity protect?
- The field annotations map Java values to the existing `seats` columns.
- `isAvailableAt()` treats an expired hold as logically available when the service reads the seat.
- `expireIfNeeded()` clears stale hold data before a new buyer claims the seat.
- `@Version` keeps a version counter for the later optimistic-lock comparison.
- Save `Seat.java`.
- Check that both seat files compile by running:
mvn test
What does this check prove?
Maven compiles the new entity before running the existing unsafe concurrency test. A successful build proves that the Java mapping loads against the migrated schema.
You should see `BUILD SUCCESS`. Your existing unsafe race should still prove that two rows can commit in the isolated lab table.
Seeing a mapping or compilation error?
- Confirm that `Seat.java` is inside `src/main/java/com/nextwork/ticketing/seat`.
- Check that the package line is `package com.nextwork.ticketing.seat;`.
- Compare `hold_expires_at` and `event_id` with the column names in `V1__create_reservation_schema.sql`.
- Help me debug the Seat entity mapping.
That compile pass confirms that Spring can represent the authoritative seat row. The confirmed owner needs a separate entity backed by the database uniqueness constraint.
- Create the `reservation` folder inside `src/main/java/com/nextwork/ticketing` using the new-folder control in the Explorer sidebar.
- Create `Reservation.java` inside `src/main/java/com/nextwork/ticketing/reservation` using the new-file control.
- Paste the confirmed-reservation entity into `Reservation.java`:
package com.nextwork.ticketing.reservation;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "reservations")
public class Reservation {
@Id private UUID id;
@Column(name = "seat_id", nullable = false, unique = true) private UUID seatId;
@Column(name = "buyer_id", nullable = false) private String buyerId;
@Column(name = "confirmed_at", nullable = false) private Instant confirmedAt;
protected Reservation() {}
public Reservation(UUID id, UUID seatId, String buyerId, Instant confirmedAt) {
this.id = id; this.seatId = seatId; this.buyerId = buyerId; this.confirmedAt = confirmedAt;
}
public UUID getId() { return id; }
public UUID getSeatId() { return seatId; }
public String getBuyerId() { return buyerId; }
public Instant getConfirmedAt() { return confirmedAt; }
}
How does the reservation backstop work?
- `seatId` identifies the seat owned by the confirmed buyer.
- `unique = true` mirrors the unique constraint already defined on `reservations.seat_id`.
- The database rejects a second confirmed reservation if application logic ever reaches an invalid insert.
- Save `Reservation.java`.
- Create `ReservationRepository.java` inside `src/main/java/com/nextwork/ticketing/reservation` using the new-file control.
- Paste the repository interface into `ReservationRepository.java`:
package com.nextwork.ticketing.reservation;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ReservationRepository extends JpaRepository<Reservation, UUID> {}
What does this repository provide?
`JpaRepository` supplies persistence operations for `Reservation` rows. The service can save confirmed ownership through this interface inside a transaction.
- Save `ReservationRepository.java`.
- Verify both reservation files against PostgreSQL by running:
mvn test
What should this run confirm?
Spring validates `Reservation` against the existing `reservations` table during application startup. The run should finish with `BUILD SUCCESS`.
Does the application context fail to start?
- Check that `Reservation` uses `@Table(name = "reservations")`.
- Confirm that `seatId` maps to `seat_id`.
- Confirm that PostgreSQL is available to Docker before rerunning the tests.
- Help me diagnose the Reservation mapping failure.
Lock the seat and expose the hold endpoint
The safe decision begins with one database query that selects the seat under `PESSIMISTIC_WRITE`. Spring Data JPA keeps that row locked until the surrounding service transaction finishes.
- Create `SeatRepository.java` inside `src/main/java/com/nextwork/ticketing/seat` using the new-file control.
- Paste the locking repository into `SeatRepository.java`:
package com.nextwork.ticketing.seat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface SeatRepository extends JpaRepository<Seat, UUID> {
List<Seat> findByEventIdOrderByLabel(UUID eventId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.eventId = :eventId and s.id = :seatId")
Optional<Seat> findForUpdate(@Param("eventId") UUID eventId, @Param("seatId") UUID seatId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.holdId = :holdId")
Optional<Seat> findByHoldIdForUpdate(@Param("holdId") UUID holdId);
}
How does the row lock serialize buyers?
- `findForUpdate()` selects one seat using its event ID and seat ID.
- `PESSIMISTIC_WRITE` prevents a competing transaction from acquiring an incompatible lock on that row.
- The waiting transaction reads the updated seat state after the winner commits.
- `findByHoldIdForUpdate()` prepares the same locking boundary for later confirmation and release operations.
- Save `SeatRepository.java`.
- Create the `booking` folder inside `src/main/java/com/nextwork/ticketing` using the new-folder control in the Explorer sidebar.
- Create `ApiModels.java` inside `src/main/java/com/nextwork/ticketing/booking` using the new-file control.
- Paste the API records into `ApiModels.java`:
package com.nextwork.ticketing.booking;
import java.time.Instant;
import java.util.UUID;
import com.nextwork.ticketing.reservation.Reservation;
import com.nextwork.ticketing.seat.Seat;
import com.nextwork.ticketing.seat.SeatState;
public final class ApiModels {
private ApiModels() {}
public record HoldRequest(UUID seatId, String buyerId) {}
public record HoldResponse(UUID holdId, UUID seatId, String buyerId, Instant expiresAt) {}
public record SeatView(UUID id, String label, String state, Instant holdExpiresAt) {
static SeatView from(Seat seat, Instant now) {
boolean available = seat.isAvailableAt(now);
return new SeatView(seat.getId(), seat.getLabel(),
available ? SeatState.AVAILABLE.name() : seat.getState().name(),
available ? null : seat.getHoldExpiresAt());
}
}
public record ReservationView(UUID reservationId, UUID seatId, String buyerId, Instant confirmedAt) {
static ReservationView from(Reservation r) {
return new ReservationView(r.getId(), r.getSeatId(), r.getBuyerId(), r.getConfirmedAt());
}
}
public record StatusResponse(String status) {}
}
What crosses the API boundary?
- `HoldRequest` accepts the selected seat and buyer IDs.
- `HoldResponse` returns the generated hold ID with its expiration time.
- `SeatView.from()` reports an expired hold as logically available by consulting the current clock time.
- The remaining response records establish the API shapes used by the controller.
- Save `ApiModels.java`.
- Check the repository query and API records by running:
mvn test
What does this compilation check cover?
Spring parses the repository queries while starting the test application. The build should complete with `BUILD SUCCESS` if the entity properties and query parameters match.
Seeing a repository query error?
- Compare `s.eventId` with the `eventId` field in `Seat`.
- Compare `s.holdId` with the `holdId` field in `Seat`.
- Check that each `@Param` name matches the corresponding query placeholder.
- Help me fix the SeatRepository query.
The lock only remains active while a transaction is open. `ReservationService.createHold()` owns that transaction from the locked read through the seat update.
- Create `ReservationService.java` inside `src/main/java/com/nextwork/ticketing/booking` using the new-file control.
- Paste the transactional service into `ReservationService.java`:
package com.nextwork.ticketing.booking;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import com.nextwork.ticketing.reservation.Reservation;
import com.nextwork.ticketing.reservation.ReservationRepository;
import com.nextwork.ticketing.seat.Seat;
import com.nextwork.ticketing.seat.SeatRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class ReservationService {
private static final Duration HOLD_DURATION = Duration.ofSeconds(30);
private final SeatRepository seats;
private final ReservationRepository reservations;
private final Clock clock;
public ReservationService(SeatRepository seats, ReservationRepository reservations, Clock clock) {
this.seats = seats; this.reservations = reservations; this.clock = clock;
}
@Transactional(readOnly = true)
public List<Seat> listSeats(UUID eventId) { return seats.findByEventIdOrderByLabel(eventId); }
@Transactional
public HoldResult createHold(UUID eventId, UUID seatId, String buyerId) {
Instant now = clock.instant();
Seat seat = seats.findForUpdate(eventId, seatId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Seat not found"));
if (!seat.isAvailableAt(now)) throw new ResponseStatusException(HttpStatus.CONFLICT, "Seat is not available");
seat.expireIfNeeded(now);
UUID holdId = UUID.randomUUID();
Instant expiresAt = now.plus(HOLD_DURATION);
seat.placeHold(holdId, buyerId, expiresAt);
seats.save(seat);
return new HoldResult(holdId, seatId, buyerId, expiresAt);
}
@Transactional
public ConfirmationResult confirm(UUID holdId) {
Instant now = clock.instant();
Seat seat = seats.findByHoldIdForUpdate(holdId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Hold not found"));
if (seat.isExpired(now)) {
seat.release(); seats.save(seat); return new ConfirmationResult(null, true);
}
Reservation reservation = new Reservation(UUID.randomUUID(), seat.getId(), seat.getHeldBy(), now);
reservations.save(reservation);
seat.confirm(); seats.save(seat);
return new ConfirmationResult(reservation, false);
}
@Transactional
public void release(UUID holdId) {
Seat seat = seats.findByHoldIdForUpdate(holdId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Hold not found"));
seat.release(); seats.save(seat);
}
@Transactional(readOnly = true)
public Reservation getReservation(UUID id) {
return reservations.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Reservation not found"));
}
public Instant now() { return clock.instant(); }
public record HoldResult(UUID holdId, UUID seatId, String buyerId, Instant expiresAt) {}
public record ConfirmationResult(Reservation reservation, boolean expired) {}
}
Where is the correctness boundary?
- `@Transactional` keeps the row lock active throughout `createHold()`.
- `findForUpdate()` acquires the lock before the service checks availability.
- `expireIfNeeded()` reclaims stale ownership while the same row remains locked.
- `HOLD_DURATION` gives the winning buyer a 30-second deadline.
- The seat update commits with the transaction before the waiting buyer makes its decision.
- Save `ReservationService.java`.
- Create `TicketController.java` inside `src/main/java/com/nextwork/ticketing/booking` using the new-file control.
- Paste the REST controller into `TicketController.java`:
package com.nextwork.ticketing.booking;
import java.util.List;
import java.util.UUID;
import com.nextwork.ticketing.booking.ApiModels.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class TicketController {
private final ReservationService service;
public TicketController(ReservationService service) { this.service = service; }
@GetMapping("/events/{eventId}/seats")
public List<SeatView> seats(@PathVariable UUID eventId) {
return service.listSeats(eventId).stream().map(s -> SeatView.from(s, service.now())).toList();
}
@PostMapping("/events/{eventId}/holds")
public ResponseEntity<HoldResponse> hold(@PathVariable UUID eventId, @RequestBody HoldRequest request) {
var r = service.createHold(eventId, request.seatId(), request.buyerId());
return new ResponseEntity<>(new HoldResponse(r.holdId(), r.seatId(), r.buyerId(), r.expiresAt()), HttpStatus.CREATED);
}
@PostMapping("/holds/{holdId}/confirm")
public ResponseEntity<?> confirm(@PathVariable UUID holdId) {
var result = service.confirm(holdId);
return result.expired()
? new ResponseEntity<>(new StatusResponse("EXPIRED"), HttpStatus.GONE)
: new ResponseEntity<>(ReservationView.from(result.reservation()), HttpStatus.CREATED);
}
@DeleteMapping("/holds/{holdId}")
public ResponseEntity<Void> release(@PathVariable UUID holdId) {
service.release(holdId); return ResponseEntity.noContent().build();
}
@GetMapping("/reservations/{id}")
public ReservationView reservation(@PathVariable UUID id) { return ReservationView.from(service.getReservation(id)); }
}
How does the hold endpoint respond?
- `POST /api/events/{eventId}/holds` passes the path event ID and request body to `createHold()`.
- The winner receives a `HoldResponse` with HTTP status `201`.
- The losing request receives HTTP status `409` from the service conflict.
- The controller also exposes the API operations that build on the same locked service boundary.
- Save `TicketController.java`.
- Verify the application context with the new safe API by running:
mvn test
What should work now?
Spring should discover the service and controller without dependency errors. The existing unsafe test should still pass before you add the safe race test.
Does the safe API stop the test context?
- Check that `ReservationService` is in the `com.nextwork.ticketing.booking` package.
- Confirm that `TimeConfiguration` still exposes the `Clock` bean.
- Compare the endpoint path with `/api/events/{eventId}/holds`.
- Help me debug the safe hold API startup failure.
Race two buyers against the locked seat
A deterministic concurrency test now sends two hold requests through the random-port server. Both requests target seat A1 while separate application threads enter separate service transactions.
- Open `src/test/java/com/nextwork/ticketing/TicketReservationIntegrationTest.java` from the Visual Studio Code Explorer sidebar.
- Replace its contents with this cumulative test file:
package com.nextwork.ticketing;
import static org.junit.jupiter.api.Assertions.*;
import java.net.URI;
import java.net.http.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TicketReservationIntegrationTest.TestClockConfiguration.class)
class TicketReservationIntegrationTest {
static final UUID EVENT = UUID.fromString("20000000-0000-0000-0000-000000000001");
static final UUID SEAT = UUID.fromString("10000000-0000-0000-0000-000000000001");
static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
@Container @ServiceConnection
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18.6-alpine3.24");
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired MutableClock clock;
final HttpClient client = HttpClient.newHttpClient();
@BeforeEach void reset() {
jdbc.update("DELETE FROM unsafe_reservations");
jdbc.update("DELETE FROM reservations");
jdbc.update("UPDATE seats SET state='AVAILABLE', hold_id=NULL, held_by=NULL, hold_expires_at=NULL, version=0");
clock.reset();
}
@Test void unsafePathDoubleBooksOneSeat() throws Exception {
var responses = posts(new Spec("/api/lab/unsafe-reservations", body("buyer-a")),
new Spec("/api/lab/unsafe-reservations", body("buyer-b")));
assertEquals(List.of(201, 201), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM unsafe_reservations WHERE seat_id=?", Integer.class, SEAT);
assertEquals(2, count); System.out.println("Unsafe reservations for one seat: " + count);
}
@Test void pessimisticLockAllowsOneHoldWinner() throws Exception {
String path = "/api/events/" + EVENT + "/holds";
var responses = posts(new Spec(path, body("buyer-a")), new Spec(path, body("buyer-b")));
assertEquals(List.of(201, 409), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM seats WHERE id=? AND state='HELD'", Integer.class, SEAT);
assertEquals(1, count); System.out.println("Safe hold winners: " + count);
}
List<HttpResponse<String>> posts(Spec a, Spec b) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<HttpResponse<String>> x = executor.submit(() -> post(a.path(), a.body()));
Future<HttpResponse<String>> y = executor.submit(() -> post(b.path(), b.body()));
return List.of(x.get(), y.get());
}
}
HttpResponse<String> post(String path, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
static List<Integer> statuses(List<HttpResponse<String>> responses) {
return responses.stream().map(HttpResponse::statusCode).sorted().toList();
}
static String body(String buyer) {
return "{\"seatId\":\"%s\",\"buyerId\":\"%s\"}".formatted(SEAT, buyer);
}
static UUID uuid(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]+)\"").matcher(json);
if (!m.find()) throw new IllegalArgumentException(json);
return UUID.fromString(m.group(1));
}
record Spec(String path, String body) {}
@TestConfiguration(proxyBeanMethods = false)
static class TestClockConfiguration {
@Bean @Primary MutableClock testClock() { return new MutableClock(START); }
}
static final class MutableClock extends Clock {
Instant current;
MutableClock(Instant current) { this.current = current; }
void advance(Duration duration) { current = current.plus(duration); }
void reset() { current = START; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return current; }
}
}
How does the safe race prove correctness?
- `posts()` starts both HTTP calls on separate virtual threads.
- Both calls enter `ReservationService.createHold()` through the running server.
- The first transaction locks A1 and changes its state to `HELD`.
- The waiting transaction reads the committed `HELD` state and receives `409`.
- The direct SQL assertion proves that PostgreSQL contains exactly one held seat.
- Save `TicketReservationIntegrationTest.java`.
Before you run the proof, do you expect both buyers to receive `201`, or will the locked seat create one winner?
- Run the safe concurrency test with this command:
mvn -Dtest=TicketReservationIntegrationTest#pessimisticLockAllowsOneHoldWinner test
What should the proof show?
The test expects sorted response statuses `201` and `409`. It then queries PostgreSQL and prints `Safe hold winners: 1`.
You should also see `BUILD SUCCESS`. That result proves that the test observed one committed hold owner.
You have moved the hold decision into the database transaction. Competing application threads now converge on one authoritative seat state.
Does the safe race fail?
- Confirm that `createHold()` calls `findForUpdate()` before `isAvailableAt()`.
- Check that `findForUpdate()` carries `@Lock(LockModeType.PESSIMISTIC_WRITE)`.
- Confirm that `createHold()` carries `@Transactional`.
- Check that the test reset changes seat A1 back to `AVAILABLE`.
- Help me debug the pessimistic-lock concurrency test.
✔️ Awesome, I've got everything!
Your safe hold path now returns one `201` response and one `409` response under concurrent requests. Double check that every changed file is saved.
ⓧ I'd like to double check the full code
Compare your project with the complete files shown above. The files added in this step are:
- `src/main/java/com/nextwork/ticketing/seat/SeatState.java`.
- `src/main/java/com/nextwork/ticketing/seat/Seat.java`.
- `src/main/java/com/nextwork/ticketing/seat/SeatRepository.java`.
- `src/main/java/com/nextwork/ticketing/reservation/Reservation.java`.
- `src/main/java/com/nextwork/ticketing/reservation/ReservationRepository.java`.
- `src/main/java/com/nextwork/ticketing/booking/ApiModels.java`.
- `src/main/java/com/nextwork/ticketing/booking/ReservationService.java`.
- `src/main/java/com/nextwork/ticketing/booking/TicketController.java`.
- `src/test/java/com/nextwork/ticketing/TicketReservationIntegrationTest.java` with both unsafe and safe race tests.
Your API now serializes competing holds without weakening the unsafe lab evidence. Next, you will make confirmation and expiration resolve through the same locked seat transaction.
Resolve Expiration and Confirmation Races
Your safe hold path already uses a PostgreSQL pessimistic row lock to choose one buyer. A 30-second hold creates a new race condition when the original buyer confirms as another buyer reclaims the seat.
In this step, you'll prove that confirmation and expiration make one decision inside the locked database transaction. A controllable clock lets the test cross the deadline without waiting in real time.
In this step, get ready to:
- Verify that confirmation and release operations lock the seat row.
- Inject a controllable clock into the integration test.
- Race an expired confirmation against a new hold request.
Prove duplicate confirmation is rejected
Confirmation changes a temporary hold into permanent ownership. The seat lock keeps two confirmation requests from creating two reservation rows for the same seat.
- Switch back to src/main/java/com/nextwork/ticketing/booking/ReservationService.java in the Visual Studio Code Explorer.
- Confirm that confirm() starts by calling findByHoldIdForUpdate().
- Confirm that the expired branch calls seat.release() before returning its expiration result.
- Confirm that the live branch saves one Reservation before calling seat.confirm().
- Confirm that release() also loads the seat through findByHoldIdForUpdate().
- Return to src/test/java/com/nextwork/ticketing/TicketReservationIntegrationTest.java in the Explorer.
- Add this test below pessimisticLockAllowsOneHoldWinner():
@Test void concurrentConfirmationCreatesOneReservation() throws Exception {
UUID holdId = uuid(post("/api/events/" + EVENT + "/holds", body("buyer-a")).body(), "holdId");
var responses = posts(new Spec("/api/holds/" + holdId + "/confirm", "{}"),
new Spec("/api/holds/" + holdId + "/confirm", "{}"));
assertEquals(List.of(201, 404), statuses(responses));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM reservations WHERE seat_id=?", Integer.class, SEAT));
}
What does this test prove?
- The first request creates the hold that both confirmation requests target.
- The virtual-thread clients send both confirmation requests through the running HTTP server.
- The status assertion requires one 201 response and one 404 response.
- The direct database assertion proves that only one committed reservation owns seat A1.
- Save TicketReservationIntegrationTest.java.
Before you run the suite, consider whether both confirmation requests can commit.
- Run the integration tests by using this command:
mvn test
What should I see?
You should see Maven complete the tests successfully. Good progress. The duplicate confirmation path now has database evidence that one request wins while the retry is rejected.
Does the confirmation test fail?
- Check that both requests use the same holdId returned by the first hold request.
- Check that confirm() calls findByHoldIdForUpdate() before reading the seat state.
- Check that the database assertion queries reservations with the shared SEAT value.
- Help me debug the concurrent confirmation test.
Inject a controllable test clock
Expiration tests need control over time. The production application keeps its UTC clock while the Testcontainers test receives a primary mutable clock that starts at a fixed instant.
- Replace the import section in TicketReservationIntegrationTest.java with this complete import group:
import static org.junit.jupiter.api.Assertions.*;
import java.net.URI;
import java.net.http.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
Why do these imports change?
- The java.time types provide the fixed starting instant and the adjustable duration.
- The Spring context annotations let the test register its clock as the primary bean.
- The remaining imports preserve the HTTP client and concurrency helpers already used by the test suite.
- Save TicketReservationIntegrationTest.java.
- Check that the revised imports compile by running:
mvn test
What should I see?
You should see Maven complete the existing tests successfully. This confirms that the consolidated import group still supports the HTTP and concurrency test harness.
Do the imports fail to compile?
- Keep the package com.nextwork.ticketing; declaration above the import group.
- Remove any duplicate imports left below the replacement group.
- Help me fix the integration test imports.
The mutable clock stores the instant returned to application code. Its advance() method moves that instant forward without pausing the test.
- Add these nested clock classes before the final closing brace of TicketReservationIntegrationTest:
@TestConfiguration(proxyBeanMethods = false)
static class TestClockConfiguration {
@Bean @Primary MutableClock testClock() { return new MutableClock(START); }
}
static final class MutableClock extends Clock {
Instant current;
MutableClock(Instant current) { this.current = current; }
void advance(Duration duration) { current = current.plus(duration); }
void reset() { current = START; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return current; }
}
How does the mutable clock work?
- The test configuration creates one MutableClock starting at START.
- The @Primary annotation gives this clock priority over the production clock.
- The reset() method restores the same instant before every test.
- The instant() method returns the time seen by ReservationService.
- Save TicketReservationIntegrationTest.java.
- Confirm that the clock classes compile by running:
mvn test
What should I see?
You should see the existing tests complete successfully. The mutable clock is now a valid test component.
Does the clock class fail to compile?
- Place both nested classes inside TicketReservationIntegrationTest.
- Keep both nested classes outside every test method.
- Help me place the mutable clock classes correctly.
The test class still needs to import the clock configuration into its Spring context. Resetting the clock beside the database cleanup keeps every test independent.
- Replace the class annotations and setup section through reset() with this updated section:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TicketReservationIntegrationTest.TestClockConfiguration.class)
class TicketReservationIntegrationTest {
static final UUID EVENT = UUID.fromString("20000000-0000-0000-0000-000000000001");
static final UUID SEAT = UUID.fromString("10000000-0000-0000-0000-000000000001");
static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
@Container @ServiceConnection
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18.6-alpine3.24");
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired MutableClock clock;
final HttpClient client = HttpClient.newHttpClient();
@BeforeEach void reset() {
jdbc.update("DELETE FROM unsafe_reservations");
jdbc.update("DELETE FROM reservations");
jdbc.update("UPDATE seats SET state='AVAILABLE', hold_id=NULL, held_by=NULL, hold_expires_at=NULL, version=0");
clock.reset();
}
How is the clock connected?
- The @Import annotation adds TestClockConfiguration to the test application context.
- The START constant gives every test the same initial instant.
- The injected clock references the primary MutableClock bean.
- The reset method clears committed database state before restoring the initial time.
- Save TicketReservationIntegrationTest.java.
- Verify that every existing test uses the injected clock successfully by running:
mvn test
What should I see?
You should see the existing tests complete successfully. The API now reads the test-controlled instant during integration tests.
Does the application context fail to start?
- Check that @Import references TicketReservationIntegrationTest.TestClockConfiguration.class.
- Check that the clock bean carries the @Primary annotation.
- Help me debug the test clock application context.
Race expiration against the next buyer
The final test advances time past the hold deadline before launching both requests. The old confirmation and the new hold then compete for the same locked seat row.
- Add this test below concurrentConfirmationCreatesOneReservation():
@Test void expiredHoldCannotConfirmWhileNextBuyerReclaims() throws Exception {
UUID holdId = uuid(post("/api/events/" + EVENT + "/holds", body("buyer-a")).body(), "holdId");
clock.advance(Duration.ofSeconds(31));
var responses = posts(new Spec("/api/holds/" + holdId + "/confirm", "{}"),
new Spec("/api/events/" + EVENT + "/holds", body("buyer-c")));
var codes = statuses(responses);
assertEquals(201, codes.get(0));
assertTrue(codes.get(1) == 404 || codes.get(1) == 410);
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM reservations WHERE seat_id=?", Integer.class, SEAT));
assertEquals("buyer-c", jdbc.queryForObject("SELECT held_by FROM seats WHERE id=?", String.class, SEAT));
}
What does the expiration race prove?
- The first request creates a hold for buyer-a.
- The clock moves 31 seconds beyond the original hold time.
- The two HTTP requests compete through separate service transactions.
- The database assertions require zero reservations for the expired owner.
- The final assertion requires buyer-c to hold seat A1.
- Save TicketReservationIntegrationTest.java.
Before you run the race, consider which request reaches a committed state.
- Run the expiration race test by using this command:
mvn -Dtest=TicketReservationIntegrationTest#expiredHoldCannotConfirmWhileNextBuyerReclaims test
What should I see?
You should see the test complete successfully. The old confirmation returns 404 or 410 while the next buyer receives 201.
The committed database state contains no reservation for the expired hold. Buyer C owns the replacement hold.
Does the expiration race fail?
- Check that the test advances the clock by 31 seconds before starting either competing request.
- Check that createHold() calls expireIfNeeded() while holding the seat lock.
- Check that expired confirmation releases the seat without creating a Reservation.
- Help me debug the expiration race test.
✔️ Awesome, I've got everything!
Your expiration race passes. Double check that you saved TicketReservationIntegrationTest.java.
ⓧ I'd like to double check the full code
Compare your complete TicketReservationIntegrationTest.java file with this reference:
package com.nextwork.ticketing;
import static org.junit.jupiter.api.Assertions.*;
import java.net.URI;
import java.net.http.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TicketReservationIntegrationTest.TestClockConfiguration.class)
class TicketReservationIntegrationTest {
static final UUID EVENT = UUID.fromString("20000000-0000-0000-0000-000000000001");
static final UUID SEAT = UUID.fromString("10000000-0000-0000-0000-000000000001");
static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
@Container @ServiceConnection
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18.6-alpine3.24");
@LocalServerPort int port;
@Autowired JdbcTemplate jdbc;
@Autowired MutableClock clock;
final HttpClient client = HttpClient.newHttpClient();
@BeforeEach void reset() {
jdbc.update("DELETE FROM unsafe_reservations");
jdbc.update("DELETE FROM reservations");
jdbc.update("UPDATE seats SET state='AVAILABLE', hold_id=NULL, held_by=NULL, hold_expires_at=NULL, version=0");
clock.reset();
}
@Test void unsafePathDoubleBooksOneSeat() throws Exception {
var responses = posts(new Spec("/api/lab/unsafe-reservations", body("buyer-a")),
new Spec("/api/lab/unsafe-reservations", body("buyer-b")));
assertEquals(List.of(201, 201), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM unsafe_reservations WHERE seat_id=?", Integer.class, SEAT);
assertEquals(2, count); System.out.println("Unsafe reservations for one seat: " + count);
}
@Test void pessimisticLockAllowsOneHoldWinner() throws Exception {
String path = "/api/events/" + EVENT + "/holds";
var responses = posts(new Spec(path, body("buyer-a")), new Spec(path, body("buyer-b")));
assertEquals(List.of(201, 409), statuses(responses));
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM seats WHERE id=? AND state='HELD'", Integer.class, SEAT);
assertEquals(1, count); System.out.println("Safe hold winners: " + count);
}
@Test void concurrentConfirmationCreatesOneReservation() throws Exception {
UUID holdId = uuid(post("/api/events/" + EVENT + "/holds", body("buyer-a")).body(), "holdId");
var responses = posts(new Spec("/api/holds/" + holdId + "/confirm", "{}"),
new Spec("/api/holds/" + holdId + "/confirm", "{}"));
assertEquals(List.of(201, 404), statuses(responses));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM reservations WHERE seat_id=?", Integer.class, SEAT));
}
@Test void expiredHoldCannotConfirmWhileNextBuyerReclaims() throws Exception {
UUID holdId = uuid(post("/api/events/" + EVENT + "/holds", body("buyer-a")).body(), "holdId");
clock.advance(Duration.ofSeconds(31));
var responses = posts(new Spec("/api/holds/" + holdId + "/confirm", "{}"),
new Spec("/api/events/" + EVENT + "/holds", body("buyer-c")));
var codes = statuses(responses);
assertEquals(201, codes.get(0));
assertTrue(codes.get(1) == 404 || codes.get(1) == 410);
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM reservations WHERE seat_id=?", Integer.class, SEAT));
assertEquals("buyer-c", jdbc.queryForObject("SELECT held_by FROM seats WHERE id=?", String.class, SEAT));
}
List<HttpResponse<String>> posts(Spec a, Spec b) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<HttpResponse<String>> x = executor.submit(() -> post(a.path(), a.body()));
Future<HttpResponse<String>> y = executor.submit(() -> post(b.path(), b.body()));
return List.of(x.get(), y.get());
}
}
HttpResponse<String> post(String path, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
static List<Integer> statuses(List<HttpResponse<String>> responses) {
return responses.stream().map(HttpResponse::statusCode).sorted().toList();
}
static String body(String buyer) {
return "{\"seatId\":\"%s\",\"buyerId\":\"%s\"}".formatted(SEAT, buyer);
}
static UUID uuid(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]+)\"").matcher(json);
if (!m.find()) throw new IllegalArgumentException(json);
return UUID.fromString(m.group(1));
}
record Spec(String path, String body) {}
@TestConfiguration(proxyBeanMethods = false)
static class TestClockConfiguration {
@Bean @Primary MutableClock testClock() { return new MutableClock(START); }
}
static final class MutableClock extends Clock {
Instant current;
MutableClock(Instant current) { this.current = current; }
void advance(Duration duration) { current = current.plus(duration); }
void reset() { current = START; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return current; }
}
}
Your API now makes expiration and confirmation decisions against one locked seat state. Next up, you'll run the complete proof suite and document the one-owner invariant.
Prove and Document the Invariant
The safe API now resolves every reservation race inside a PostgreSQL transaction. Correct-looking code alone cannot prove that the one-owner invariant survives concurrent requests.
This step turns the implementation into repeatable evidence with the existing Testcontainers suite. You will also create a GitHub-ready explanation that a reviewer can inspect.
In this step, get ready to:
- Verify that duplicate confirmation commits one reservation.
- Run the complete concurrency suite repeatedly against PostgreSQL.
- Create a README that explains the correctness boundary.
Inspect the duplicate-confirmation proof
The duplicate-confirmation test sends two requests for the same live hold. Its HTTP assertions prove one request wins while its JdbcTemplate assertion checks the committed database state.
- Select src/test/java/com/nextwork/ticketing/TicketReservationIntegrationTest.java in the Visual Studio Code file sidebar.
- Locate the concurrentConfirmationCreatesOneReservation() test.
- Inspect the response assertion to identify the two HTTP outcomes.
- Inspect the database assertion to identify the permitted reservation count.
Before you run the suite, which result would violate the one-owner invariant? Hold your prediction while the test exercises every race.
- Run the complete integration suite from the repository terminal by running:
mvn test
What Does the Suite Prove?
- The unsafe test proves that two synchronized buyers can commit two rows without a database correctness boundary.
- The pessimistic-hold test proves that competing requests produce one 201 response plus one 409 response.
- The duplicate-confirmation test proves that competing confirmations produce one 201 response plus one 404 response.
- The expiration test proves that the former holder cannot create a reservation after buyer-c reclaims the seat.
You should see all four tests complete with zero failures. Maven reports a successful build after the disposable database assertions pass.
Seeing a Test Failure?
- Confirm Docker is running before Testcontainers starts its PostgreSQL container.
- Check that TicketReservationIntegrationTest.java still contains all four test methods from the previous steps.
- Check the first failing assertion to identify whether the mismatch is an HTTP status or a database row count.
- Help me debug the failing reservation suite.
Document the architecture and correctness path
A strong README connects each observed outcome to the mechanism that protects it. The Mermaid diagram makes the unsafe path visible beside the transactional path.
- Select the project name at the top of the Visual Studio Code file sidebar.
- Use the new-file control above the file tree to create README.md beside pom.xml.
- Add the project purpose plus its invariant plus its architecture by pasting this content:
# Distributed Ticket Reservation API: PostgreSQL Correctness Core
A Java 25 and Spring Boot API that demonstrates why ticket correctness belongs in the authoritative database transaction.
## Invariant
For every seat ID, the `reservations` table contains at most one confirmed owner.
## Architecture
```mermaid
flowchart LR
C[Concurrent buyers] --> API[Spring Boot REST API]
API --> U[Unsafe lab service]
U --> UT[(unsafe_reservations)]
API --> S[Transactional reservation service]
S --> L[PESSIMISTIC_WRITE seat query]
L --> PG[(PostgreSQL)]
PG --> UC[UNIQUE reservations.seat_id]
F[Flyway migrations] --> PG
T[JUnit 5 and virtual-thread clients] --> API
TC[Testcontainers] --> PG
```
What Does This Documentation Show?
- The invariant gives reviewers one precise correctness claim to test against every scenario.
- The unsafe branch shows where double booking becomes visible in the isolated lab table.
- The transactional branch shows the seat-row lock before the authoritative PostgreSQL state.
- The unique constraint appears as the final database backstop for confirmed ownership.
- Save README.md.
- Use the preview control in the editor toolbar to render README.md.
You should see the invariant as its own section. The architecture should render as a left-to-right flow from concurrent buyers to PostgreSQL.
Diagram Not Rendering?
- Check that the opening fence says ```mermaid.
- Check that the diagram ends with a separate closing fence.
- Compare each node label with the code block above.
- Help me fix the Mermaid architecture diagram.
- Return to the README.md editor tab.
- Add the failure evidence plus the correctness workflow plus the API table below the architecture diagram by pasting:
## Failure first
The unsafe endpoint performs an availability read, waits at a two-party barrier, and then inserts without a database lock or uniqueness constraint. The test intentionally proves two committed rows for one seat.
## Correctness path
1. Start a short transaction.
2. Lock the seat row with `PESSIMISTIC_WRITE`.
3. Reclaim an expired hold if necessary.
4. Validate the current state.
5. Create or confirm the hold.
6. Commit the seat state and reservation together.
7. Let `UNIQUE (seat_id)` provide a final database backstop.
## API
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/events/{eventId}/seats` | List logical seat availability |
| POST | `/api/events/{eventId}/holds` | Create a 30-second hold |
| POST | `/api/holds/{holdId}/confirm` | Confirm a live hold |
| DELETE | `/api/holds/{holdId}` | Release a live hold |
| GET | `/api/reservations/{reservationId}` | Retrieve a confirmed reservation |
| POST | `/api/lab/unsafe-reservations` | Reproduce double booking in the lab path |
Why Does This Evidence Matter?
- The failure section records the exact vulnerable window created by the availability check plus the barrier.
- The seven-step path follows one transaction from lock acquisition through commit.
- The API table separates the production-style workflow from the unsafe lab endpoint.
- Save README.md.
The Markdown preview should now show a numbered seven-step workflow. You should also see six API rows beneath it.
Table Formatting Broken?
- Check that the API header row has three columns.
- Check that the separator row appears directly below the header.
- Check that every endpoint row begins plus ends with a pipe character.
- Help me repair the README API table.
Finish the README and rerun the proof
The final README sections turn test output into a repeatable demonstration. They also record the locking trade-offs that shape the next project.
- Return to the bottom of README.md.
- Add the local run instructions plus the complete proof table by pasting:
## Run locally
```bash
docker compose up -d
mvn spring-boot:run
```
Seed event: `20000000-0000-0000-0000-000000000001`
Seed seat A1: `10000000-0000-0000-0000-000000000001`
## Run the proof
```bash
mvn test
```
| Scenario | Expected committed outcome |
| --- | --- |
| Unsafe two-buyer race | Two unsafe rows for one seat |
| Safe two-buyer hold race | One `201`, one `409`, one held owner |
| Duplicate confirmation | One reservation, one rejected retry |
| Expiry versus confirmation | Expired owner cannot confirm; next buyer can hold |
What Does the Proof Table Capture?
- The local commands give reviewers a direct path to the running API.
- The seed identifiers make the example event plus seat reproducible.
- The proof table pairs each race with committed database evidence.
- Save README.md.
The preview should now show the local commands plus both seed identifiers. The proof table should contain four concurrency scenarios.
Missing a Proof Scenario?
- Compare the table rows with the four test methods in TicketReservationIntegrationTest.java.
- Check that the unsafe race remains clearly separated from the safe reservation workflow.
- Help me align the README proof table with the test suite.
- Add the trade-offs plus the Project 2 handoff plus the cleanup section at the bottom of README.md by pasting:
## Trade-offs
- Pessimistic locking is direct for a highly contended seat, but contenders wait and poor lock ordering can create deadlocks.
- Optimistic locking avoids waiting when conflicts are rare, but hot ticket drops can create failed work and retries.
- Lazy expiration keeps the hold decision inside the same seat transaction.
- The unsafe table exists only to preserve reproducible failure evidence.
- PostgreSQL requires retrying the complete transaction after serialization failures and deadlocks. Retry policy is deferred.
## Project 2 handoff
Project 2 adds PostgreSQL-backed idempotency, Redis admission control, two application instances, fail-open versus fail-closed outage behavior, and load testing. Redis reduces pressure but never authorizes a sale.
## Cleanup
```bash
docker compose stop
docker compose down -v
```
What Do These Sections Explain?
- The trade-offs explain why a hot seat favors queued pessimistic serialization on the main path.
- The optimistic alternative records the cost of retries when conflicts are frequent.
- The handoff preserves PostgreSQL as the sales authority when later admission controls are added.
- The cleanup commands distinguish a temporary stop from complete local database removal.
- Save README.md.
- Return to the rendered Markdown preview.
You should see the trade-offs as five bullets. The final sections should show the Project 2 handoff plus two cleanup commands.
Cleanup Block Not Rendering?
- Check that the cleanup commands sit between one opening fence plus one closing fence.
- Check that the cleanup heading appears after the Project 2 handoff.
- Help me fix the final README sections.
✔️ Awesome, I've got everything!
Great. Double check that you saved README.md beside pom.xml.
ⓧ I'd like to double check the full code
Compare your complete README.md with this reference file.
# Distributed Ticket Reservation API: PostgreSQL Correctness Core
A Java 25 and Spring Boot API that demonstrates why ticket correctness belongs in the authoritative database transaction.
## Invariant
For every seat ID, the `reservations` table contains at most one confirmed owner.
## Architecture
```mermaid
flowchart LR
C[Concurrent buyers] --> API[Spring Boot REST API]
API --> U[Unsafe lab service]
U --> UT[(unsafe_reservations)]
API --> S[Transactional reservation service]
S --> L[PESSIMISTIC_WRITE seat query]
L --> PG[(PostgreSQL)]
PG --> UC[UNIQUE reservations.seat_id]
F[Flyway migrations] --> PG
T[JUnit 5 and virtual-thread clients] --> API
TC[Testcontainers] --> PG
```
## Failure first
The unsafe endpoint performs an availability read, waits at a two-party barrier, and then inserts without a database lock or uniqueness constraint. The test intentionally proves two committed rows for one seat.
## Correctness path
1. Start a short transaction.
2. Lock the seat row with `PESSIMISTIC_WRITE`.
3. Reclaim an expired hold if necessary.
4. Validate the current state.
5. Create or confirm the hold.
6. Commit the seat state and reservation together.
7. Let `UNIQUE (seat_id)` provide a final database backstop.
## API
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/events/{eventId}/seats` | List logical seat availability |
| POST | `/api/events/{eventId}/holds` | Create a 30-second hold |
| POST | `/api/holds/{holdId}/confirm` | Confirm a live hold |
| DELETE | `/api/holds/{holdId}` | Release a live hold |
| GET | `/api/reservations/{reservationId}` | Retrieve a confirmed reservation |
| POST | `/api/lab/unsafe-reservations` | Reproduce double booking in the lab path |
## Run locally
```bash
docker compose up -d
mvn spring-boot:run
```
Seed event: `20000000-0000-0000-0000-000000000001`
Seed seat A1: `10000000-0000-0000-0000-000000000001`
## Run the proof
```bash
mvn test
```
| Scenario | Expected committed outcome |
| --- | --- |
| Unsafe two-buyer race | Two unsafe rows for one seat |
| Safe two-buyer hold race | One `201`, one `409`, one held owner |
| Duplicate confirmation | One reservation, one rejected retry |
| Expiry versus confirmation | Expired owner cannot confirm; next buyer can hold |
## Trade-offs
- Pessimistic locking is direct for a highly contended seat, but contenders wait and poor lock ordering can create deadlocks.
- Optimistic locking avoids waiting when conflicts are rare, but hot ticket drops can create failed work and retries.
- Lazy expiration keeps the hold decision inside the same seat transaction.
- The unsafe table exists only to preserve reproducible failure evidence.
- PostgreSQL requires retrying the complete transaction after serialization failures and deadlocks. Retry policy is deferred.
## Project 2 handoff
Project 2 adds PostgreSQL-backed idempotency, Redis admission control, two application instances, fail-open versus fail-closed outage behavior, and load testing. Redis reduces pressure but never authorizes a sale.
## Cleanup
```bash
docker compose stop
docker compose down -v
```
How to Compare the File
Check that every heading appears in the same order. Confirm that both tables plus both fenced command sections render in the preview.
Before the final run, do you expect the disposable database reset to preserve the same four outcomes? Use the repeated run to test that prediction.
- Run the complete proof again from the repository terminal by running:
mvn test
What Did the Repeat Run Prove?
- The unsafe path still commits two rows for one seat.
- The locked hold path still produces one winner.
- Duplicate confirmation still leaves one reservation.
- The expiration race still transfers the hold to buyer-c without confirming the expired owner.
You should see all four scenarios pass again against a fresh PostgreSQL container. The repeated success shows that the evidence does not depend on leftover local database state.
Second Run Behaving Differently?
- Check that the reset() method clears both reservation tables before each test.
- Check that the same method returns both seats to AVAILABLE.
- Check that clock.reset() restores the test clock before each race.
- Help me debug a nondeterministic second test run.
That closes the proof loop. Your repository now demonstrates the failure plus the PostgreSQL boundary that prevents it.
Secret mission
Make Optimistic Locking Lose Cleanly
Your safe service queues every hot-seat contender before a write. Build a side-by-side optimistic contest where two transactions read the same version, one commits, and the stale writer fails cleanly.
Clean Up Your Resources
Clean Up Your Resources
This project has no cloud resources or ongoing costs. Choose whether to keep the local services running, pause PostgreSQL, or remove its Docker Compose resources.
Resources you used:
- The local Docker Compose service running PostgreSQL from the postgres:18.6-alpine3.24 image.
- The named Docker volume called postgres-data containing your local reservation database.
- The Spring Boot API process if it is still running in a Visual Studio Code terminal.
Testcontainers removes each disposable test database after its test run. Those containers need no manual cleanup.
Keep everything running
Keep this option if you plan to make more manual API requests or continue developing the reservation workflow.
- Keep the PostgreSQL service running for manual API experiments.
- Keep the Spring Boot API process running while you are sending requests.
- Use the local run commands in README.md whenever you need to restart the environment.
Pause - I'll come back to this later
Stop the running processes to free up local resources. Your database remains stored in the named volume for your next session.
- Return to the terminal where the Spring Boot API is running.
- Press Ctrl+C to stop the API process.
- Return to the Visual Studio Code terminal for the repository.
- Stop PostgreSQL while preserving its named volume by running this command:
docker compose stop
What does this command do?
Docker Compose stops the PostgreSQL service while preserving postgres-data. Your local seats and reservations remain available for the next start.
- Check the Compose service state by running this command:
docker compose ps
What should I see?
You should see no running PostgreSQL service. Your saved database remains in the named volume.
Delete - I don't want to use this again
Remove the local database runtime if you want a clean start. Your GitHub-ready repository remains untouched.
The Database Data Is Permanent
Deleting the named volume permanently removes its local database data. Your source code and documentation stay safe in the repository.
- Return to the terminal where the Spring Boot API is running.
- Press Ctrl+C to stop the API process.
- Return to the Visual Studio Code terminal for the repository.
- Remove the Compose container, network, and named volume by running this command:
docker compose down -v
What does this cleanup remove?
Docker Compose removes the PostgreSQL container and its network. The volume flag also removes postgres-data with its stored database.
- Confirm that no Compose service container remains by running this command:
docker compose ps
What should I see?
You should see no running PostgreSQL service container. The disposable Testcontainers databases are already gone from their completed test runs.
Nice Work!
Nice Work!
You made it! Your Spring Boot reservation API now uses PostgreSQL transactions to protect one confirmed owner per seat.
You've learned how to:
- Reproduce a deterministic race condition with two synchronized buyers. The unsafe lab commits two rows for one seat.
- Protect the one-owner invariant through pessimistic row locking on the authoritative seat row. The database uniqueness constraint remains a final backstop.
- Prove committed outcomes against disposable PostgreSQL with Testcontainers. Explain the architecture plus lock trade-offs in a GitHub-ready README.md.
- Complete the optional Secret Mission by comparing optimistic locking with the production pessimistic path. The @Version experiment produces one successful hold plus one explicit ObjectOptimisticLockingFailureException.
Ready to quiz yourself?