init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
# adapter:outbound:persistence-jpa — JPA/PostgreSQL persistence adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-persistence-jpa`
|
||||
- Gradle path: `:adapter:outbound:persistence-jpa`
|
||||
- Focused test: `./gradlew :adapter:outbound:persistence-jpa:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.persistence`.
|
||||
|
||||
Design decisions previously kept as code comments (transaction templates, auditing capture,
|
||||
failure-translation SPI, idempotency/outbox concurrency, distributed-lock TTL) live in
|
||||
[README.md](README.md). This file stays the SSOT for module rules and contract tables.
|
||||
|
||||
This module is the RDBMS/JPA implementation base. It is not a datastore-neutral
|
||||
core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence
|
||||
adapters implement application/domain ports directly and must not depend on this module.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- JPA entities.
|
||||
- Spring Data repositories.
|
||||
- Persistence mappers.
|
||||
- Repository adapter implementations.
|
||||
- `TransactionPort` implementation (`SpringTransactionPort`) — the bridge between
|
||||
application transactional intent and Spring's `PlatformTransactionManager`.
|
||||
- Audit-metadata base + actor seam (`audit/AuditableEntity`, `audit/AuditContextPort`,
|
||||
`audit/DomainContextAuditContextPort`) — see "Persistence auditing contract" below.
|
||||
- Vendor SPI extension points shared by all RDBMS vendors:
|
||||
- `outbox/OutboxClaimRepository` — vendor module implements claim strategy (e.g. FOR UPDATE SKIP LOCKED).
|
||||
- `failure/SqlStateErrorMapping` — vendor module contributes vendor-specific SQLState rows.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`
|
||||
- `:domain-core`
|
||||
- `:shared-contract`
|
||||
- Spring Data JPA and Spring transaction.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- `adapter-web`, `adapter-outbound`, or `app-bootstrap`.
|
||||
- Presentation DTOs.
|
||||
- Business policy decisions.
|
||||
- Use case orchestration hidden inside persistence adapters.
|
||||
- Repository adapters owning `@Transactional` boundaries — the application use case owns
|
||||
the transaction via `TransactionPort` (see
|
||||
[application-core/CLAUDE.md](../application-core/CLAUDE.md)).
|
||||
- **DB drivers** (`org.postgresql..`) or **`org.flywaydb.database.postgresql..`** — those are
|
||||
vendor-specific and belong only in this module's `.postgresql` package; NoSQL-specific dependencies
|
||||
belong only in their own future modules
|
||||
(persistence-multi-db-extensibility D3). This is enforced by ArchUnit
|
||||
`persistence_rdbms_stays_vendor_neutral` in `CleanArchitectureTest`.
|
||||
- NoSQL adapter code. MongoDB/Redis/DynamoDB adapters are sibling modules, not children of this module.
|
||||
- Any sibling persistence or inbound/outbound adapter not allowed by the registry.
|
||||
|
||||
## TransactionPort implementation contract
|
||||
|
||||
`SpringTransactionPort` pre-builds one `TransactionTemplate` per mode:
|
||||
|
||||
| Mode | Propagation | Isolation | Read-only |
|
||||
|---|---|---|---|
|
||||
| `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
|
||||
| `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` |
|
||||
| `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` |
|
||||
|
||||
Pre-built templates are immutable after construction so concurrent callers cannot
|
||||
observe each other's reconfiguration.
|
||||
|
||||
### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract)
|
||||
|
||||
`REQUIRES_NEW` acquires a NEW physical JDBC connection while pinning the outer
|
||||
transaction's connection. Provision the pool to satisfy:
|
||||
|
||||
```
|
||||
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
|
||||
```
|
||||
|
||||
Loop-per-record `inNew` calls are forbidden (pool exhaustion + deadlock risk).
|
||||
Batch records inside ONE `inNew`, or move the loop outside the transaction.
|
||||
|
||||
## Persistence failure translation contract (feature-persistence-failure-baseline D1)
|
||||
|
||||
A raw Spring `DataAccessException` (and the JPA exception / SQLState / constraint name
|
||||
inside it) must never reach the presentation layer. The
|
||||
`failure/PersistenceExceptionTranslator` classifies a `DataAccessException` by its
|
||||
SQLState against the §SQLState → Error Code Matrix and returns a
|
||||
framework-neutral `shared.error.PersistenceFailureException` carrying one of the
|
||||
`DB_*` `OperationalError` codes.
|
||||
|
||||
**Standard rows (core):**
|
||||
|
||||
| SQLState | code | category | http | retryable |
|
||||
|---|---|---|---|---|
|
||||
| `08*` | `DB_UNAVAILABLE` | `TRANSIENT_DEPENDENCY` | 503 | true |
|
||||
| `40001` | `DB_SERIALIZATION_FAILURE` | `CONFLICT` | 409 | true |
|
||||
| `23502` | `DB_NULL_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
|
||||
| `23503` | `DB_FK_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
|
||||
| `23505` | `DB_UNIQUE_VIOLATION` | `CONFLICT` | 409 | false |
|
||||
| `23514` | `DB_CHECK_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
|
||||
|
||||
**Vendor-specific rows (contributed by vendor module via `SqlStateErrorMapping` SPI):**
|
||||
|
||||
| SQLState | code | vendor |
|
||||
|---|---|---|
|
||||
| `40P01` | `DB_DEADLOCK` | PostgreSQL (`.postgresql` package) |
|
||||
| `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL |
|
||||
| `57014` | `DB_QUERY_CANCELED` | PostgreSQL |
|
||||
|
||||
- A repository adapter that catches a `DataAccessException` calls
|
||||
`translator.translate(ex)` and rethrows the carrier (`ifPresent(e -> { throw e; })`);
|
||||
an empty result means an unknown SQLState — rethrow the original so the web catch-all
|
||||
answers a generic `INTERNAL` envelope (no leak).
|
||||
- The category SSOT is the 10-value `Category` enum — there is **no** `PERSISTENCE`
|
||||
category (branch-note §Audit CATEGORY_DRIFT).
|
||||
|
||||
## Persistence auditing contract (feature-persistence-auditing-contract)
|
||||
|
||||
Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an
|
||||
infrastructure concern that must never reach `domain-core` (D2). It lives only on the
|
||||
`audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in
|
||||
by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries
|
||||
zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus
|
||||
`domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy`
|
||||
fields under `..domain..`).
|
||||
|
||||
- **Capture = Manual explicit-set (D1 current default).** The repository adapter
|
||||
constructor-injects `Clock` (D4) and `AuditContextPort` (D5) and stamps audit on `save`:
|
||||
INSERT (null version) → `initializeAudit(now, actor)`; UPDATE (non-null version) →
|
||||
carry the persisted `created_*` forward + `applyModification(now, actor)`. This mirrors
|
||||
the `IdempotencyStoreAdapter` precedent. `created_*` is `updatable = false`.
|
||||
- **Actor seam.** `AuditContextPort.currentActor()` reads the runtime-context-propagation
|
||||
seam and falls back to `"system"` when no principal is bound (scheduler / Flyway / anonymous).
|
||||
The actor's value semantics are owned by feature-authentication-authorization-contract
|
||||
(UNSUPPORTED here); the type is fixed to `String`.
|
||||
- **Excluded (D6).** Infra/immutable entities such as `IdempotencyRecordEntity` (own
|
||||
`created_at`, no `updated_at`) do NOT extend `AuditableEntity`. `version`/optimistic-lock
|
||||
is owned by feature-persistence-failure-baseline / feature-transaction-concurrency-contract,
|
||||
not by this audit base.
|
||||
- **Growth path (D1, deferred).** Migrate to Spring Data JPA Auditing
|
||||
(`@EntityListeners(AuditingEntityListener)` + `@CreatedDate`/`@LastModifiedDate`/… on the
|
||||
base, `@EnableJpaAuditing(dateTimeProviderRef, auditorAwareRef)` in the composition root,
|
||||
`DateTimeProvider` wrapping the same `Clock`, `AuditorAware<String>` delegating to
|
||||
`AuditContextPort`) when manual set risks omission. Bulk/native `@Query` UPDATEs bypass
|
||||
both capture paths — stamp audit explicitly there if added.
|
||||
|
||||
## MapStruct generated mapper exemption (D9 of feature-architecture-enforcement-rules)
|
||||
|
||||
If MapStruct is introduced for persistence mappers, the generated mapper class will
|
||||
be annotated with `javax.annotation.processing.Generated`. Architecture rules that
|
||||
forbid mapper boundary violations MUST exempt generated code via ArchUnit predicate:
|
||||
|
||||
```java
|
||||
import javax.annotation.processing.Generated;
|
||||
|
||||
classes()
|
||||
.that().resideInAPackage("..adapter.persistence.mapper..")
|
||||
.and().areNotAnnotatedWith(Generated.class)
|
||||
.should() /* ... boundary rule ... */;
|
||||
```
|
||||
|
||||
> Note the annotation FQN: MapStruct uses
|
||||
> `javax.annotation.processing.Generated`. Spring AOT uses
|
||||
> `org.springframework.aot.generate.Generated` — do **not** mix the two. The
|
||||
> exemption MUST scope to the specific annotation expected for the build step
|
||||
> being exempted.
|
||||
|
||||
Current ca-tmpl mappers are hand-written so no MapStruct exemption is wired into
|
||||
ArchUnit yet — when generation is added, follow the predicate above and add a
|
||||
red/green test using a fixture mapper.
|
||||
|
||||
## NoSQL extension rule
|
||||
|
||||
Do not create `adapter-persistence-nosql-core` preemptively. NoSQL stores have different
|
||||
models and operational contracts. When a real MongoDB, Redis, or DynamoDB adapter is needed,
|
||||
create a sibling module:
|
||||
|
||||
```text
|
||||
adapter-persistence-mongodb
|
||||
adapter-persistence-redis
|
||||
adapter-persistence-dynamodb
|
||||
```
|
||||
|
||||
Such modules implement application/domain ports directly and must not depend on
|
||||
`adapter:outbound:persistence-jpa`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||||
```
|
||||
Reference in New Issue
Block a user