Course outline

Spring Boot / Intermediate: data, security, and framework evidence / Lesson 7

JPA repositories and persistence context

Model a persistence boundary with explicit entity identity, database invariants, scoped queries, pagination, and controlled DTO exposure.

About 5 min readContent reviewed
On this page

Lesson goals

  • Distinguish entity state from a public response model.
  • Explain save, flush, commit, and version checks.
  • Keep repository access scoped and bounded.

A repository is a capability over a real storage contract

Spring Data JPA provides repository implementation machinery around JPA, while Boot can configure infrastructure from managed dependencies and application settings. Neither creates a correct domain schema by inference. Choose identifiers, required fields, uniqueness, foreign keys, and migration ownership deliberately. A production data model should not depend on silently recreating tables whenever the application starts.

An entity is managed persistence state, not automatically a stable HTTP DTO. JPA identity, equality, lifecycle, and lazy relationships have different concerns from response field selection. Database constraints protect concurrent writers beyond a controller's validation. Keep the actual database dialect and provider behavior part of integration testing; no configured database or automatic schema mutation is used in this course.

Make identity, scope, and optimistic version explicit

The entity fragment uses Jakarta persistence annotations for the Boot3.5 baseline. A protected no-argument constructor supports JPA construction, a generated Long identity is provider-backed, and @Version supports optimistic update checking. The tenant/code uniqueness declaration describes an intended database invariant; reviewed migrations must establish and verify the actual schema rather than assuming an annotation alone changed production.

A version conflict is not a generic instruction to repeat an unconditional write. Reload or reconcile according to the operation contract, preserving another writer's work. Keep the trusted tenant boundary in queries and mutations. A field named tenantId does not authorize a caller, and an entity returned by findById can still need ownership verification before it becomes accessible.

java Illustrative entity identity and tenant/code invariant
package training.catalog;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;

@Entity
@Table(name="catalog_product", uniqueConstraints=@UniqueConstraint(
    name="uk_product_tenant_code", columnNames={"tenant_id","code"}))
class Product {
    @Id @GeneratedValue private Long id;
    @Version private Long version;
    @Column(name="tenant_id", nullable=false) private String tenantId;
    @Column(nullable=false) private String code;
    @Column(nullable=false) private int unitPriceCents;
    protected Product() {}
}

Generation strategy, schema migration, setters/domain methods, and provider execution are contextual. This fragment does not create a database.

Select a bounded representation under trusted scope

The repository fragment requests a page of a closed projection rather than serializing a complete entity graph. The tenant argument must come from trusted application scope, and the caller must cap page size and choose stable ordering such as unique id. A method name can derive a query; its actual generated SQL, index suitability, and database behavior still need integration evidence.

Page supports total information and can require additional count work. Slice or keyset-style workflows can be appropriate when the product needs only continuation, but each has a consistency and ordering contract. Do not expose an unbounded findAll result merely because a small training table fits memory. Changing records between requests can shift pages unless the extraction design controls it.

java Illustrative scoped page projection
package training.catalog;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

interface ProductSummary {
    Long getId();
    String getCode();
    int getUnitPriceCents();
}

interface ProductRepository extends JpaRepository<Product, Long> {
    Page<ProductSummary> findByTenantId(String tenantId, Pageable pageable);
}

The application must supply trusted tenant scope and a bounded, explicitly sorted Pageable. No repository SQL was executed.

Separate managed changes, flush, and durable commit

A persistence context tracks managed entities and can flush changes through dirty checking. Repository save chooses persistence/merge behavior according to state detection; it is not a universal immediate insert or standalone durability guarantee. Flush synchronizes pending work with the database within transaction semantics, while commit completes the transaction subject to its outcome. Failures can emerge at flush or commit rather than the source line that changed a field.

Lazy data accessed outside the intended context can fail or trigger unexpected work depending on configuration. Do not fix every lazy failure by making all relationships eager or allowing hidden query traffic during response serialization. Define the data needed by a use case, fetch deliberately, and map an approved result within a clear read boundary.

Test the database boundary separately from a fake

Verify mappings, migrations, constraints, tenant filtering, paging order, and optimistic conflicts against the intended database when an isolated integration environment is authorized. A mocked repository cannot prove generated SQL or unique-key behavior. An in-memory database with another dialect can be useful but does not automatically reproduce the deployment database's locking, types, or query plans.

Keep test fixtures synthetic and never point @DataJpaTest at a configured project datasource by accident. The framework examples remain non-runnable here and no persistence service is provisioned. A sound repository boundary identifies what is stored, how callers are scoped, when state is managed, and which evidence validates real persistence rather than only method delegation.

Common errors and fixes

LazyInitializationException

Why it happens
Required lazy state was accessed beyond the intended persistence context.
How to fix it
Fetch/map the use-case data deliberately and review the read boundary.
Prevent it next time
Test response mapping and query count.

OptimisticLockException

Why it happens
A versioned update conflicts with another committed writer.
How to fix it
Reconcile or reject under the business conflict policy.
Prevent it next time
Test simultaneous edits with real version semantics.

A mocked repository test was called SQL coverage

Why it happens
No provider or database query was exercised.
How to fix it
Add controlled dialect-specific integration evidence when authorized.
Prevent it next time
Label mocks, slices, and database tests separately.

Check your understanding

1. Does save(entity) prove an order is durably committed?

Show worked solution

No. It participates in persistence/state handling and may defer SQL or fail later at flush/commit. The enclosing transaction owner must observe completion, and the caller must distinguish a confirmed outcome from lost confirmation.

2. Why can a Page projection be safer than returning every entity, yet still need review?

Show worked solution

It narrows exposed fields and bounds a requested page, but still needs trusted tenant scope, a capped size, stable order, query/count cost checks, and dialect-specific validation. Projection is not authorization or a free performance guarantee.

Key takeaways

  • Entity lifecycle and public representation have different contracts.
  • save, flush, and commit are distinct events.
  • Repository tests need evidence for the actual storage boundary.

Official references