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

75 min

Lesson 17 of 17

1d streak

The 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.

  1. 01Flyway V1 migration creates tables and indexes.
  2. 02ProductEntity models identity, optimistic locking (@Version), and value objects.
  3. 03ProductRepository provides custom derived queries and DTO projections.
  4. 04ProductService manages @Transactional boundaries with readOnly = true optimizations.
  5. 05DataJpaTest slice verifies queries and transactions against a real PostgreSQL container.

Complete persistence slice implementation

ProductEntity.java
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; }
}
ProductRepository.java
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

ProductRepositoryTest.java
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.

CatalogApplication.java
@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.
Every snippet in this module lands in one Product Catalog service, so the pieces get assembled instead of demonstrated separately.
Build in dependency order (migration, entity, repository, service, controller), then read it back at runtime from HTTP request to committed SQL.
@EnableJpaAuditing sits on the application class so the auditing listener is active for every entity in the slice.

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.

Java 21Spring Boot 3.5.xSpring Data JPAPostgreSQLFlywayTestcontainers

Runtime path

  1. 01HTTP request
  2. 02ProductController
  3. 03ProductService transaction
  4. 04ProductRepository proxy
  5. 05EntityManager / Hibernate
  6. 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.java

Implementation sequence

Build in dependency order

Each file answers three questions: what to write, why this boundary exists, and how to prove it works.

01

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.

pom.xml
<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

02

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.

src/main/resources/application.yml
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: graceful

Why 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)

03

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.

src/main/resources/db/migration/V1__create_products.sql
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

04

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.

src/main/java/com/acme/catalog/product/Product.java
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.

05

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.

src/main/java/com/acme/catalog/product/ProductRepository.java
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.

06

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.

src/main/java/com/acme/catalog/product/ProductSpecifications.java
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.

07

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.

src/main/java/com/acme/catalog/product/ProductService.java
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.

08

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.

src/main/java/com/acme/catalog/product/ProductController.java
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.

09

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.

src/test/java/com/acme/catalog/product/ProductRepositoryTest.java
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

create.sh
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

search.sh
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

adjust-stock.sh
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

  1. Break: Skip the service boundary and return Product entities directly from a repository-backed controller
  2. Observe: Persistence concerns leak into HTTP, transactions become accidental, and query performance is hard to control
  3. 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

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.

Take Module Quiz

Primary Documentation & Specifications