Foundation · Build
Complete JPA Implementation: Product Catalog API
Connect every JPA concept in a production-shaped, multi-file implementation and understand why each boundary exists.
Primary source: Spring Boot 3.5: Production JPA & HikariCP Configuration
Lesson 17 of 17
1d streakThe Architectural Question
Isolated annotation snippets do not teach how transaction boundaries, query choices, DTO mapping, schema migrations, and HTTP contracts work together in a real application.
What this guide proves
- Vertical Slice
- Design Rationale
- Integration Verification
Production Spring Data JPA persistence slice architecture
This is the JPA integration capstone. Apply the earlier lessons on migrations, entity identity, persistence context, repository queries, locking, transactions, and projections to one auditable catalog slice. The sections below choose a design and verify it; they do not redefine those mechanisms.
- 01Flyway V1 migration creates tables and indexes.
- 02ProductEntity models identity, optimistic locking (@Version), and value objects.
- 03ProductRepository provides custom derived queries and DTO projections.
- 04ProductService manages @Transactional boundaries with readOnly = true optimizations.
- 05DataJpaTest slice verifies queries and transactions against a real PostgreSQL container.
Complete persistence slice implementation
package com.example.catalog.domain;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "products")
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private double price;
@Version
private Long version; // Optimistic locking guard
protected ProductEntity() {}
public ProductEntity(String name, double price) {
this.name = name;
this.price = price;
}
public void updatePrice(double newPrice) {
if (newPrice <= 0) throw new IllegalArgumentException("Price must be positive");
this.price = newPrice;
}
public UUID getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
}package com.example.catalog.domain;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.*;
import java.util.UUID;
public interface ProductRepository extends JpaRepository<ProductEntity, UUID> {
@Query("SELECT new com.example.catalog.domain.ProductSummary(p.id, p.name, p.price) FROM ProductEntity p")
Slice<ProductSummary> findAllSummaries(Pageable pageable);
}
record ProductSummary(UUID id, String name, double price) {}Verifying persistence with @DataJpaTest and Testcontainers
package com.example.catalog.domain;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.data.domain.PageRequest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {
@Autowired private ProductRepository repository;
@Test
void persistsProductAndReturnsSummarySlice() {
var product = new ProductEntity("Mechanical Keyboard", 129.99);
repository.saveAndFlush(product);
var slice = repository.findAllSummaries(PageRequest.of(0, 10));
assertThat(slice.getContent()).hasSize(1);
assertThat(slice.getContent().get(0).name()).isEqualTo("Mechanical Keyboard");
}
}Active Recall Checkpoint
Why should you test JPA repositories against a real PostgreSQL container instead of in-memory H2?
Hint: Consider dialect differences and lock mechanisms.
Show answer & explanation
H2 has different SQL syntax, locking behavior, type mappings, and index support than PostgreSQL. Testing against real database containers via Testcontainers ensures migrations and queries behave identically in production.
Production persistence readiness checklist
Use it when
Ship JPA code with Flyway migrations, @Version optimistic locking, spring.jpa.open-in-view=false, and Testcontainers verification in CI.
Reconsider when
Never rely on ddl-auto=update or unpaged findAll() in production.
Working Implementation
Code Walkthrough
Build the complete Product Catalog API from dependency setup and Flyway migration through repository, service, controller, exception handling, and Testcontainers tests.
@SpringBootApplication
@EnableJpaAuditing
public class CatalogApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogApplication.class, args);
}
}
// Continue through the implementation guide below:
// migration -> entity -> repository -> service -> HTTP -> PostgreSQL test.End-to-end implementation
Build a production-shaped Product Catalog API
One vertical slice that connects PostgreSQL, Flyway, a JPA entity, repository queries, a transactional service, DTOs, HTTP endpoints, and an integration test.
Runtime path
- 01HTTP request
- 02ProductController
- 03ProductService transaction
- 04ProductRepository proxy
- 05EntityManager / Hibernate
- 06PostgreSQL
Project structure
src
├── main
│ ├── java/com/acme/catalog
│ │ ├── CatalogApplication.java
│ │ └── product
│ │ ├── Product.java
│ │ ├── ProductRepository.java
│ │ ├── ProductSpecifications.java
│ │ ├── ProductService.java
│ │ ├── ProductController.java
│ │ ├── ProductRequests.java
│ │ └── ProductNotFound.java
│ └── resources
│ ├── application.yml
│ └── db/migration/V1__create_products.sql
└── test/java/com/acme/catalog/product
└── ProductRepositoryTest.javaImplementation sequence
Build in dependency order
Each file answers three questions: what to write, why this boundary exists, and how to prove it works.
Add only the persistence dependencies
pom.xml
Bring in the repository abstraction, Hibernate, connection pooling, PostgreSQL, migrations, validation, and a real database for integration tests.
<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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Why these choices
- Use the Spring Boot dependency-management versions; do not independently version compatible Spring libraries.
- Keep the PostgreSQL driver at runtime scope because application code programs against JDBC/JPA APIs.
- Use PostgreSQL in integration tests when SQL behavior, constraints, or migrations matter; H2 is not PostgreSQL.
Verify before moving on
./mvnw dependency:tree
Make migrations authoritative
src/main/resources/application.yml
Configure the database from the environment and make Hibernate validate—not mutate—the schema managed by Flyway.
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/catalog}
username: ${DB_USER:catalog}
password: ${DB_PASSWORD:catalog}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc.batch_size: 25
order_inserts: true
order_updates: true
flyway:
enabled: true
server:
shutdown: gracefulWhy these choices
- `ddl-auto: validate` fails startup when mappings and schema disagree without silently changing production data.
- `open-in-view: false` forces queries and DTO mapping into an intentional service transaction.
- Environment variables externalize credentials; production secrets should come from the deployment platform.
Verify before moving on
./mvnw spring-boot:run (startup should fail until PostgreSQL and V1 exist)
Protect invariants in PostgreSQL
src/main/resources/db/migration/V1__create_products.sql
Create a reviewable, repeatable schema whose constraints protect data even when another client bypasses this service.
create table products (
id uuid primary key,
sku varchar(64) not null,
name varchar(160) not null,
description varchar(2000),
price numeric(12, 2) not null,
stock integer not null,
active boolean not null default true,
version bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null,
constraint uk_products_sku unique (sku),
constraint ck_products_price_non_negative check (price >= 0),
constraint ck_products_stock_non_negative check (stock >= 0)
);
create index idx_products_active_name
on products (active, lower(name));Why these choices
- A unique constraint is the final defense against concurrent duplicate-SKU requests.
- Check constraints preserve facts for every database writer, not only requests passing Java validation.
- Add indexes for measured query patterns; each index also increases storage and write cost.
Verify before moving on
./mvnw flyway:info or inspect flyway_schema_history after application startup
Model behavior, identity, and concurrency
src/main/java/com/acme/catalog/product/Product.java
Represent one managed aggregate and keep stock/price rules inside methods instead of scattering setters across services.
package com.acme.catalog.product;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
import org.springframework.data.annotation.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "products")
@EntityListeners(AuditingEntityListener.class)
public class Product {
@Id private UUID id;
@Column(nullable = false, length = 64, unique = true) private String sku;
@Column(nullable = false, length = 160) private String name;
@Column(length = 2000) private String description;
@Column(nullable = false, precision = 12, scale = 2) private BigDecimal price;
@Column(nullable = false) private int stock;
@Column(nullable = false) private boolean active = true;
@Version private Long version;
@CreatedDate @Column(nullable = false, updatable = false) private Instant createdAt;
@LastModifiedDate @Column(nullable = false) private Instant updatedAt;
protected Product() {}
Product(String sku, String name, String description, BigDecimal price, int stock) {
this.id = UUID.randomUUID();
this.sku = sku;
changeDetails(name, description, price);
adjustStock(stock);
}
void changeDetails(String name, String description, BigDecimal price) {
if (price.signum() < 0) throw new IllegalArgumentException("price must be non-negative");
this.name = name;
this.description = description;
this.price = price;
}
void adjustStock(int delta) {
if (stock + delta < 0) throw new IllegalArgumentException("insufficient stock");
stock += delta;
}
void archive() { active = false; }
UUID id() { return id; }
String sku() { return sku; }
String name() { return name; }
BigDecimal price() { return price; }
int stock() { return stock; }
boolean active() { return active; }
}Why these choices
- The protected no-argument constructor exists for JPA; use the intention-revealing constructor in application code.
- `@Version` converts a lost update into an optimistic-locking failure instead of silently overwriting data.
- The entity protects local state transitions; request validation still belongs at the HTTP boundary.
Verify before moving on
Start the context: Hibernate validate should accept the Product-to-products mapping.
Choose the simplest query mechanism per use case
src/main/java/com/acme/catalog/product/ProductRepository.java
Use inherited CRUD for identity access, a derived query for a short predicate, a DTO projection for a list, and Specifications for optional filters.
package com.acme.catalog.product;
import java.math.BigDecimal;
import java.util.*;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends
JpaRepository<Product, UUID>, JpaSpecificationExecutor<Product> {
boolean existsBySkuIgnoreCase(String sku);
@Query("""
select new com.acme.catalog.product.ProductSummary(
p.id, p.sku, p.name, p.price, p.stock)
from Product p
where p.active = true
and (:term is null or lower(p.name) like lower(concat('%', :term, '%')))
""")
Page<ProductSummary> findCatalog(@Param("term") String term, Pageable pageable);
}
record ProductSummary(UUID id, String sku, String name,
BigDecimal price, int stock) {}Why these choices
- Do not redeclare `save`, `findById`, or `delete`; `JpaRepository` already supplies their implementation.
- Use a derived query while its name remains short and unambiguous; use `@Query` when the selected shape or joins matter.
- The projection avoids entity tracking and prevents HTTP serialization from walking lazy relationships.
Verify before moving on
Write a @DataJpaTest proving SKU lookup, projection fields, pagination, and the generated count.
Compose optional filters instead of method-name explosions
src/main/java/com/acme/catalog/product/ProductSpecifications.java
Build dynamic admin searches without creating a repository method for every combination of active state and price range.
package com.acme.catalog.product;
import java.math.BigDecimal;
import org.springframework.data.jpa.domain.Specification;
final class ProductSpecifications {
private ProductSpecifications() {}
static Specification<Product> searchable(
Boolean active, BigDecimal minPrice, BigDecimal maxPrice) {
return Specification.allOf(
active == null ? null : (root, query, cb) ->
cb.equal(root.get("active"), active),
minPrice == null ? null : (root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("price"), minPrice),
maxPrice == null ? null : (root, query, cb) ->
cb.lessThanOrEqualTo(root.get("price"), maxPrice)
);
}
}Why these choices
- Specifications are appropriate for combinatorial filters; they are excessive for one stable lookup.
- Map externally supplied sort names to an allowlist before constructing Pageable.
- For complex reporting or database-specific search, prefer purpose-built SQL/query tooling over forcing everything through Specifications.
Verify before moving on
Test no filters, each individual filter, and combined min/max/active predicates.
Put transactions around complete use cases
src/main/java/com/acme/catalog/product/ProductService.java
Keep controllers free of persistence rules and guarantee that each write either commits completely or rolls back.
package com.acme.catalog.product;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
private final ProductRepository products;
ProductService(ProductRepository products) { this.products = products; }
@Transactional
public ProductView create(CreateProduct request) {
if (products.existsBySkuIgnoreCase(request.sku())) {
throw new DuplicateSku(request.sku());
}
try {
return ProductView.from(products.saveAndFlush(new Product(
request.sku(), request.name(), request.description(),
request.price(), request.stock())));
} catch (DataIntegrityViolationException race) {
throw new DuplicateSku(request.sku());
}
}
@Transactional(readOnly = true)
public Page<ProductSummary> catalog(String term, Pageable pageable) {
return products.findCatalog(normalize(term), pageable);
}
@Transactional
public ProductView adjustStock(UUID id, int delta) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFound(id));
product.adjustStock(delta); // Dirty checking writes on flush.
return ProductView.from(product);
}
private String normalize(String term) {
return term == null || term.isBlank() ? null : term.strip();
}
}Why these choices
- The uniqueness pre-check improves the error, but the database constraint resolves concurrent races.
- Dirty checking makes another `save` unnecessary for a managed Product changed inside this transaction.
- `readOnly = true` communicates intent and may enable provider optimizations; it is not a security control.
Verify before moving on
Force an exception after mutation and assert that neither the stock nor any companion write commits.
Keep entities out of the HTTP contract
src/main/java/com/acme/catalog/product/ProductController.java
Validate transport input, constrain pagination, and expose stable response records without leaking persistence internals.
package com.acme.catalog.product;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.net.URI;
import java.util.UUID;
import org.springframework.data.domain.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
class ProductController {
private final ProductService products;
ProductController(ProductService products) { this.products = products; }
@PostMapping
ResponseEntity<ProductView> create(@Valid @RequestBody CreateProduct request) {
ProductView created = products.create(request);
return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
}
@GetMapping
Page<ProductSummary> catalog(
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "0") @PositiveOrZero int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return products.catalog(q, PageRequest.of(page, size, Sort.by("name")));
}
@PatchMapping("/{id}/stock")
ProductView adjustStock(@PathVariable UUID id, @RequestBody @Valid StockChange body) {
return products.adjustStock(id, body.delta());
}
}
record CreateProduct(@NotBlank String sku, @NotBlank String name,
@Size(max = 2000) String description, @PositiveOrZero BigDecimal price,
@PositiveOrZero int stock) {}
record StockChange(@NotNull Integer delta) {}
record ProductView(UUID id, String sku, String name, BigDecimal price, int stock) {
static ProductView from(Product p) {
return new ProductView(p.id(), p.sku(), p.name(), p.price(), p.stock());
}
}Why these choices
- The controller owns HTTP details; the service remains reusable from messaging or batch adapters.
- The maximum page size prevents one request from accidentally materializing an unbounded result.
- Separate request/response types let the API evolve without changing managed entity mappings.
Verify before moving on
POST returns 201 + Location; GET is paged; invalid price/size returns 400; missing id returns 404.
Prove behavior against the real database
src/test/java/com/acme/catalog/product/ProductRepositoryTest.java
Run migrations and repository code against PostgreSQL so constraints, decimal types, JPQL, and pagination behave like production.
package com.acme.catalog.product;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.domain.PageRequest;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine");
@Autowired ProductRepository products;
@Test
void returns_only_the_requested_catalog_projection() {
products.save(new Product("KB-100", "Keyboard", null,
new BigDecimal("89.90"), 12));
var page = products.findCatalog("key", PageRequest.of(0, 20));
assertThat(page.getTotalElements()).isEqualTo(1);
assertThat(page.getContent()).singleElement().satisfies(item -> {
assertThat(item.sku()).isEqualTo("KB-100");
assertThat(item.name()).isEqualTo("Keyboard");
});
}
}Why these choices
- A repository slice test is smaller than `@SpringBootTest` while still exercising JPA mappings and SQL.
- `@ServiceConnection` supplies the container connection without hand-written dynamic properties.
- Add service integration tests for rollback/concurrency and MVC tests for HTTP; each test should target one risk.
Verify before moving on
./mvnw test
Exercise the API
Create
curl -i -X POST http://localhost:8080/api/products \
-H 'Content-Type: application/json' \
-d '{"sku":"KB-100","name":"Keyboard","price":89.90,"stock":12}'Expected: 201 Created with Location: /api/products/{id}
Search
curl 'http://localhost:8080/api/products?q=key&page=0&size=20'Expected: A Page<ProductSummary> containing KB-100 without entity-only fields
Adjust stock
curl -X PATCH http://localhost:8080/api/products/{id}/stock \
-H 'Content-Type: application/json' -d '{"delta":-2}'Expected: 200 OK with stock 10; a delta below zero inventory is rejected
Production review
- Flyway owns every schema change; production never uses ddl-auto=create or update.
- Database constraints and indexes match real invariants and measured query patterns.
- Transactions live on public service use cases and do not wrap remote network calls.
- Entities stay inside persistence/application boundaries; API responses use DTOs or projections.
- Every collection endpoint has bounded pagination and an allowlisted sort.
- Query logs or metrics verify N+1-sensitive paths before release.
- Optimistic conflicts, duplicate keys, validation failures, and missing rows map to intentional API errors.
- PostgreSQL integration tests cover migrations, custom queries, rollback, and concurrency-sensitive behavior.
Synthesis & Production Verification
Deliberate Failure Condition
- Break: Skip the service boundary and return Product entities directly from a repository-backed controller
- Observe: Persistence concerns leak into HTTP, transactions become accidental, and query performance is hard to control
- Repair: Use explicit boundaries: controller owns HTTP, service owns the use-case transaction, repository owns persistence access, and DTOs own the public shape.
Production Acceptance Criteria
- Run the API against PostgreSQL with Flyway migrations
- Explain every repository and transaction choice
- Prove CRUD, search, rollback, and query behavior with tests
Key Takeaway
A production persistence slice unites Flyway schema versioning, entity invariants, optimistic locking, and Testcontainers verification in CI.
Architectural & Technical Review
Core Interview Questions
Q01What components make up a production-ready persistence slice?Show answer
Flyway migrations, @Version optimistic locking, constructor DTO projections, and readOnly = true transaction optimizations.
Q02Why should repository integration tests run against Testcontainers rather than H2?Show answer
To verify migrations, SQL dialect compatibility, and locking behavior against a genuine production database engine.
Q03What operational settings should be configured for JPA production readiness?Show answer
spring.jpa.open-in-view=false, ddl-auto=validate, batch-size tuning, and slow-query logging thresholds.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Skipping database integration tests in CI — Relying solely on unit tests leaves SQL syntax errors, migration failures, and transaction rollbacks undetected.
- Pitfall: Leaving SQL logging enabled at DEBUG level in production — Logging every SQL statement to stdout in production degrades application throughput and generates gigabytes of disk I/O.
Module Certification Quiz
Prove Module Mastery
Comprehensive question set covering all lessons in this module.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources