Files
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

65 lines
3.5 KiB
Markdown

# Entity Mapping Guide
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
and expensive in production.
## The domain owns the model
The platform defines no business entity. Table names, column semantics, keys, unique and check
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
single generic API forces every aggregate through the same operations — and one aggregate's later
requirement then changes behaviour for all of them.
## Entities must be proxyable
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
- A non-private no-arg constructor. The provider instantiates entities reflectively before
populating fields.
`EntityMappingCondition` in the testkit enforces both.
## Identifiers
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
surfaces later as a primary-key violation under load.
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
letting the import silently run an order of magnitude slower.
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
never stays in cache and write amplification grows with the table.
## Values
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
existing row.
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
drift and reconciliation disagrees with the ledger.
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
compares wrongly in SQL.
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
other.
## Associations
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
child also queries for its parent — the most common accidental N+1 in a JPA application.
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
unlinked, so aggregates expose an association helper that sets both sides.
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
Between independent aggregates it deletes rows another part of the system still owns.
## Entities never leave the transaction
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
happens after the transaction closes, so a lazy association touched by the serialiser either throws
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
`EntityExposureCondition` checks generic type arguments, not just the erased return type.