Files
clean-architecture-backend-…/docs/jpa/repository-adaptation.md
T

15 KiB

JPA Relational Persistence Platform — Repository Adaptation Contract

Design source: jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md (copied to docs/superpowers/specs/) Stable plan source: jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md (copied to docs/superpowers/plans/) Experimental plan source: jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md (copied to docs/superpowers/plans/)

The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle structure are explicit implementation assumptions made because the real Backend Skeleton repository was not supplied. Before implementing, paths are adjusted to the repository's existing conventions and root package while the public contracts and policy semantics are preserved.

This file is the single record of how that mapping was performed. Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, retry semantics, and error semantics are implemented as specified.

1. Why the module layout differs

The plan assumes a greenfield library with 18 Stable Gradle projects under modules/jpa/ plus 7 Experimental projects under modules/jpa-experimental/. This repository is a Clean Architecture template whose fail-closed registry (src/config/architecture/modules.json, enforced by src/settings.gradle and verifyCleanArchitectureDependencies) declares exactly 19 leaf identities, and src/settings.gradle throws when the registry does not contain exactly 19 modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in AGENTS.md.

Therefore the plan's library modules become package boundaries inside the registered leaf :adapter:outbound:persistence-jpa, with two exceptions driven by this repository's own rules. This is the same adaptation already applied to the HTTP client platform (docs/httpclient/repository-adaptation.md).

Plan module Repository home Reason
jpa-spring-boot-starter :app-bootstrap (dev.caskeleton.bootstrap.autoconfigure.jpa) This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. AGENTS.md assigns composition to app-bootstrap.
jpa-testkit, jpa-testkit-postgresql, jpa-testkit-migration, jpa-testkit-queryplan :adapter:outbound:persistence-jpa src/testkit/java/**/testkit The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it.

The package boundary is enforced by JpaModuleBoundaryTest. It holds a closed catalog of the production root's direct child packages, compares that catalog against the tree for exact equality, checks every observed top-level edge against the declared ones, and rejects cycles.

This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree held twenty-two, so nine — audit, config, failure, fileserver, h2, idempotency, lock, notification, outbox — were governed by nothing, and a transaction → postgresql / postgresql → transaction cycle passed. Both are closed now, and the catalog's exact-equality check is what keeps a new package from being green by omission.

Known gap. The catalog governs top-level packages. Sub-package edges inside one top-level package are not checked, and the target tree in the review's JPA-023 (a capability/* layout) is not implemented — the notification configuration facade is the first step toward it.

2. Package mapping

Root package: io.backend.skeleton.jpadev.caskeleton.adapter.outbound.persistence.

