Build a Safe Travel Inventory API
Build and deploy a Java API that prevents travel inventory overselling.
Introduction
30 Second Summary
The final airline seat can attract several booking requests in the same instant. A system that approves more than one leaves customers with a promise it cannot keep.
In this project, you will build a Spring Boot REST API that reserves limited travel inventory in MySQL. You will prove its reservation guarantee under concurrent demand before publishing the service to the cloud.
What You'll Build
Picture sharing a public booking API that accepts one reservation for the final seat while every competing request receives a clear conflict response.
By the end of this project, you'll have:
- A working inventory API where you create an item with a visible SKU. You can read its JSON state before reserving the final unit. A later reservation receives a conflict response.
- A repeatable race condition test that makes overselling visible with eight synchronized callers. After you add a pessimistic row lock, the same test proves that only one reservation succeeds.
- A public Render service backed by Aiven for MySQL. GitHub Actions blocks deployment until the MySQL-backed test suite passes.
- Secret Mission: Generalize the concurrency test to prove five units survive twenty competing callers without inventory becoming negative.
Are there any prerequisites?
This project assumes professional Java experience with Spring. You should already have Java 21, Apache Maven 3.6.3 or later, Git, IntelliJ IDEA, and Docker Desktop installed.
Before We Start
This project stays focused on one business-critical guarantee: accepted reservations never exceed available inventory. Before the hands-on work begins, this checkpoint asks you to commit to proving that guarantee under concurrent demand.
Prepare the Project and Local MySQL
Concurrency failures become harder to diagnose when database versions drift between machines. A reproducible MySQL setup gives every later reservation test the same starting point.
Your existing Java, Apache Maven, Git, IntelliJ IDEA, and Docker Desktop tools provide the local foundation. You will also prepare GitHub, Render, and Aiven for MySQL accounts for the deployment path.
In this step, get ready to:
- Confirm the installed development tools from PowerShell.
- Create the inventory-api Maven project structure.
- Start a healthy local MySQL service with Docker Compose.
Confirm your tools and accounts
Version checks prove that Windows can find each command from your terminal. They also confirm that Docker Desktop exposes the Docker Engine required by Compose.
- Press the Windows key to open search.
- Type Docker Desktop in the search field.
- Press Enter to open Docker Desktop.
- Wait for Docker Desktop to finish starting.
- Press the Windows key to open search again.
- Type PowerShell in the search field.
- Press Enter to open PowerShell.
- Confirm the installed toolchain by running these commands:
java --version
mvn --version
git --version
docker version
docker compose version
What do these checks prove?
- The Java output confirms that the active runtime is version 21.
- The Maven output confirms version 3.6.3 or later. It also reports the Java runtime Maven uses.
- The Git output confirms that its command-line client is available.
- The Docker outputs confirm that the client can reach the Docker Engine. They also confirm that the Compose command is available.
You should see version information from all five commands. The Docker output should include both client details and server details.
Missing a version result?
- Restart PowerShell if a recently installed command cannot be found.
- Confirm Docker Desktop has finished starting if the Docker server details are missing.
- Check that Maven reports Java 21 before continuing.
Ask for help: Help me diagnose which installed development tool PowerShell cannot find.
The cloud accounts support source control, CI, the public web service, and the managed database. Completing their account flows now prevents authentication setup from interrupting the deployment later.
Render may offer optional billing details during setup. Leaving them empty ensures that affected free capabilities suspend when their allowances are exhausted.
- Complete the account flow on the official GitHub website until you are signed in.
- Complete the account flow on the official Render website until you are signed in.
- Leave the payment method option empty in Render.
- Complete the account flow on the official Aiven for MySQL Free page until you are signed in.
Your three cloud accounts are now ready for the repository, database, and deployment steps. No cloud resources have been created yet.
Create the Maven project structure
A Maven project uses a predictable directory layout for application code, tests, and runtime configuration. IntelliJ IDEA can create that layout while keeping Java 21 attached to the project.
- Press the Windows key to open search.
- Type IntelliJ IDEA in the search field.
- Press Enter to open IntelliJ IDEA.
You should see the IntelliJ IDEA welcome screen after startup completes.
- Click New Project on the welcome screen.
- Enter inventory-api in the Name field.
- Select your Desktop in the Location field.
- Select Java as the language.
- Select Maven as the build system.
- Select JDK 21 from the JDK list.
- Select Create Git repository.
- Expand Advanced Settings.
Why create a Maven project?
Maven treats pom.xml as the source of truth for dependencies and build plugins. IntelliJ IDEA reads that file to configure the project model.
- Enter com.example in the GroupId field.
- Enter inventory-api in the ArtifactId field.
- Clear Add sample code.
- Click Create.
IntelliJ IDEA opens the new inventory-api project. Its project tree includes pom.xml plus the standard source directories.
- Select src/main/java in the Project tool window.
- Press Alt+Insert.
- Select Package.
- Enter com.example.inventory.
- Select src/test/java in the Project tool window.
- Press Alt+Insert.
- Select Package.
- Enter com.example.inventory.
The project tree now contains matching application and test packages. This keeps future tests in the same Java package as the package-private production classes.
Check whether src/main/resources appears in the project tree.
✔️ I see src/main/resources
The resource directory is ready for the application configuration added in the next step.
ⓧ The resources folder is missing
Create the missing directory inside src/main.
- Select src/main in the Project tool window.
- Press Alt+Insert.
- Select Directory.
- Enter resources.
Git ignore rules keep editor metadata, build output, and local environment files out of the repository.
- Open .gitignore from the top level of inventory-api.
- Replace its contents with these rules:
.idea/
target/
*.iml
.env
What do these rules protect?
- The .idea/ rule excludes local IntelliJ IDEA settings.
- The target/ rule excludes Maven build output.
- The *.iml rule excludes IntelliJ IDEA module files.
- The .env rule prevents a local environment file from entering Git.
- Save .gitignore.
- Confirm that the file contains exactly four ignore rules.
Cannot find the generated files?
- Confirm that the Project tool window is showing the inventory-api directory.
- Check that the project location points to your Desktop.
- Use Synchronize all Maven projects if the Maven folders are not recognized.
Ask for help: Help me check the Maven directory structure in my IntelliJ IDEA project.
Configure and start local MySQL
Docker Compose captures the database image, local credentials, storage, and readiness check in one file. The application service is defined now so the same stack can run the API after its container image exists.
- Select the top-level inventory-api directory in the Project tool window.
- Press Alt+Insert.
- Select File.
- Enter compose.yaml.
- Define the MySQL service by pasting this first configuration block:
services:
mysql:
image: mysql:8.4.11
environment:
MYSQL_ROOT_PASSWORD: local-root
MYSQL_DATABASE: inventory
MYSQL_USER: inventory
MYSQL_PASSWORD: inventory
ports:
- "3306:3306"
volumes:
- inventory-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
What does the MySQL service define?
- The mysql:8.4.11 image pin keeps the local database version consistent.
- The MYSQL_DATABASE setting creates the inventory database.
- The local inventory username uses the matching inventory password. These credentials are only for local development.
- The health check uses mysqladmin ping to report when MySQL is ready for connections.
- Append the application service and named volume below the MySQL service:
app:
build: .
depends_on:
mysql:
condition: service_healthy
environment:
DB_URL: jdbc:mysql://mysql:3306/inventory
DB_USERNAME: inventory
DB_PASSWORD: inventory
ports:
- "8080:8080"
volumes:
inventory-mysql-data:
What does the remaining configuration do?
- The app service builds from the project directory.
- The service_healthy condition prevents the future application container from starting before MySQL is ready.
- The DB_URL value uses the Compose service name mysql as the database host.
- The inventory-mysql-data volume preserves database files when the container stops.
The generated Maven file still needs the exact dependencies used by the inventory API. Use the complete-file view below to replace pom.xml and compare all three project files.
- Open pom.xml from the top level of inventory-api.
- Replace its contents with the pom.xml file in the second tab below.
✔️ Awesome, I've got everything!
Great. Double check that all three files are saved before starting MySQL.
ⓧ I'd like to double check the full code
Compare these complete files with the versions in your inventory-api directory.
<?xml version="1.0" encoding="UTF-8"?>
<project>
<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.example</groupId>
<artifactId>inventory-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>inventory-api</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.7.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>4.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.1.1</version>
</plugin>
</plugins>
</build>
</project>
What does the Maven configuration provide?
- The Spring Boot parent pins the project to version 4.1.1 with Java 21.
- The web, JPA, Jakarta Validation, and Actuator starters provide the API, persistence, input checks, and operational endpoints.
- MySQL Connector/J connects the application to MySQL. The test dependencies provide JUnit Jupiter and AssertJ.
- The Spring Boot Maven plugin packages the application for execution.
.idea/
target/
*.iml
.env
These four rules exclude local editor settings, Maven output, module metadata, and environment files.
services:
mysql:
image: mysql:8.4.11
environment:
MYSQL_ROOT_PASSWORD: local-root
MYSQL_DATABASE: inventory
MYSQL_USER: inventory
MYSQL_PASSWORD: inventory
ports:
- "3306:3306"
volumes:
- inventory-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
app:
build: .
depends_on:
mysql:
condition: service_healthy
environment:
DB_URL: jdbc:mysql://mysql:3306/inventory
DB_USERNAME: inventory
DB_PASSWORD: inventory
ports:
- "8080:8080"
volumes:
inventory-mysql-data:
This Compose file defines the local MySQL service, the future application service, and the persistent database volume.
- Press Ctrl+S to save all project files.
- Click Synchronize all Maven projects in the Maven tool window.
The first Maven synchronization downloads the project dependencies. A longer progress indicator is expected while those files arrive.
You should see the Maven project reload without configuration errors. The project tree should still show both com.example.inventory packages and src/main/resources.
Maven project not loading?
- Confirm that pom.xml sits directly inside inventory-api.
- Check that every opening XML element has a matching closing element.
- Confirm that IntelliJ IDEA still uses JDK 21 for the project.
Ask for help: Help me diagnose why IntelliJ IDEA cannot synchronize this Maven project.
Before you run the final check, do you expect Compose to return immediately or wait for the MySQL health check?
- Start only the MySQL service from the inventory-api directory by running:
docker compose up -d --wait mysql
What does this command do?
- The up command creates and starts the selected service.
- The -d option leaves MySQL running in the background.
- The --wait option keeps the command active until the service is running or healthy.
- The mysql argument starts only the database service.
The first run downloads the pinned MySQL image, so the terminal may stay busy while its layers arrive. The command exits successfully once the MySQL health check passes.
That establishes the reproducible database boundary for the project. MySQL is now healthy at localhost:3306 with the inventory database ready.
MySQL not becoming healthy?
- Confirm that Docker Desktop is still running.
- Check that compose.yaml uses spaces for indentation.
- Confirm that another local database is not already using port 3306.
Ask for help: Help me diagnose why the MySQL Compose service is not becoming healthy.
Your Maven project and local database are ready. Next up, you will build the first working inventory API against this MySQL service.
Ship the Naive Inventory API
Your local MySQL service is healthy. Now the empty project needs a believable REST API that can store scarce travel inventory.
A single reservation request can look correct while hiding a race condition. You will build an ordinary Spring Data JPA transaction path in Spring Boot to create that baseline.
In this step, get ready to:
- Model inventory in MySQL through Spring Data JPA.
- Expose the inventory workflow through validated REST routes.
- Run a seeded LAST-SEAT item through the local API.
Build the domain and persistence layer
The domain layer defines what an inventory item can do. The persistence layer gives those objects durable rows in MySQL.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA to find the installed application.
- Press Enter to open IntelliJ IDEA.
- Select the existing inventory-api project from your recent projects.
- Create src/main/java/com/example/inventory/InventoryExceptions.java through the IntelliJ IDEA project file tree with this content:
package com.example.inventory;
class InventoryItemNotFoundException extends RuntimeException {
InventoryItemNotFoundException(String identifier) {
super("Inventory item not found: " + identifier);
}
}
class SoldOutException extends RuntimeException {
SoldOutException(String sku) {
super("Inventory is sold out for SKU: " + sku);
}
}
class DuplicateSkuException extends RuntimeException {
DuplicateSkuException(String sku) {
super("Inventory already exists for SKU: " + sku);
}
}
What do these exceptions represent?
- The three exception types give missing inventory, sold-out inventory, and duplicate SKUs distinct failure paths.
- Each message carries the identifier that caused the failure. The controller will convert these failures into useful HTTP responses.
- Save InventoryExceptions.java.
- Verify that the exception classes compile by running this command in the IntelliJ IDEA terminal:
mvn --batch-mode --update-snapshots verify
The Maven build should complete successfully. This proves the first domain types compile inside the project.
Seeing a compilation failure?
- Check that the file sits directly inside src/main/java/com/example/inventory.
- Confirm that the package line reads package com.example.inventory;.
Ask for help: Help me debug the exception compilation failure.
- Create src/main/java/com/example/inventory/InventoryItem.java through the project file tree with this initial entity structure:
package com.example.inventory;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "inventory_items")
class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 80)
private String sku;
@Column(nullable = false)
private int availableUnits;
protected InventoryItem() {
}
InventoryItem(String sku, int availableUnits) {
this.sku = sku;
this.availableUnits = availableUnits;
}
}
What does this entity define?
- The @Entity mapping stores each object in the inventory_items table.
- The database generates each id. It also enforces a unique non-null sku value.
- The protected empty constructor supports JPA object creation. The package-visible constructor supports application code.
- Save InventoryItem.java.
- Verify that the entity structure compiles by running:
mvn --batch-mode --update-snapshots verify
The build should complete successfully with the new entity included.
Does the entity fail to compile?
- Check that every persistence import begins with jakarta.persistence.
- Confirm that InventoryItem has one final closing brace.
Ask for help: Help me fix the InventoryItem entity.
- Place your cursor immediately above the final brace in InventoryItem.java.
- Add the entity accessors and reservation rule with this code:
Long getId() {
return id;
}
String getSku() {
return sku;
}
int getAvailableUnits() {
return availableUnits;
}
void reserveOne() {
if (availableUnits < 1) {
throw new SoldOutException(sku);
}
availableUnits--;
}
What does the reservation rule protect?
- The accessors expose the values needed by service responses without making the fields public.
- The reserveOne() method rejects reservations when the current object has no units left.
- A successful call decrements the in-memory value by exactly one unit.
- Save InventoryItem.java.
- Verify that the complete entity compiles by running:
mvn --batch-mode --update-snapshots verify
The Maven build should still complete successfully. Your inventory object now owns its sold-out rule.
Is reserveOne missing from the class?
- Move the new methods inside the final brace of the InventoryItem class.
- Check that availableUnits-- remains after the sold-out check.
Ask for help: Help me place the InventoryItem methods correctly.
- Create src/main/java/com/example/inventory/InventoryModels.java through the project file tree with these API records:
package com.example.inventory;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
record CreateInventoryRequest(
@NotBlank String sku,
@Positive int availableUnits) {
}
record InventoryResponse(Long id, String sku, int availableUnits) {
static InventoryResponse from(InventoryItem item) {
return new InventoryResponse(item.getId(), item.getSku(), item.getAvailableUnits());
}
}
record ApiError(String code, String message) {
}
How do these records shape the API?
- The CreateInventoryRequest record uses Jakarta Validation to reject blank SKUs and non-positive quantities.
- The InventoryResponse record converts an entity into the three values returned to clients.
- The ApiError record gives failures a stable code and message structure.
- Save InventoryModels.java.
- Verify that the API records compile by running:
mvn --batch-mode --update-snapshots verify
The build should complete successfully with validation available on the create request.
Are the validation imports unresolved?
- Confirm that the imports use jakarta.validation.constraints.
- Reload the Maven project if IntelliJ IDEA has not indexed the validation dependency from pom.xml.
Ask for help: Help me resolve the validation imports.
- Create src/main/java/com/example/inventory/InventoryRepository.java through the project file tree with this repository interface:
package com.example.inventory;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findBySku(String sku);
}
What does the repository provide?
- Extending JpaRepository supplies persistence operations for InventoryItem rows.
- The declared findBySku() query supports the public SKU lookup.
- The service will inherit findById() without any row lock. That ordinary lookup creates the baseline for the next step.
- Save InventoryRepository.java.
- Verify the complete domain and persistence layer by running:
mvn --batch-mode --update-snapshots verify
The Maven build should complete successfully. Your project can now represent inventory and access it through a repository.
Does Spring reject the repository type?
- Check that InventoryRepository extends JpaRepository<InventoryItem, Long>.
- Confirm that java.util.Optional is imported.
Ask for help: Help me debug the repository interface.
Wire the service and REST routes
The service defines the transaction boundaries around inventory changes. The controller translates those operations into HTTP routes and status codes.
- Create src/main/java/com/example/inventory/InventoryService.java through the project file tree with the service constructor and create operation:
package com.example.inventory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class InventoryService {
private final InventoryRepository repository;
InventoryService(InventoryRepository repository) {
this.repository = repository;
}
@Transactional
InventoryResponse create(CreateInventoryRequest request) {
if (repository.findBySku(request.sku()).isPresent()) {
throw new DuplicateSkuException(request.sku());
}
InventoryItem saved = repository.save(
new InventoryItem(request.sku(), request.availableUnits()));
return InventoryResponse.from(saved);
}
}
What does the create transaction do?
- Constructor injection gives the service one repository dependency.
- The create() transaction checks for an existing SKU before saving a new entity.
- The response is built from the saved entity. This includes the database-generated ID.
- Save InventoryService.java.
- Verify the first service operation by running:
mvn --batch-mode --update-snapshots verify
The build should complete successfully with the create transaction available.
Does the service fail to compile?
- Confirm that InventoryService is in the same package as the package-visible model classes.
- Check that @Transactional comes from Spring transaction support.
Ask for help: Help me fix the create service method.
- Place your cursor immediately above the final brace in InventoryService.java.
- Add the read and deliberately naive reservation operations with this code:
@Transactional(readOnly = true)
InventoryResponse getBySku(String sku) {
return repository.findBySku(sku)
.map(InventoryResponse::from)
.orElseThrow(() -> new InventoryItemNotFoundException(sku));
}
@Transactional
InventoryResponse reserve(Long id) {
InventoryItem item = repository.findById(id)
.orElseThrow(() -> new InventoryItemNotFoundException(id.toString()));
try {
Thread.sleep(150);
}
catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
item.reserveOne();
return InventoryResponse.from(item);
}
Why is this reservation path naive?
- The read operation uses a read-only transaction because it does not change inventory.
- The reserve() operation uses the inherited unlocked findById() lookup inside a transaction.
- The 150 millisecond pause represents downstream work after the read. It widens the interval where concurrent callers can hold the same starting value.
- Save InventoryService.java.
- Verify the complete service by running:
mvn --batch-mode --update-snapshots verify
The Maven build should complete successfully. The reservation path now compiles with an unlocked lookup and a simulated delay.
Is the delay causing a Java error?
- Confirm that Thread.sleep(150) sits inside the try block.
- Check that the catch block handles InterruptedException.
Ask for help: Help me debug the naive reservation method.
- Create src/main/java/com/example/inventory/InventoryController.java through the project file tree with the controller setup and create route:
package com.example.inventory;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/inventory")
class InventoryController {
private final InventoryService service;
InventoryController(InventoryService service) {
this.service = service;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
InventoryResponse create(@Valid @RequestBody CreateInventoryRequest request) {
return service.create(request);
}
}
What does the create route expose?
- The controller places every inventory route under /api/inventory.
- A POST request passes its body through validation before calling create().
- A successful creation receives the HTTP created status.
- Save InventoryController.java.
- Verify the create route by running:
mvn --batch-mode --update-snapshots verify
The build should complete successfully with the first HTTP route registered in code.
Are the controller annotations unresolved?
- Confirm that the web annotations come from org.springframework.web.bind.annotation.
- Check that Valid comes from jakarta.validation.
Ask for help: Help me resolve the controller annotations.
- Place your cursor immediately above the final brace in InventoryController.java.
- Add the read and reservation routes with this code:
@GetMapping("/{sku}")
InventoryResponse get(@PathVariable String sku) {
return service.getBySku(sku);
}
@PostMapping("/{id}/reserve")
InventoryResponse reserve(@PathVariable Long id) {
return service.reserve(id);
}
How do these routes address inventory?
- The GET route uses a SKU because that value is visible to API clients.
- The reservation route uses the database ID to target one inventory row.
- Save InventoryController.java.
- Verify the three inventory routes by running:
mvn --batch-mode --update-snapshots verify
The build should complete successfully with create, read, and reserve operations available.
Does a route method sit outside the controller?
- Move both new methods above the final brace of InventoryController.
- Check that each path variable has the matching @PathVariable annotation.
Ask for help: Help me place the inventory routes correctly.
- Place your cursor immediately above the final brace in InventoryController.java.
- Add the API exception handlers with this code:
@ExceptionHandler(InventoryItemNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ApiError notFound(InventoryItemNotFoundException exception) {
return new ApiError("NOT_FOUND", exception.getMessage());
}
@ExceptionHandler({SoldOutException.class, DuplicateSkuException.class})
@ResponseStatus(HttpStatus.CONFLICT)
ApiError conflict(RuntimeException exception) {
return new ApiError("CONFLICT", exception.getMessage());
}
How are failures translated?
- A missing item becomes a not-found response with a structured ApiError body.
- A sold-out item or duplicate SKU becomes a conflict response.
- Clients receive stable error codes without seeing Java exception details.
- Save InventoryController.java.
- Verify the complete HTTP layer by running:
mvn --batch-mode --update-snapshots verify
The Maven build should complete successfully. Your API contract now includes validated requests and structured failures.
Do the exception handlers conflict?
- Confirm that only InventoryItemNotFoundException appears in the not-found handler.
- Keep SoldOutException and DuplicateSkuException together in the conflict handler.
Ask for help: Help me debug the API exception handlers.
Launch and verify the seeded API
The API now needs runtime configuration and a main application class. A small data initializer will then create the final seat used throughout the concurrency experiment.
- Create src/main/resources/application.properties through the project file tree with this configuration:
spring.application.name=inventory-api
server.port=${PORT:8080}
spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/inventory}
spring.datasource.username=${DB_USERNAME:inventory}
spring.datasource.password=${DB_PASSWORD:inventory}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
management.endpoints.web.exposure.include=health,metrics
What does this configuration control?
- The server uses port 8080 unless the environment supplies another port.
- The datasource properties use environment variables when present. Their defaults connect to the local MySQL service from the previous step.
- Hibernate updates the demo schema. Spring Boot Actuator exposes health and metrics endpoints.
- Save application.properties.
- Confirm that the file remains under src/main/resources.
Is the configuration being ignored?
- Check that the filename is exactly application.properties.
- Confirm that the file is inside src/main/resources.
Ask for help: Help me check the Spring configuration file.
- Create src/main/java/com/example/inventory/InventoryApiApplication.java through the project file tree with the application entry point:
package com.example.inventory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class InventoryApiApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryApiApplication.class, args);
}
}
What starts the application?
- The @SpringBootApplication annotation enables application configuration and component discovery.
- The main() method starts the Spring application context.
- Save InventoryApiApplication.java.
- Open InventoryApiApplication.java in the IntelliJ IDEA editor.
- Click the green run icon beside the main() method.
The first application start can take a little longer while Maven finishes resolving dependencies. The run process should stay active without a startup failure.
- Open http://localhost:8080/actuator/health in your browser.
You should see a health response showing that the application is healthy. This also proves the application reached the local MySQL database.
Does the application stop during startup?
- Confirm that Docker Desktop is still running the healthy mysql service from the previous step.
- Check that another process is not already using port 8080.
- Compare the datasource defaults with the local database name and credentials from compose.yaml.
Ask for help: Help me diagnose the Spring Boot startup failure.
- Stop the active application process from the IntelliJ IDEA run panel.
- Create src/main/java/com/example/inventory/DemoDataConfiguration.java through the project file tree with this initializer:
package com.example.inventory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class DemoDataConfiguration {
@Bean
CommandLineRunner seedInventory(InventoryRepository repository) {
return args -> repository.findBySku("LAST-SEAT")
.orElseGet(() -> repository.save(new InventoryItem("LAST-SEAT", 1)));
}
}
How does the demo item stay repeatable?
- The seedInventory() runner executes when the application starts.
- It searches for LAST-SEAT before saving a new item with one available unit.
- Restarting the application does not create another row with the same SKU.
- Save DemoDataConfiguration.java.
- Return to InventoryApiApplication.java.
- Click the green run icon beside the main() method.
Before you check the API, what inventory state do you expect the initializer to create?
- Open http://localhost:8080/api/inventory/LAST-SEAT in your browser.
You should see JSON containing the LAST-SEAT SKU with one available unit. That visible response proves the controller, service, repository, and MySQL database are connected.
- Return to http://localhost:8080/actuator/health in your browser.
You should see the application report a healthy state again. The seeded API baseline is now running against MySQL.
Can you see health but not LAST-SEAT?
- Confirm that DemoDataConfiguration.java is inside the com.example.inventory package.
- Restart the application after saving the initializer.
- Check that the browser URL ends with the exact SKU LAST-SEAT.
Ask for help: Help me find the missing seeded inventory item.
✔️ Awesome, I've got everything!
Great. Save every source file while the application is showing the seeded inventory response.
ⓧ I'd like to double check the full code
Compare each file below with your completed naive API.
package com.example.inventory;
class InventoryItemNotFoundException extends RuntimeException {
InventoryItemNotFoundException(String identifier) {
super("Inventory item not found: " + identifier);
}
}
class SoldOutException extends RuntimeException {
SoldOutException(String sku) {
super("Inventory is sold out for SKU: " + sku);
}
}
class DuplicateSkuException extends RuntimeException {
DuplicateSkuException(String sku) {
super("Inventory already exists for SKU: " + sku);
}
}
package com.example.inventory;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "inventory_items")
class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 80)
private String sku;
@Column(nullable = false)
private int availableUnits;
protected InventoryItem() {
}
InventoryItem(String sku, int availableUnits) {
this.sku = sku;
this.availableUnits = availableUnits;
}
Long getId() {
return id;
}
String getSku() {
return sku;
}
int getAvailableUnits() {
return availableUnits;
}
void reserveOne() {
if (availableUnits < 1) {
throw new SoldOutException(sku);
}
availableUnits--;
}
}
package com.example.inventory;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
record CreateInventoryRequest(
@NotBlank String sku,
@Positive int availableUnits) {
}
record InventoryResponse(Long id, String sku, int availableUnits) {
static InventoryResponse from(InventoryItem item) {
return new InventoryResponse(item.getId(), item.getSku(), item.getAvailableUnits());
}
}
record ApiError(String code, String message) {
}
package com.example.inventory;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findBySku(String sku);
}
package com.example.inventory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class InventoryService {
private final InventoryRepository repository;
InventoryService(InventoryRepository repository) {
this.repository = repository;
}
@Transactional
InventoryResponse create(CreateInventoryRequest request) {
if (repository.findBySku(request.sku()).isPresent()) {
throw new DuplicateSkuException(request.sku());
}
InventoryItem saved = repository.save(
new InventoryItem(request.sku(), request.availableUnits()));
return InventoryResponse.from(saved);
}
@Transactional(readOnly = true)
InventoryResponse getBySku(String sku) {
return repository.findBySku(sku)
.map(InventoryResponse::from)
.orElseThrow(() -> new InventoryItemNotFoundException(sku));
}
@Transactional
InventoryResponse reserve(Long id) {
InventoryItem item = repository.findById(id)
.orElseThrow(() -> new InventoryItemNotFoundException(id.toString()));
try {
Thread.sleep(150);
}
catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
item.reserveOne();
return InventoryResponse.from(item);
}
}
package com.example.inventory;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/inventory")
class InventoryController {
private final InventoryService service;
InventoryController(InventoryService service) {
this.service = service;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
InventoryResponse create(@Valid @RequestBody CreateInventoryRequest request) {
return service.create(request);
}
@GetMapping("/{sku}")
InventoryResponse get(@PathVariable String sku) {
return service.getBySku(sku);
}
@PostMapping("/{id}/reserve")
InventoryResponse reserve(@PathVariable Long id) {
return service.reserve(id);
}
@ExceptionHandler(InventoryItemNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ApiError notFound(InventoryItemNotFoundException exception) {
return new ApiError("NOT_FOUND", exception.getMessage());
}
@ExceptionHandler({SoldOutException.class, DuplicateSkuException.class})
@ResponseStatus(HttpStatus.CONFLICT)
ApiError conflict(RuntimeException exception) {
return new ApiError("CONFLICT", exception.getMessage());
}
}
package com.example.inventory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class InventoryApiApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryApiApplication.class, args);
}
}
package com.example.inventory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class DemoDataConfiguration {
@Bean
CommandLineRunner seedInventory(InventoryRepository repository) {
return args -> repository.findBySku("LAST-SEAT")
.orElseGet(() -> repository.save(new InventoryItem("LAST-SEAT", 1)));
}
}
spring.application.name=inventory-api
server.port=${PORT:8080}
spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/inventory}
spring.datasource.username=${DB_USERNAME:inventory}
spring.datasource.password=${DB_PASSWORD:inventory}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
management.endpoints.web.exposure.include=health,metrics
Your single-request inventory flow now works from the browser to MySQL. Next, you will coordinate concurrent callers and watch this ordinary reservation path oversell the final unit.
Make the API Fail Under Concurrency
Your Spring Boot API now reserves the final unit correctly when requests arrive one at a time. Sequential requests hide what happens when several transactions read that same unit together.
A race condition appears when competing callers make decisions from the same stale value. This step uses a synchronized JUnit test to release eight callers at once. Their results expose whether the API preserves its inventory guarantee under contention.
In this step, get ready to:
- Build a concurrency test with eight synchronized reservation callers.
- Assert the one-success inventory invariant against the naive reservation path.
- Run the Maven verification phase to expose overselling.
Create the test source file
The test needs your real repository. It also needs your real transactional service. A full application test provides both through the same MySQL-backed context used by the API.
- Switch back to IntelliJ IDEA from earlier.
- Create the src/test/java/com/example/inventory package path inside inventory-api from IntelliJ's file tree.
- Create ReservationConcurrencyTest.java inside the com.example.inventory package.
- Add the package declaration plus the test imports by copying this code:
package com.example.inventory;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
What do these imports provide?
- The concurrency imports provide the worker pool. They also provide the latches that coordinate every caller.
- The Future collection lets the test wait for every worker before checking the result.
- The AtomicInteger counters safely record outcomes from different threads.
- The Spring testing imports load the application context. AssertJ expresses the business invariant as readable assertions.
- Save ReservationConcurrencyTest.java.
- Return to the terminal you used for inventory-api earlier.
- Confirm the existing project still verifies by running:
mvn --batch-mode --update-snapshots verify
What did this baseline check prove?
The command completes successfully because the new source file contains a valid package plus imports. Your existing application code still compiles before the concurrency test is added.
Your baseline is intact. Any failure after the next edit can now be traced to the new test behavior.
Did the baseline verification stop early?
- Confirm the healthy MySQL service from the previous step is still running.
- Check that ReservationConcurrencyTest.java sits under src/test/java/com/example/inventory.
- Check the package declaration for an exact match with the file path.
Ask for help: Help me diagnose why the Maven baseline verification fails after I add the imports for ReservationConcurrencyTest.java.
Coordinate eight concurrent callers
A thread pool creates the competing callers. Two CountDownLatch instances make the race deterministic. The first confirms that every worker is ready. The second releases all eight workers from the same starting line.
- Add the test class directly below the import block by copying this first section:
@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class ReservationConcurrencyTest {
@Autowired
private InventoryRepository repository;
@Autowired
private InventoryService service;
@Test
void onlyOneConcurrentCallerCanReserveTheLastUnit() throws Exception {
repository.deleteAll();
repository.flush();
InventoryItem item = repository.save(new InventoryItem("LAST-SEAT", 1));
int callers = 8;
ExecutorService pool = Executors.newFixedThreadPool(callers);
CountDownLatch ready = new CountDownLatch(callers);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger successes = new AtomicInteger();
AtomicInteger soldOuts = new AtomicInteger();
try {
How does this set up the race?
- The test clears existing rows before saving a fresh LAST-SEAT item with one available unit.
- The fixed thread pool limits the race to eight worker threads.
- The two atomic counters separate successful reservations from SoldOutException outcomes.
- The final try opens the cleanup boundary. The next chunk completes that boundary before the test runs.
- Complete the open try block by adding this code directly below it:
List<Future<Integer>> futures = IntStream.range(0, callers)
.mapToObj(index -> pool.submit(() -> {
ready.countDown();
start.await();
try {
service.reserve(item.getId());
successes.incrementAndGet();
}
catch (SoldOutException exception) {
soldOuts.incrementAndGet();
}
return index;
}))
.toList();
ready.await();
start.countDown();
for (Future<Integer> future : futures) {
future.get();
}
}
finally {
pool.shutdownNow();
}
assertThat(successes.get()).isEqualTo(1);
assertThat(soldOuts.get()).isEqualTo(7);
assertThat(repository.findBySku("LAST-SEAT").orElseThrow().getAvailableUnits()).isZero();
}
}
What does the race runner prove?
- Each worker lowers the ready latch before waiting on the shared start latch.
- The test releases the start latch only after every worker reaches the waiting point.
- Each Future is collected before the assertions run. This prevents the test from checking partial results.
- The finally block shuts down the pool even when a worker throws an exception.
- The assertions define the business invariant. One reservation succeeds. Seven callers see sold-out inventory. The stored count finishes at zero.
- Save ReservationConcurrencyTest.java.
- Confirm IntelliJ reports no Java syntax problems in the completed file.
The test now has a complete class boundary. It also has a complete method boundary. Every worker result is collected before the inventory assertions run.
Seeing a Java syntax problem?
- Check that the second chunk sits inside the open try block from the first chunk.
- Confirm the file ends with one brace for the test method. Confirm it has another brace for the test class.
- Check that SoldOutException uses the same capitalization as the exception class in your application.
Ask for help: Help me find the syntax problem in my ReservationConcurrencyTest.java file.
✔️ Awesome, I've got everything!
Your synchronized concurrency test is assembled. The source file is ready for the deliberate race check.
ⓧ I'd like to double check the full code
- Compare your complete ReservationConcurrencyTest.java file with this reference:
package com.example.inventory;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class ReservationConcurrencyTest {
@Autowired
private InventoryRepository repository;
@Autowired
private InventoryService service;
@Test
void onlyOneConcurrentCallerCanReserveTheLastUnit() throws Exception {
repository.deleteAll();
repository.flush();
InventoryItem item = repository.save(new InventoryItem("LAST-SEAT", 1));
int callers = 8;
ExecutorService pool = Executors.newFixedThreadPool(callers);
CountDownLatch ready = new CountDownLatch(callers);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger successes = new AtomicInteger();
AtomicInteger soldOuts = new AtomicInteger();
try {
List<Future<Integer>> futures = IntStream.range(0, callers)
.mapToObj(index -> pool.submit(() -> {
ready.countDown();
start.await();
try {
service.reserve(item.getId());
successes.incrementAndGet();
}
catch (SoldOutException exception) {
soldOuts.incrementAndGet();
}
return index;
}))
.toList();
ready.await();
start.countDown();
for (Future<Integer> future : futures) {
future.get();
}
}
finally {
pool.shutdownNow();
}
assertThat(successes.get()).isEqualTo(1);
assertThat(soldOuts.get()).isEqualTo(7);
assertThat(repository.findBySku("LAST-SEAT").orElseThrow().getAvailableUnits()).isZero();
}
}
What should match?
The file should contain one test method. That method should create one inventory item. It should launch eight workers against the same item ID.
The final assertions should require one success. They should require seven sold-out results. They should require zero remaining units.
Run the failing invariant check
The test now asks the business question that sequential requests cannot answer. It checks whether eight callers can collectively reserve more units than the database started with.
- Stop the running InventoryApiApplication process in IntelliJ.
- Return to the inventory-api terminal from earlier.
Before you run the check, do you expect one caller or several callers to report success? Your prediction gives you a result to compare with the test report.
- Run the full MySQL-backed verification with this command:
mvn --batch-mode --update-snapshots verify
What did the failure prove?
The Maven verification exits unsuccessfully. The report for ReservationConcurrencyTest shows that more than one caller reported a successful reservation for the single available unit.
That failure is intentional. Multiple transactions read the same one-unit state before any committed update becomes visible to the others.
You have captured the race in a repeatable test. The concurrency gap is now visible evidence instead of a production-only suspicion.
Did the test miss the intended assertion?
- Confirm the local MySQL service from earlier is still healthy if Maven stops before the test assertions.
- Confirm the temporary delay from the previous step remains immediately after the unlocked reservation lookup if the test unexpectedly passes.
- Confirm the test creates eight workers against the ID of the same one-unit item.
Ask for help: Help me determine why ReservationConcurrencyTest did not expose the expected overselling race.
Your test now reproduces overselling under synchronized contention. Next, you'll close that concurrency gap and make the same inventory invariant pass locally plus in continuous integration.
Lock the Last Unit and Gate Deployments
The synchronized test from the previous step has exposed a lost-update race in MySQL. Several callers can read the same one-unit row before any caller commits its reservation.
This step gives Spring Data JPA a database-enforced serialization point for that row. A GitHub Actions workflow then reruns the same proof before future changes can reach deployment.
In this step, get ready to:
- Lock the selected inventory row during each reservation transaction.
- Remove the simulated delay from the reservation path.
- Gate repository changes with MySQL-backed CI verification.
Lock the inventory row
A pessimistic row lock gives one transaction exclusive write access to the selected inventory row. The database releases that lock when the surrounding @Transactional method finishes.
- In IntelliJ IDEA's Project panel, return to src/main/java/com/example/inventory/InventoryRepository.java.
- Replace the file contents with this locked repository definition:
package com.example.inventory;
import java.util.Optional;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findBySku(String sku);
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<InventoryItem> findById(Long id);
}
What does this repository change do?
- Redeclaring findById(Long id) lets the repository attach locking behavior to the inherited lookup.
- The PESSIMISTIC_WRITE mode asks MySQL to lock the selected row for the active transaction.
- Concurrent callers wait for the lock before reading the latest available-unit count.
- Return to src/main/java/com/example/inventory/InventoryService.java.
- Find the existing reserve(Long id) method that contains the simulated delay.
- Replace the entire method with this final reservation implementation:
@Transactional
InventoryResponse reserve(Long id) {
InventoryItem item = repository.findById(id)
.orElseThrow(() -> new InventoryItemNotFoundException(id.toString()));
item.reserveOne();
return InventoryResponse.from(item);
}
Why keep the transaction boundary?
- The @Transactional boundary keeps the row lock active through the availability check.
- The same transaction decrements the available-unit count before releasing the lock.
- The simulated delay is gone because the concurrency test no longer needs help exposing the race.
- Save InventoryRepository.java.
- Save InventoryService.java.
✔️ Awesome, I've got everything!
Your repository now locks findById(Long id). Your service keeps the reservation inside one transaction without the simulated delay.
ⓧ I'd like to double check the full code
Compare both updated files with these final versions.
package com.example.inventory;
import java.util.Optional;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
interface InventoryRepository extends JpaRepository<InventoryItem, Long> {
Optional<InventoryItem> findBySku(String sku);
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<InventoryItem> findById(Long id);
}
package com.example.inventory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class InventoryService {
private final InventoryRepository repository;
InventoryService(InventoryRepository repository) {
this.repository = repository;
}
@Transactional
InventoryResponse create(CreateInventoryRequest request) {
if (repository.findBySku(request.sku()).isPresent()) {
throw new DuplicateSkuException(request.sku());
}
InventoryItem saved = repository.save(
new InventoryItem(request.sku(), request.availableUnits()));
return InventoryResponse.from(saved);
}
@Transactional(readOnly = true)
InventoryResponse getBySku(String sku) {
return repository.findBySku(sku)
.map(InventoryResponse::from)
.orElseThrow(() -> new InventoryItemNotFoundException(sku));
}
@Transactional
InventoryResponse reserve(Long id) {
InventoryItem item = repository.findById(id)
.orElseThrow(() -> new InventoryItemNotFoundException(id.toString()));
item.reserveOne();
return InventoryResponse.from(item);
}
}
Before you rerun the race, do you think all eight callers can still report success after the first caller commits?
- Run the complete test suite from the inventory-api directory with this command:
mvn --batch-mode --update-snapshots verify
What does this verification prove?
- The Maven verify phase runs ReservationConcurrencyTest against the local MySQL database.
- The test releases eight worker threads against the same final inventory unit.
- Its assertions enforce one successful reservation.
- The remaining assertions enforce seven sold-out results plus zero remaining units.
The build now succeeds because the lock serializes access to LAST-SEAT. That is the core concurrency guarantee working under repeatable contention.
Does the concurrency test still fail?
- Check that @Lock(LockModeType.PESSIMISTIC_WRITE) sits directly above the redeclared findById(Long id) method.
- Check that reserve(Long id) still has its @Transactional annotation.
- Confirm the healthy MySQL service is still running through Docker Compose.
Ask for help: Help me debug why ReservationConcurrencyTest still allows more than one successful reservation after adding PESSIMISTIC_WRITE to findById.
Build the CI verification job
A local passing test protects your current machine. The repository needs the same MySQL-backed check so every push follows an identical verification path.
- In IntelliJ IDEA's Project panel, create .github/workflows/ci.yml inside the inventory-api folder.
- Add the workflow trigger plus the MySQL service configuration with this first section:
name: Java CI
on:
push:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4.11
env:
MYSQL_ROOT_PASSWORD: local-root
MYSQL_DATABASE: inventory
MYSQL_USER: inventory
MYSQL_PASSWORD: inventory
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
What does this workflow section do?
- The workflow starts for pushes plus pull requests.
- The verify job runs on a hosted Linux runner.
- The service container uses the same pinned mysql:8.4.11 image as local development.
- The health options stop the job from testing against a database that is still starting.
- Continue directly below the options block with these workflow steps:
steps:
- name: Check out source
uses: actions/checkout@v6
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: temurin
cache: maven
- name: Verify with Maven
run: mvn --batch-mode --update-snapshots verify
How does CI reproduce the local proof?
- The checkout step places the repository source on the runner.
- The Java setup step selects Temurin Java 21.
- Maven caching reuses downloaded dependencies across workflow runs.
- The final step runs the same verify phase that passed on your machine.
- Save .github/workflows/ci.yml.
- Confirm IntelliJ IDEA's Project panel shows ci.yml inside .github/workflows.
Does the workflow look misaligned?
- Check that steps aligns with services inside the verify job.
- Check that every item beneath steps begins at the same indentation level.
- Confirm the file path begins with the hidden .github directory.
Ask for help: Help me check the indentation and structure of my ci.yml GitHub Actions workflow.
✔️ Awesome, I've got everything!
Your workflow now contains the database service plus the Java verification steps. Save the file before publishing the repository.
ⓧ I'd like to double check the full code
Compare your workflow with this complete file.
name: Java CI
on:
push:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4.11
env:
MYSQL_ROOT_PASSWORD: local-root
MYSQL_DATABASE: inventory
MYSQL_USER: inventory
MYSQL_PASSWORD: inventory
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Check out source
uses: actions/checkout@v6
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: temurin
cache: maven
- name: Verify with Maven
run: mvn --batch-mode --update-snapshots verify
Publish the repository and verify CI
The workflow only becomes a deployment gate after GitHub receives the project. Publishing the repository also gives the next step a source for its cloud deployment.
- Open IntelliJ IDEA's Terminal panel inside the inventory-api project.
- Initialize the repository plus create its first commit by running these commands:
git init -b main
git add .
git commit -m "First commit"
What do these Git commands do?
- The first command initializes a local repository with main as its starting branch.
- The second command stages the project files while respecting .gitignore.
- The third command records the locked API plus its CI workflow as one snapshot.
Did the first commit stop?
Git may require an author name or email before creating your first commit. Follow the terminal guidance to configure the missing identity.
Ask for help: Help me finish my first Git commit after Git requested author identity details.
A public repository makes the project files visible to anyone. Your .gitignore keeps local environment files out of the commit.
- Return to your signed-in GitHub account.
- Create a new repository named inventory-api.
- Select Public for the repository visibility.
- Leave the README initialization option unselected.
- Leave the license initialization option unselected.
- Leave the .gitignore initialization option unselected.
- Click Create repository.
- Copy the HTTPS remote URL from Quick Setup into your GitHub repository URL.
The first push can trigger GitHub authentication. This is a normal credential check before the repository accepts your commit.
- Connect the local repository to GitHub by running this command:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/inventory-api.git"]]
What does the remote command do?
The command stores your GitHub repository URL under the remote name origin. Future pushes can use that short remote name.
- Publish the main branch by running:
git push -u origin main
What happens after the push?
The push uploads your commit to the public repository. The workflow's push trigger starts the Java CI run automatically.
The first workflow needs time to start MySQL plus download dependencies. Expect the run to take a few minutes.
Did the push fail?
- Complete the GitHub authentication flow if the terminal asks for credentials.
- Check that the copied remote URL belongs to your new inventory-api repository.
- Confirm the GitHub repository was created without starter files that could conflict with your local commit.
Ask for help: Help me diagnose why my inventory-api repository did not push to GitHub.
Before you inspect the workflow, do you expect the same MySQL-backed concurrency proof to pass on GitHub's runner?
- Return to the public inventory-api repository on GitHub.
- Select the Actions tab.
- Open the latest Java CI workflow run.
- Confirm the verify job completes successfully.
You will see a successful workflow with a green verify job. Strong work. The one-success reservation invariant now passes locally plus in CI against real MySQL instances.
Your database lock now protects the final unit across concurrent callers. Next, you will package this verified API as a container and deploy it with a managed cloud database.
Deploy the Container to a Free Cloud Stack
The previous step proved that your MySQL row lock protects the final unit. It also placed that proof behind a passing GitHub Actions workflow.
A public deployment makes the concurrency guarantee more credible. The same immutable Docker image must start with external configuration against a managed database.
You will verify the image locally before connecting it to Aiven for MySQL. You will then publish it through Render with health checks and CI-gated deployments.
In this step, get ready to:
- Build the API as a multi-stage container image.
- Create an Aiven for MySQL Free service.
- Deploy the Render Blueprint behind the existing CI gate.
Build and verify the container locally
A multi-stage build compiles the application in a Maven image. A smaller Eclipse Temurin image receives only the packaged JAR.
- In IntelliJ IDEA's project file tree, create Dockerfile inside the inventory-api folder.
- Fill Dockerfile by pasting the following code:
FROM maven:3.10.0-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B -DskipTests clean package
FROM eclipse-temurin:21.0.12.1_1-jre-alpine-3.24
WORKDIR /app
COPY --from=build /workspace/target/inventory-api-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What Does This Dockerfile Do?
- The maven:3.10.0-eclipse-temurin-21-alpine stage compiles the Maven project into a JAR.
- The Maven build skips tests because this image stage has no MySQL service. The existing GitHub Actions workflow remains responsible for the database-backed test suite.
- The eclipse-temurin:21.0.12.1_1-jre-alpine-3.24 stage copies the JAR into the runtime image.
- The container starts app.jar with Java on port 8080.
- Save Dockerfile.
- Confirm Dockerfile appears beside pom.xml in the inventory-api folder.
Dockerfile in the wrong folder?
Check that Dockerfile has no file extension. Make sure it sits directly beside pom.xml.
Ask for help: Help me check the location and filename of my Dockerfile.
Docker sends the project files to its image builder as a build context. A .dockerignore file keeps repository metadata and local build output outside that context.
- In IntelliJ IDEA's project file tree, create .dockerignore inside the inventory-api folder.
- Fill .dockerignore by pasting the following rules:
.git
.github
.idea
target
*.iml
What Does This Ignore File Do?
These rules exclude Git metadata and IntelliJ IDEA files. They also exclude the local target directory because the build stage creates a fresh JAR.
- Save .dockerignore.
- Confirm .dockerignore appears beside Dockerfile in the inventory-api folder.
Ignore file not appearing?
Check that the filename begins with a period. Remove any extra extension that IntelliJ IDEA added.
Ask for help: Help me verify the filename and contents of my Docker ignore file.
The existing compose.yaml already defines the application service. It builds from the project folder and waits for the healthy MySQL service.
- Stop InventoryApiApplication from the IntelliJ IDEA run panel.
Before you run the container stack, do you expect the packaged API to expose the same health and inventory responses as the IntelliJ process?
- Build the image and start the Compose services from the inventory-api folder by running:
docker compose up --build -d --wait
What Does This Command Do?
Docker Compose builds the application image before starting the app service. Detached mode returns control of the terminal after startup.
The --wait option waits until MySQL is healthy. It also waits until the application container is running.
- Open http://localhost:8080/actuator/health in your browser.
You should see an UP response from Spring Boot Actuator inside the application container.
- Open http://localhost:8080/api/inventory/LAST-SEAT in your browser.
You should see JSON with sku set to LAST-SEAT. You should also see availableUnits set to 1.
Container not responding?
- Confirm the IntelliJ IDEA process has stopped so port 8080 is available.
- Confirm Dockerfile sits beside pom.xml.
- Check Docker Desktop to confirm the MySQL service is healthy.
Ask for help: Help me diagnose why my containerized inventory API is not responding.
That local response proves the packaged API can start against MySQL. The same image still exposes the seeded inventory endpoint.
A Render Blueprint stores the service plan and health path in version control. It also identifies the database settings that Render must request during deployment.
- In IntelliJ IDEA's project file tree, create render.yaml inside the inventory-api folder.
- Fill render.yaml by pasting the following configuration:
services:
- type: web
name: inventory-lock-api
runtime: docker
plan: free
autoDeployTrigger: checksPass
healthCheckPath: /actuator/health
envVars:
- key: DB_URL
sync: false
- key: DB_USERNAME
sync: false
- key: DB_PASSWORD
sync: false
What Does This Blueprint Define?
- The Blueprint creates a Docker web service named inventory-lock-api on the free plan.
- The checksPass trigger gates deployments on successful GitHub checks.
- The /actuator/health path gives Render a health signal.
- Each sync: false entry tells Render to request a database value during Blueprint creation.
- Save render.yaml.
- Confirm render.yaml appears beside Dockerfile.
Blueprint file not detected?
Confirm the filename is exactly render.yaml. Make sure it sits directly inside the inventory-api folder.
Ask for help: Help me compare my Render Blueprint with the expected service definition.
✔️ Awesome, I've got everything!
Great. Double check that all three deployment files are saved inside inventory-api.
ⓧ I'd like to double check the full code
Compare each deployment file with the complete version below.
FROM maven:3.10.0-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B -DskipTests clean package
FROM eclipse-temurin:21.0.12.1_1-jre-alpine-3.24
WORKDIR /app
COPY --from=build /workspace/target/inventory-api-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
.git
.github
.idea
target
*.iml
services:
- type: web
name: inventory-lock-api
runtime: docker
plan: free
autoDeployTrigger: checksPass
healthCheckPath: /actuator/health
envVars:
- key: DB_URL
sync: false
- key: DB_USERNAME
sync: false
- key: DB_PASSWORD
sync: false
- Use the Git workflow from the previous step to commit the three deployment files.
- Push the new commit to the existing public GitHub repository.
- Return to the repository's Actions tab.
- Confirm the newest verify job completes successfully.
Your deployment files are now protected by the existing MySQL-backed check. Render can use that check as its deployment gate.
Create the managed MySQL service
The cloud container needs a database that remains available outside your laptop. Aiven provides the managed MySQL endpoint and the SSL connection details.
Creating a cloud database can feel risky because many services begin charging after a trial. The Aiven Free tier requires no credit card and includes 1 GB of storage.
- Return to the Aiven Console from earlier.
- Select Services.
- Select Create service.
- Select MySQL.
- Select Free under Service tier.
- Create the service using the selected free tier.
Provisioning can take a few minutes while Aiven prepares the database. Keep the service page available during this wait.
- Wait until the service is ready for connections.
Good progress. Your managed database is active without changing any application code.
Service not becoming ready?
- Confirm the selected service tier is Free.
- Refresh the service page after the provisioning status has had time to update.
Ask for help: Help me troubleshoot an Aiven for MySQL Free service that is not becoming ready.
The connection panel contains a live database password. Keep that password out of screenshots and project files.
- Open Quick connect from the service's Overview page.
- Select the Java connection option.
- Construct the JDBC URL from the displayed details using jdbc:mysql://HOST:PORT/DATABASE?sslmode=require.
- Record the completed URL here: your Aiven JDBC URL.
- Confirm the displayed username is avnadmin.
- Store the displayed password in your password manager.
Why Use External Configuration?
Render supplies DB_URL, DB_USERNAME, and DB_PASSWORD when the container starts. Your existing application.properties file already reads those environment variables.
The managed database password stays outside the Git repository. The same image can connect to local or cloud MySQL through configuration.
Deploy the Render Blueprint
Render reads render.yaml from your GitHub repository. The Blueprint creates the free web service and uses the existing workflow result as its deployment gate.
- Return to the Render Dashboard from earlier.
- Create a Blueprint from the existing GitHub repository.
- Authorize access to the public repository if Render requests it.
- Select the inventory-api repository.
- Confirm the discovered service name is inventory-lock-api.
- Confirm the discovered plan is free.
What Did Render Discover?
Render found a Docker web service and the /actuator/health health path. It also found three environment variables marked with sync: false.
Those unsynchronized values are requested during creation. Their contents do not enter the repository.
You are about to paste a live database password into Render. The value stays in the service configuration because the Blueprint does not synchronize it to Git.
- Paste your Aiven JDBC URL into the DB_URL prompt.
- Enter avnadmin into the DB_USERNAME prompt.
- Paste the Aiven password from your password manager into the DB_PASSWORD prompt.
- Start the Blueprint deployment.
The first deployment can take several minutes while Render builds both image stages. Keep the deployment page available until the health check completes.
- Wait until the inventory-lock-api deployment finishes.
- Record the published address here: your Render service URL.
Deployment not finishing?
- Confirm the latest GitHub Actions verify job is successful.
- Confirm DB_URL contains the Aiven host and port.
- Confirm the URL ends with ?sslmode=require.
Ask for help: Help me diagnose why my Render Blueprint deployment cannot start the inventory API.
Before you check the public endpoints, do you expect the cloud service to expose the same health and seeded inventory responses as the local container?
- Open your Render service URL with /actuator/health appended in your browser.
You should see an UP health response. This proves that Render started the container with a working Aiven connection.
- Open your Render service URL with /api/inventory/LAST-SEAT appended in your browser.
You should see cloud-backed JSON with sku set to LAST-SEAT. You should also see availableUnits set to 1.
Cloud endpoint not healthy?
- Confirm the health URL ends with /actuator/health.
- Confirm the JDBC URL ends with ?sslmode=require.
- Confirm the Aiven service remains ready for connections.
Ask for help: Help me troubleshoot a Render health check for my Aiven-backed API.
That completes the delivery chain. Your concurrency-safe inventory API now runs from a public container with managed MySQL and CI-gated deployments.
Secret mission
Generalize the Inventory Invariant
The final-seat test proves one boundary case. Generalize the test harness to prove that successful reservations always equal the starting stock, even when twenty callers compete for five units.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your resources available, pause them for later, or remove them completely. The selected cloud resources stay at $0 within their documented free limits.
Cost warning
Your Render Free web service receives 750 free instance hours per workspace each month. No payment method is attached to your Render account.
Render suspends free services if the bandwidth allowance runs out. Render disables new builds if the pipeline allowance runs out.
Your Aiven for MySQL Free service requires no credit card. Aiven can power off an unused free service after notification.
Resources you used:
- Local inventory-api project directory on your Desktop.
- Local Docker Compose application containing the mysql service plus the app service.
- Local MySQL named volume called inventory-mysql-data.
- Local Docker Desktop image built for the app service.
- Public GitHub repository containing the inventory-api project.
- Passing MySQL-backed GitHub Actions workflow in .github/workflows/ci.yml.
- Aiven for MySQL Free service used by the deployed API.
- Render Free web service named inventory-lock-api.
Keep everything running
Keeping everything requires no cleanup. Choose this while you still want the public API plus its concurrency tests available.
- Leave the inventory-lock-api Render service active.
- Leave the Aiven database active.
- Keep the public GitHub repository available.
- Keep the inventory-api directory on your Desktop.
- Keep the local Docker resources for future container tests.
Render spins down a free web service after 15 minutes without inbound traffic. Expect the first request after that idle period to take about one minute.
Aiven may power off an unused free database after notifying you. You can power it back on when you return.
Pause - I'll come back to this later
Pausing stops the running services while preserving your code plus configuration. Your database remains available for a later restart.
Pause the Cloud Services
- Return to the inventory-lock-api service in Render.
- Choose Suspend.
- Return to Services in Aiven.
- Select your existing MySQL service.
- Open Actions.
- Choose Power off service.
Pause the Local Containers
- Start Docker Desktop through Windows search.
- Locate the Compose application containing the mysql service.
- Use the application stop control.
- Confirm both local containers show a stopped state.
Your GitHub repository stays available. Your inventory-api directory remains on your Desktop.
- Restart the Render web service later with Resume.
- Restart the Aiven database later with Power on service.
Delete - I don't want to use this again
Deleting these resources is permanent. The sequence below removes each dependency cleanly.
Delete the Cloud Services
- Return to the inventory-lock-api service page in Render.
- Open the settings area for that web service.
- Use the deletion control at the bottom of the page.
- Confirm the web service no longer appears in your Render service list.
- Return to the managed MySQL service in Aiven.
- Open Actions.
- Use the service deletion control in that menu.
- Confirm the database no longer appears in Services.
Delete the GitHub Repository
- Return to the public inventory-api repository in GitHub.
- Open the repository settings page.
- Scroll to the permanent deletion area at the bottom of the page.
- Use the repository deletion control.
- Confirm the repository URL no longer opens the project.
Delete the Local Docker Containers
- Start Docker Desktop through Windows search.
- Locate the Compose application containing the mysql service.
- Use the application deletion control.
- Confirm the mysql container no longer appears.
- Confirm the app container no longer appears.
Delete the Local Docker Storage
- Open the local volume list in Docker Desktop.
- Select inventory-mysql-data.
- Use the volume deletion control.
- Open the local image list in Docker Desktop.
- Locate the image attached to the app service.
- Use the image deletion control.
- Confirm the volume no longer appears in the local volume list.
- Confirm the application image no longer appears in the local image list.
Delete the Local Project Folder
- Use File Explorer to open your Desktop.
- Select the inventory-api folder.
- Press Shift+Delete.
- Confirm the permanent deletion prompt.
- Refresh your Desktop.
You should no longer see the inventory-api folder. The local source files plus configuration are now removed.
Having trouble removing a resource?
- Remove the Compose application before retrying a busy inventory-mysql-data volume.
- Confirm the signed-in cloud account owns the resource when a deletion control is missing.
- Confirm your GitHub account has repository administration permission when repository deletion is unavailable.
Help me finish the cleanup.
Nice Work!
Nice Work!
Excellent work! You built a validated Spring Boot travel inventory API that protects scarce stock in MySQL during concurrent reservations. Your automated tests now prove that inventory reaches zero without becoming negative.
You learned how to:
- Build a validated REST API that creates inventory items. The API returns current stock as JSON. It rejects reservations after an item sells out.
- Reproduce a lost-update race with synchronized concurrent callers. You protected the reservation invariant with a transaction-scoped pessimistic row lock.
- Package the service with a multi-stage container build. You exposed health information through Spring Boot Actuator. You gated the public Render deployment with a MySQL-backed GitHub Actions workflow.
- Secret Mission: Generalized the concurrency proof to five units contested by twenty callers. The test confirms five successful reservations. It also confirms fifteen sold-out results.
Ready to quiz yourself?