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>
65 lines
3.5 KiB
Markdown
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.
|