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