Files
clean-architecture-backend-…/docs/jpa/migration-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

2.9 KiB

Migration Guide

Design §31-§32. Flyway owns the schema; Hibernate only validates.

Who may change the schema

Environment Mode
local, test, dev migrate at startup with the migration credential
staging, prod deployment-owned migration; the application validates only

Migrating from inside the application in production means every instance of a rolling deploy races to apply the same script, and the loser's failure is indistinguishable from a real one.

ddl-auto is validate or none. Never update: it never drops or narrows anything, so it produces a schema that is neither the old one nor the one the migrations describe — silently, on whichever instance started first.

Validation fails closed and never repairs

FlywayValidationGate throws SchemaMismatchException on a checksum mismatch, a missing migration, or a schema Hibernate disagrees with. It never calls repair.

Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the applied one, and the interesting question is which change is missing from this database. Repair makes that question unaskable. It exists only as an explicit admin operation with an operator, a reason, and an approval (design §8.4).

Only Flyway's structured error codes reach the exception. Its messages embed the script path and part of the failing statement.

Concurrent index builds

CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and Flyway wraps migrations in one by default. The migration therefore needs a companion configuration:

# V42__order_index.sql.conf
executeInTransaction=false

ConcurrentIndexMigrationInspector fails validation without it, and additionally requires the migration to contain nothing else. A failed concurrent build leaves an invalid index behind; recovering is a single DROP INDEX when the migration did nothing else, and a manual reconstruction of partial state when it did.

An invalid index is not merely useless — the planner ignores it while every write still maintains it. FailedConcurrentIndexRecovery reports them with the statement to run, and deliberately does not drop them: an invalid index can also mean a build is still running, and the two are indistinguishable from the catalog alone.

Upgrade scenarios

Three, each catching something the others do not:

Scenario Catches
empty an early migration edited to match a later one, no longer applying to a fresh database
previous-release the actual deployment path; the only one exercising this release's migrations
oldest-supported a migration that silently assumes state only recent databases have

Each asserts a data invariant, not just the schema version. A migration that renames a column and loses its contents leaves the version correct and the data gone.