↑ ↓ to move · Enter to open · Esc to closeTap a result

Architecture
skill · class design · opus

persistence-architect

Designs how the aggregate reaches disk and comes back: schema, mapping, adapters and migrations.

skill · designopuswrites 20-persistencia.mdonly inside docs/use-cases/UC-*/**
§ the file it writes

20-persistencia.md, block by block

A real partial: UC-001-create-customer, in bank-app, excerpted. Hover or click a block to see what the skill fixes there. The sections of the file are also chips: click one to jump to it.

bank-app/docs/use-cases/UC-001-create-customer/20-persistencia.md

UC-001 · Persistence

Partial of docs/use-cases/UC-001-create-customer/. Owner: persistence-architect. Inherits the aggregate and ports from 10-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.
FieldValue
Mother spec00-caso-de-uso.md
Domain partial10-dominio.md
REST partial30-rest.md — block 4 schema requirements read: idempotency_keys (NEW), its retention
AggregateCustomer
EnginePostgreSQL 16 — postgres:16-alpine in docker-compose.yml and in TestcontainersConfiguration
Expected volume at 12 monthsnot given; one insert per creation, no listing — sizing below doesn't depend on it
Divergences from 10-dominio.mdnone

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.

ColumnTypeNullableDefaultConstraint
iduuidno—PK — v7, assigned by the application
namevarchar(120)no—— trimmed value, 2 to 120 characters (domain)
security_numberchar(11)no—uq_customers_security_number UNIQUE (security_number) — business key, arbiter of invariant 11
birth_datedateno——
registered_attimestamptzno—filled by the application's Clock, never by the database
versionbigintno0optimistic lock — no update case yet; present so the first one doesn't need an expand migration

Indexes

IndexColumnsWhy
pk_customersidPK
uq_customers_security_numbersecurity_numberUniqueness, 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.

… cut here, the rest is in the file

## 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 fieldDomain typeColumnTranslation
idCustomerIdidCustomerId.value() ↔ CustomerId.of(uuid)
nameStringnamedirect
securityNumberSecurityNumbersecurity_numberSecurityNumber.value() ↔ SecurityNumber.of(text); field carries @JdbcTypeCode(SqlTypes.CHAR) and @Column(length = 11) — char(11) is not String's default inference
birthDateLocalDatebirth_datedirect
registeredAtInstantregistered_atdirect, UTC
——versionentity-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

PortSignature (from 10-dominio.md)QueryFetchIndex serving it
CustomerRepository.existsBySecurityNumberboolean existsBySecurityNumber(SecurityNumber securityNumber)Spring Data derived existsBySecurityNumber(String) — SELECT … LIMIT 1—uq_customers_security_number
CustomerRepository.saveCustomer 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:

… cut here, the rest is in the file

## 4 · Migrations

FileTypeContent
V1__create_customers.sqlexpandcustomers, its PK and unique constraint
V2__create_idempotency_keys.sqlexpandshared 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

… cut here, the rest is in the file

## 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.

PropertyValueWhy
spring.datasource.hikari.maximum-pool-size10One short insert per request; a pool bigger than Postgres's default max_connections share only moves the queue
spring.datasource.hikari.minimum-idle10Equal to the max — no latency spike opening connections
spring.datasource.hikari.connection-timeout3000Rule — fail fast instead of holding the request
spring.datasource.hikari.validation-timeout2000Rule
spring.datasource.hikari.max-lifetime1800000Below the database side's connection timeout
spring.datasource.hikari.pool-namebank-app-poolReadable pool metrics
spring.jpa.show-sqlfalseRule
spring.jpa.properties.jakarta.persistence.query.timeout5000Rule — query timeout defined; the case's two queries are by unique key
spring.jpa.properties.hibernate.jdbc.time_zoneUTCRule — instants stored in UTC
spring.jpa.properties.hibernate.jdbc.batch_size50Harmless with single inserts; batching ready for later cases
spring.jpa.properties.hibernate.order_insertstrueRule when batching
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetchtrueRule
spring.flyway.validate-on-migratetrueRule
spring.flyway.out-of-orderfalseRule
logging.level.org.hibernate.orm.jdbc.bindWARNTRACE 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

DecidedMissingNormIntended owner
idempotency_keys rows expire after 24 ha 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 recorda 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 restbacklog

## Impact on approved use cases

none — UC-001 is the first case.

## Implementation order

  1. V1__create_customers.sql, V2__create_idempotency_keys.sql
  2. infrastructure/persistence/shared/AssignedIdEntity
  3. CustomerEntity + CustomerPersistenceMapper + CustomerJpaRepository + CustomerRepositoryJpaAdapter
  4. IdempotencyKeyEntity + IdempotencyKeyJpaRepository + IdempotencyKeyStore
  5. Properties in application.yml
  6. Integration tests against the real database (Testcontainers) — 40-testes.md
Open the full file on GitHub →
§ behavior

What it reads, writes and refuses

The skill's own contract, from SKILL.md, in the original wording.

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.

Source: SKILL.md § Contract, at nerviz e4cae27.

§ guard and hook

What the guard does with it

design is the skill's class. ArchHook opens a phase when the skill is called and checks every write against the class's territory. The text on the right is the hook's own.

write_allow of class design

  • ✓docs/use-cases/UC-*/**
  • ✓docs/use-cases/BACKLOG.md
  • src/**: migrations and classes are the executor's
  • docker-compose.yml: another class's file
  • a use case already approved: its specs are frozen

A refusal exits with code 2 and the message goes back to the model.

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
§ orchestration

Where it sits on the rail

Each card is a step of /new-feature; the arrow carries the partial one step hands to the next. Dashed steps run only when the use case needs them.

/new-feature-implement Who reads the partial next

After approval, /new-feature-implement has the executor build the code. The spec is consolidated by reference and wins where it and a partial disagree; the partial is read, by section, for detail.

20-persistencia.mddocs/use-cases/UC-NNN-…/
read by section, for detail
java-spring-boot-developeragent · executor
writes the code
src/**designs one use case into a spec, writes no code
← back to the chains