Plan module Plan package Repository package
jpa-core-api …jpa.api (+ .capability, .error, .query, .transaction) dev.caskeleton.adapter.outbound.persistence.api (+ same subpackages)
jpa-transaction …jpa.transaction …persistence.transaction
jpa-spring-data …jpa.springdata …persistence.springdata
jpa-querydsl …jpa.querydsl …persistence.querydsl
jpa-hibernate …jpa.hibernate (+ .batch, .bulk, .stateless) …persistence.hibernate (+ same subpackages)
jpa-postgresql …jpa.postgresql (+ .error, .lock, .constraint, .json, .array, .range, .write) …persistence.postgresql (+ same subpackages)
jpa-postgresql-copy …jpa.postgresql.copy …persistence.postgresql.copy
jpa-migration-flyway …jpa.migration …persistence.migration
jpa-auditing …jpa.auditing …persistence.auditing
jpa-envers …jpa.envers …persistence.envers
jpa-cache-hibernate …jpa.cache …persistence.cache
jpa-observability …jpa.observation …persistence.observation
jpa-security …jpa.security …persistence.security
jpa-spring-boot-starter …jpa.autoconfigure dev.caskeleton.bootstrap.autoconfigure.jpa
jpa-testkit* …jpa.testkit (+ .id, .mapping, .lifecycle, .query, .fetch, .postgresql, .migration, .queryplan, .failure, .pool, .release) …persistence.testkit (+ same subpackages), testkit source set
jpa-experimental/* …jpa.experimental (+ .tenant, .rls, .schema, .database, .replica, .next) …persistence.experimental (+ same subpackages)

The existing …persistence.transaction and …persistence.postgresql packages already hold this leaf's TransactionPort implementation and PostgreSQL vendor composition. The platform types are additive: no existing type was renamed, moved, or replaced, and no plan type collides with an existing name.

3. Test-suite mapping

The plan declares seven JVM test suites (test, integrationTest, contractTest, migrationTest, failureTest, performanceTest, compatibilityTest). This leaf already owns a Docker-backed postgresqlIntegrationTest source set and its readiness Gradle tasks are registered in a fail-closed contract (verifyJpaReadinessRegistry in src/build.gradle).

Plan suite Repository lane
test src/test — hermetic unit lane, ./gradlew :adapter:outbound:persistence-jpa:test
contractTest, integrationTest, migrationTest, failureTest, compatibilityTest src/postgresqlIntegrationTest — real PostgreSQL containers; selected by the jpaPlatform* Gradle tasks
performanceTest src/jpaPlatformPerformanceTest — machine-dependent bounds, never part of check

Docker-dependent lanes fail closed rather than skipping, matching the existing PostgreSqlReadinessSupport.assertDockerAvailable() convention in this leaf.

4. Other deliberate substitutions

Plan assumption Repository reality Adaptation
Gradle Kotlin DSL, build-logic convention plugin, jpa-library-conventions.gradle.kts Groovy DSL, root src/build.gradle conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, -Werror, one-type-per-file), LockMode.STRICT dependency locking Source sets and dependencies declared in src/adapter/outbound/persistence-jpa/build.gradle; gradle.lockfile regenerated with resolveAndLockAll --write-locks.
Spring Boot 4.1 dependency management, Spring Data JPA 4.1 Repository baseline is Spring Boot 4.0.0 Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM").
Hibernate ORM 7.4 is the Stable provider Boot 4.0.0 resolves org.hibernate.orm:hibernate-core:7.1.8.Final The declared Stable provider baseline of the design stays 7.4 in HibernateProviderPolicy; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and HibernateProviderPolicy.driftsFromDeclaredBaseline() makes the difference visible instead of hiding it behind a green check.
PostgreSQL 16·17·18 Stable matrix This leaf's existing evidence image is postgres:16-alpine PostgreSqlVersion declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by -Pjpa.matrix.versions=16,17,18, and an unknown or empty selection is an error rather than a skip.
settings.gradle.kts module registration Fail-closed 19-leaf registry No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged.
infra/jpa/{postgres,roles,toxiproxy} Repository already owns infra/ Created at the same repository-relative paths.
docs/jpa/**, docs/adr/ADR-JPA-*, .github/workflows/jpa-*.yml Repository already owns docs/ and .github/workflows/ Created at the same repository-relative paths.
build.gradle.kts release aggregate jpaReleaseGate Root is src/build.gradle Registered there against the repository lane names in §3.
Per-task git add + git commit AGENTS.md: commit policy is human-only; agents do not stage, commit, amend, or push Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here.
Querydsl as an optional module dependency Querydsl is not part of this repository's dependency set querydsl is implemented against the plan's contracts with the Querydsl types kept behind compileOnly, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself.
Hibernate Envers as a module dependency Envers is not part of this repository's dependency set Same treatment as Querydsl: compileOnly + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class".
build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt There is no build-logic project and no Kotlin source set; module boundaries are enforced by the registry itself verifyCleanArchitectureDependencies plus :app-bootstrap:test --tests '*CleanArchitectureTest' assert the same property against src/config/architecture/modules.json, which is the authority the plan's test would have had to duplicate.
PostgreSqlRuntimeRoleVerifierIntegrationTest (Task 45) The security lane is one suite in this leaf rather than a per-module integrationTest PostgreSqlSecurityContractTest (tag jpa-security) exercises PostgreSqlRuntimeRoleVerifier.verify and .requireSafe against a real restricted role on a real server.
JpaSafetyProperties, JpaDataSourceProperties NamingConventionTest requires every @ConfigurationProperties type to end in Settings or Policy Renamed to JpaSafetySettings and JpaDataSourceSettings. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention.

The plan's module map forbids jpa-core-api from depending on any other platform module. Three value-only types the design places in a downstream module are consumed by a core contract, so they live in the core here instead. Each is a pure value with no framework dependency, so the relocation costs nothing and the alternative — a core contract importing an adapter package — would break the boundary the module map exists to hold.

Type Plan module Repository package Consumed by
TransactionCompletionEvidence jpa-transaction …persistence.api.transaction TransactionCompletionUnknownException (design §17.3 types the field)
ConstraintCode jpa-postgresql …persistence.api.error ConstraintViolationDetails (design §22.4)
SqlStateResolver, SqlExceptionSqlStateResolver jpa-transaction …persistence.api.error both the transaction module's commit classifier and the PostgreSQL translator

The ArchUnit rule pack (JpaArchitectureRules, EntityMappingCondition, EntityExposureCondition) is placed in the testkit source set rather than in …persistence.security production code. ArchUnit is a test library; putting the rule pack in main would drag it onto every deployment's runtime classpath to serve code that only ever runs in a test.

Findings the contracts produced against a real server

Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what showed it. Both are recorded here because the design text still reads the old way.

  • §17.2 commit ambiguity is not only SQLSTATE class 08. pg_terminate_backend on a backend with a commit in flight reports 57P01 (admin_shutdown), not a connection-class state — and the commit record may already be in the WAL when it arrives. CommitFailureClassifier now treats 57P01/57P02/57P03 as completion-unknown alongside 40003, class 08, and transport breaks. CommitAmbiguityContractTest asserts the SQLSTATE directly so the rule cannot silently narrow again.
  • Schema-per-tenant status must be read back, not inferred from the run. MigrateResult's target version is empty for a tenant that was already current, so recording it reported migrated tenants as unmigrated during a partial rollout. SchemaTenantMigrationOrchestrator now reads the applied version from the tenant's schema history.

5. What is unchanged from the design

  • Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit policy. No GenericRepository<T, ID> and no Spring Data CRUD re-implementation exists.
  • Application Service owns the transaction boundary; OSIV is false in every runtime profile.
  • TransactionCompletionUnknownException always reports completionUnknown=true, retryable=false, and is never automatically retried — reconciliation handles it.
  • Retry re-executes the whole use case in a new transaction and a new Persistence Context.
  • SQLSTATE classification is structural (40001, 40003, 40P01, 23505, 23503, 23514, 55P03) and never parses localized message text.
  • Flyway is the source of truth for production schema change; Hibernate only validates; ddl-auto never mutates a deployed schema.
  • CREATE INDEX CONCURRENTLY requires an explicit non-transactional migration marker.
  • Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
  • Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4, Hibernate 8, PostgreSQL 19) stay behind backend.jpa.experimental.* flags and never enter the Stable composition.