Files
clean-architecture-backend-…/docs/jpa/query-fetch-guide.md
T
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00

3.7 KiB

Query and Fetch Guide

Design §23-§28. How queries are chosen, bounded, and proven.

Named queries

Every registered query carries a QueryName. It becomes the metric tag, the trace attribute, and the SQL comment that appears in pg_stat_activity and the slow-query log — which is the only thing that connects a statement on the server back to the use case that issued it. The format rejects raw SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built from a parameterised value leaks row data into telemetry.

Fetch plans, not eager mappings

N+1 is solved per use case with a registered entity graph, not by making an association EAGER in the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every other query against that entity, including the ones that only wanted the id.

fetchgraph and loadgraph are different: a fetch graph is exhaustive (attributes outside it are lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either missing data or the amplification the graph was meant to avoid.

Measuring, not guessing

QueryMeasurement records statements, hydrated entities, rows, fetches, and elapsed time. Statement count alone cannot distinguish the two failures that matter:

  • N+1 — many statements, few rows.
  • Cartesian fetch — one statement, an enormous number of rows.

A suite asserting only on statement count passes the second one every time.

Pagination

Offset pagination makes the database walk and discard n rows before returning any. Keyset pagination replaces it:

  • The predicate is lexicographic. For an ordering of (createdAt, id), "after (t, x)" is createdAt < t OR (createdAt = t AND id < x)not createdAt <= t AND id < x, which reads plausibly and silently drops rows from the middle of the result set.
  • The ordering must end in a unique column. Without one, a page boundary inside a run of equal values duplicates and skips rows.
  • size + 1 rows are fetched and size returned. That extra row answers hasNext without a count query, which would be a second full scan whose answer is stale on arrival.

Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a caller seek to arbitrary keys.

Sorting

Client sort parameters are mapped through SafeSortRegistry, never passed through. A sort field reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the client's string means the client writes part of the statement. JpaSort.unsafe has no call site in this platform.

The registry's tie-breaker is always appended, because a sort that does not end in a unique column has no total order and paging over a non-total order duplicates and skips rows.

Streaming

A JPA Stream is a live cursor holding a ResultSet, a statement, and a connection. JpaStreamExecutor consumes it inside a try-with-resources and never returns it, because a stream returned past the transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only transaction is required: streaming inside a write transaction pins a write connection for the whole traversal.

Batching

Configuring hibernate.jdbc.batch_size proves nothing. BatchExecutionResult.jdbcBatches comes from counting real executeBatch() calls at the JDBC layer, because an IDENTITY generator, an interleaved select, or a mid-loop flush disables batching while the configuration still says it is on.

Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity in the Persistence Context — the classic bulk-import out-of-memory.