20-persistencia.md,逐块说明
一份真实的分文件:bank-app 中的 UC-001-create-customer,为节选。把鼠标移到某一块上或点击它,可以看到 skill 在这里固定了什么。文件的各个章节也是标签:点击即可跳转。
UC-001 · Persistence
Partial ofdocs/use-cases/UC-001-create-customer/. Owner:persistence-architect. Inherits the aggregate and ports from10-dominio.md— doesn't reinvent them. Contains no code. Form references in.claude/skills/persistence-architect/templates/. Rules applied, never reproduced:@.claude/rules/persistence.md.
| Field | Value |
|---|---|
| Mother spec | 00-caso-de-uso.md |
| Domain partial | 10-dominio.md |
| REST partial | 30-rest.md — block 4 schema requirements read: idempotency_keys (NEW), its retention |
| Aggregate | Customer |
| Engine | PostgreSQL 16 — postgres:16-alpine in docker-compose.yml and in TestcontainersConfiguration |
| Expected volume at 12 months | not given; one insert per creation, no listing — sizing below doesn't depend on it |
Divergences from 10-dominio.md | none |
Paths relative to src/main/java/dev/nerviz/bankapp/ unless they start with src/. Survey: no migration, no @Entity, no idempotency_keys in the project — everything here is NEW.
## 1 · Schema
Table customers — one per aggregate root.
| Column | Type | Nullable | Default | Constraint |
|---|---|---|---|---|
id | uuid | no | — | PK — v7, assigned by the application |
name | varchar(120) | no | — | — trimmed value, 2 to 120 characters (domain) |
security_number | char(11) | no | — | uq_customers_security_number UNIQUE (security_number) — business key, arbiter of invariant 11 |
birth_date | date | no | — | — |
registered_at | timestamptz | no | — | filled by the application's Clock, never by the database |
version | bigint | no | 0 | optimistic lock — no update case yet; present so the first one doesn't need an expand migration |
Indexes
| Index | Columns | Why |
|---|---|---|
pk_customers | id | PK |
uq_customers_security_number | security_number | Uniqueness, and serves existsBySecurityNumber |
No index on name or birth_date: no query filters by them.
Table idempotency_keys — shared by every Idempotency-Key endpoint, NEW, first in the project. Columns, types and index exactly as templates/IdempotencyKeyTable.sql.example — the set is @.claude/rules/api-rest.md § Idempotency's, not redecided here: TTL 24 h (expires_at = created_at + 24 h), IN_PROGRESS lease 5 minutes, caller_identity = anonymous for this public endpoint (30-rest.md § 4).
Personal data at rest — @.claude/rules/personal-data.md § At rest.
… 此处截断,其余见文件
## 2 · Mapping
Aggregate Customer → entity CustomerEntity in infrastructure/persistence/customer/. Entity, Spring Data interface, mapper and adapter live there, package-private (@.claude/rules/persistence.md § Boundary).
| Aggregate field | Domain type | Column | Translation |
|---|---|---|---|
id | CustomerId | id | CustomerId.value() ↔ CustomerId.of(uuid) |
name | String | name | direct |
securityNumber | SecurityNumber | security_number | SecurityNumber.value() ↔ SecurityNumber.of(text); field carries @JdbcTypeCode(SqlTypes.CHAR) and @Column(length = 11) — char(11) is not String's default inference |
birthDate | LocalDate | birth_date | direct |
registeredAt | Instant | registered_at | direct, UTC |
| — | — | version | entity-only, @Version long version, invisible to the domain |
Reconstruction goes through Customer.rehydrate(...), never register — the date rules must not re-run against today (10-dominio.md § 1). Mapper: CustomerPersistenceMapper, static, package-private.
CustomerEntity extends AssignedIdEntity<UUID> — the id is assigned, so the entity is Persistable (@.claude/rules/persistence.md § Identity and keys). AssignedIdEntity is NEW, written once in infrastructure/persistence/shared/AssignedIdEntity.java, public abstract (templates/JpaEntity.java.example, last block); IdempotencyKeyEntity extends it too.
## 3 · Adapter and ports
| Port | Signature (from 10-dominio.md) | Query | Fetch | Index serving it |
|---|---|---|---|---|
CustomerRepository.existsBySecurityNumber | boolean existsBySecurityNumber(SecurityNumber securityNumber) | Spring Data derived existsBySecurityNumber(String) — SELECT … LIMIT 1 | — | uq_customers_security_number |
CustomerRepository.save | Customer save(Customer customer) | saveAndFlush — INSERT (new entity, isNew = true) | — | pk_customers |
CustomerRepositoryJpaAdapter (blueprint convention <Capability><Technology>Adapter) implements CustomerRepository; CustomerJpaRepository extends JpaRepository<CustomerEntity, UUID>. No @Transactional here — the use case owns the transaction.
Constraint translation. save calls saveAndFlush so the INSERT reaches the database inside the adapter's try. A DataIntegrityViolationException whose constraint name is uq_customers_security_number becomes SecurityNumberAlreadyRegisteredException with the original as cause; any other integrity violation is rethrown as a ConflictException with errorCode CUSTOMER_PERSISTENCE_CONFLICT. The message never carries the security number.
Concurrency: two requests with the same number — both pass existsBySecurityNumber, the second INSERT hits the unique constraint and is translated as above. No collection returned, so no pagination or JOIN FETCH.
Idempotency — NEW, shapes in templates/IdempotencyKeyStore.java.example and templates/IdempotentExecution.java.example, packages per the blueprint's packages.map:
… 此处截断,其余见文件
## 4 · Migrations
| File | Type | Content |
|---|---|---|
V1__create_customers.sql | expand | customers, its PK and unique constraint |
V2__create_idempotency_keys.sql | expand | shared idempotency_keys and its expires_at index |
New tables, no expand/contract pair. The executor creates each file verbatim from these blocks, re-checking <N> against the disk.
src/main/resources/db/migration/V1__create_customers.sql
-- Personal data: security_number (national identification number) and birth_date.
-- Retention: life of the customer record (UC-001 20-persistencia.md § 1).
CREATE TABLE customers (
id uuid NOT NULL,
name varchar(120) NOT NULL,
security_number char(11) NOT NULL,
birth_date date NOT NULL,
registered_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 0,
CONSTRAINT pk_customers PRIMARY KEY (id),
CONSTRAINT uq_customers_security_number UNIQUE (security_number)
);
src/main/resources/db/migration/V2__create_idempotency_keys.sql
… 此处截断,其余见文件
## 5 · Configuration
Added to src/main/resources/application.yml; what is already there (open-in-view: false, ddl-auto: validate, Flyway enabled, datasource variables) stays as is.
| Property | Value | Why |
|---|---|---|
spring.datasource.hikari.maximum-pool-size | 10 | One short insert per request; a pool bigger than Postgres's default max_connections share only moves the queue |
spring.datasource.hikari.minimum-idle | 10 | Equal to the max — no latency spike opening connections |
spring.datasource.hikari.connection-timeout | 3000 | Rule — fail fast instead of holding the request |
spring.datasource.hikari.validation-timeout | 2000 | Rule |
spring.datasource.hikari.max-lifetime | 1800000 | Below the database side's connection timeout |
spring.datasource.hikari.pool-name | bank-app-pool | Readable pool metrics |
spring.jpa.show-sql | false | Rule |
spring.jpa.properties.jakarta.persistence.query.timeout | 5000 | Rule — query timeout defined; the case's two queries are by unique key |
spring.jpa.properties.hibernate.jdbc.time_zone | UTC | Rule — instants stored in UTC |
spring.jpa.properties.hibernate.jdbc.batch_size | 50 | Harmless with single inserts; batching ready for later cases |
spring.jpa.properties.hibernate.order_inserts | true | Rule when batching |
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch | true | Rule |
spring.flyway.validate-on-migrate | true | Rule |
spring.flyway.out-of-order | false | Rule |
logging.level.org.hibernate.orm.jdbc.bind | WARN | TRACE would log the bound security number and birth date |
Shape in templates/application-persistence.yml.example.
## 6 · Declared dependencies
none — spring-boot-starter-data-jpa, postgresql, flyway-database-postgresql and com.fasterxml.uuid:java-uuid-generator are already in pom.xml. The Postgres service exists in docker-compose.yml (postgres:16-alpine): no pending compose service.
## Design patterns
none — no force in the spec, no symptom on disk: one aggregate, no existing adapter whose skeleton this one would repeat. The idempotency store is the mandated shared mechanism.
## Deferred
| Decided | Missing | Norm | Intended owner |
|---|---|---|---|
idempotency_keys rows expire after 24 h | a scheduled, batched DELETE FROM idempotency_keys WHERE expires_at < now() — nothing deletes expired rows; the table grows by one row per creation call. No personal data in it | @.claude/rules/api-rest.md § Idempotency (TTL), @.claude/rules/personal-data.md § At rest ("a retention window nobody reads is no retention") | backlog — user's decision |
| Customer personal data kept for the life of the record | a use case that deletes or anonymizes a customer — until it exists, security_number and birth_date are kept indefinitely | @.claude/rules/personal-data.md § At rest | backlog |
## Impact on approved use cases
none — UC-001 is the first case.
## Implementation order
V1__create_customers.sql,V2__create_idempotency_keys.sqlinfrastructure/persistence/shared/AssignedIdEntityCustomerEntity+CustomerPersistenceMapper+CustomerJpaRepository+CustomerRepositoryJpaAdapterIdempotencyKeyEntity+IdempotencyKeyJpaRepository+IdempotencyKeyStore- Properties in
application.yml - Integration tests against the real database (Testcontainers) —
40-testes.md
它读什么、写什么、拒绝什么
skill 自己的契约,取自 SKILL.md,保留原文。
Class
design — the territory is skill_classes.design in @.claude/schemas/extensions.json and ArchHook.java guard enforces it. Writes inside the use case folder and nothing else: no migration under src/, no service in the compose file, both refused with exit 2.
Reads
docs/use-cases/UC-NNN-<slug>/00-use-case.md (its Storage row mandatory, its Audit row when present) and 10-domain.md (mandatory — stops without either), @.claude/rules/persistence.md, @.claude/rules/architecture-ddd.md (Adapters and Composition sections), @.claude/rules/naming.md, @.claude/rules/error-handling.md, @.claude/rules/lombok.md, @.claude/rules/value-objects.md, @.claude/rules/api-rest.md § Idempotency (only when step 4a applies), @.claude/rules/messaging.md § Publication timing (only when step 4b applies), and the active blueprint's packages.map. Also reads two partials whose schema requirements are this pass's input, each mandatory under its own condition: 30-rest.md for a REST-triggered case (block 4), 31-graphql.md for a GraphQL-triggered one (§ 9 — batch statements and the connection order's index), and 25-messaging.md whenever 10-domain.md's Events block names external delivery (§ 6 — the shared outbox table under Form B, a dedupe table for a consumer). And 35-jobs.md under Form B or a scheduled trigger (§ 3 replica count, § 6 tool tables, claim requirement and prune operation). And 28-http-client.md whenever 10-domain.md declares a port of kind external HTTP (§ 10 — a stored idempotency key, an unknown-outcome column). And 23-search.md when Search index is not none (§ 8 — the source's version and its change event). And 32-security.md when an audited aggregate is written through HTTP (the principal recorded as the actor). Missing any of them where it's required stops this skill instead of starting a design that a later pass would have to revise.
Writes
docs/use-cases/UC-NNN-<slug>/20-persistence.md. Nothing else — the migration SQL lives inside it.
Never writes under `src/`
Not a migration, not a class, not a property file. The executor materializes every file there from this partial.
Does not write Java code
The entities, adapters, and repositories come from the executor agent.
Does not edit `docker-compose.yml`, and does not invoke `docker-architect` either
When step 9 finds the chosen engine has no container yet, it records the user's answer in the partial's ## Compose services (@.claude/skills/docker-architect/references/compose-service-decision.md), and /new-feature-implement adds a materialize row after the code is green. The guard enforces both halves: the file is outside this class's territory, and a build-class skill is unreachable from inside a design run.
Does not decide
the storage family of an aggregate, nor weigh one family against another (00-use-case.md's Storage row), nor whether an aggregate is audited and at which level (its Audit row), the use case boundary (00-use-case.md), the domain model (10-domain.md), the transport (30-rest.md), or the tests (40-tests.md). Doesn't touch .claude/rules/**.
Does not collide with `messaging-architect`
: that one decides the publication form and owns the relay pass, the appender, and the declaration of OutboxRelayGateway / OutboxEventRecord. This one owns the outbox_events table, its mapping, and the adapter implementing that port. A form choice is never made here — an outbox requirement that contradicts 25-messaging.md is a divergence to report.
Does not collide with `jobs-architect`
: that one decides when things run — the relay's poll interval, the switch, coordination across instances, the prune's cadence — and declares OutboxRetentionGateway. This one implements that port, owns the statement it runs, the retention window, and the final form of every scheduling table 35-jobs.md § 6 asks for. The audit history's prune splits the same way: AuditHistoryPruneJob and AuditHistoryRetentionGateway are that skill's, the statement and app.audit.history.retain-for are this one's.
Does not collide with `domain-modeling`
: that one declares the output port, this one says how it's served. The port's signature belongs to the other; if it needs to change, report the divergence instead of rewriting it.
来源:SKILL.md § Contract(nerviz e4cae27)。
guard 如何处理它
design 是这个 skill 的类。ArchHook 在 skill 被调用时开启一个阶段,并按该类的领地检查每次写入。右侧文字是 hook 自己的输出。
design 类的 write_allow
- ✓
docs/use-cases/UC-*/** - ✓
docs/use-cases/BACKLOG.md src/**:迁移和类归执行器docker-compose.yml:另一个类的文件已批准的用例:其 spec 已冻结
拒绝以退出码 2 结束,消息回到模型。
PreToolUse Skill(persistence-architect) audit call exit 0 guard call exit 0 # a skill of another class replaces the phase # write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md
PreToolUse Write(docs/use-cases/UC-005-record-kyc-verdict/20-persistencia.md) guard write exit 0 PostToolUse audit file exit 0
PreToolUse Write(docker-compose.yml) guard write exit 2 ❌ `persistence-architect` is class `design` — docker-compose.yml is outside its territory. write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md Put the content in the spec as a code block, or finish the run and invoke the skill that owns this path. If the path is legitimately this skill's, widen skill_classes.design in .claude/schemas/extensions.json — retrying will not help.
# UC-005-record-kyc-verdict-spec.md: status: approved PreToolUse Edit(docs/use-cases/UC-005-record-kyc-verdict/20-persistencia.md) guard write exit 2 ❌ UC-005-record-kyc-verdict is approved — its specs are immutable. Record the change in the new use case's "Impact on approved use cases" section, and the one-line entry in UC-005-record-kyc-verdict/CHANGELOG.md, which stays writable.
它在轨道上的位置
每张卡片是 /new-feature 的一步;箭头带着一步交给下一步的分文件。虚线的步骤仅在用例需要时运行。
/new-feature orchestrator · opus/new-feature-implement 之后谁读这份分文件批准之后,/new-feature-implement 让执行器构建代码。spec 按引用整合,与分文件不一致时以 spec 为准;分文件按章节读取,用于细节。