Files
clean-architecture-backend-…/docs/jpa/entity-mapping-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.5 KiB

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.