rest-api-architect
Designs the HTTP contract: endpoints, DTOs, error map, pagination, idempotency and versioning.
30-rest.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.
UC-001 · REST adapter
Partial ofdocs/use-cases/UC-001-create-customer/. Owner:rest-api-architect. Inherits the canonical names from00-caso-de-uso.mdand the ports from10-dominio.md— doesn't reinvent them. Contains no code. Reference shapes in.claude/skills/rest-api-architect/templates/. Rules applied, never reproduced:@.claude/rules/api-rest.md.
| Field | Value |
|---|---|
| Parent spec | 00-caso-de-uso.md |
| Domain partial | 10-dominio.md |
| Resource | customers |
| Version prefix | /api/v1 |
Divergences from 10-dominio.md | none |
Divergences from 00-caso-de-uso.md | none — "reference only" is honoured as a body carrying id alone, plus Location; api-rest.md § Success statuses requires a body on 201, and a reduced representation satisfies both |
Paths below are relative to src/main/java/dev/nerviz/bankapp/infrastructure/rest/. No @RestController exists in the project yet: every class here is NEW.
## 1 · Endpoints
| Method | Path | operationId | Inbound port | Success |
|---|---|---|---|---|
POST | /api/v1/customers | createCustomer | CreateCustomerUseCase.create | 201 + Location: /api/v1/customers/{customerId} (relative, built from the controller's path constant) + CustomerResponse |
No GET: the parent spec has no read use case, and an endpoint with no port behind it is a guessed contract. Location still points at the resource's canonical URI; it answers 404 until a read case exists (## Out of scope of the parent spec).
POST /api/v1/customers requires Idempotency-Key: it creates a resource (api-rest.md § Idempotency). The business duplicate guard (unique security number) does not replace it — it answers the second call with 409, where the protocol requires the retried call to get the original 201 back.
Access: public (parent spec). The project has no Spring Security filter chain; the idempotency key's caller identity is the constant anonymous — see block 4.
… cut here, the rest is in the file
## 2 · DTOs
| DTO | Direction | Fields | Shape validation |
|---|---|---|---|
dto/CreateCustomerRequest | input | name (String), securityNumber (String), birthDate (LocalDate, ISO-8601 yyyy-MM-dd) | @NotNull on each field — presence only |
dto/CustomerResponse | output | id (UUID) | — |
Every field carries @Schema with description, example and explicit requiredMode = REQUIRED. securityNumber's @Schema documents pattern = "^[0-9]{11}$" for the client; the pattern is not enforced with @Pattern — see block 3.
Why presence only. Every format rule (blank or out-of-range name, 11 digits, date limits) is a domain invariant with its own errorCode (10-dominio.md § 2). A @NotBlank, @Size or @Pattern on the same field would fire first, as a violations 400 without errorCode, and make the domain's code unreachable through this endpoint. Kept to @NotNull, the domain answers each format error with a 400 carrying its errorCode.
Masking — re-derived over the DTO fields by @.claude/rules/logging.md § Masking candidates. Same result as 10-dominio.md § 1, no divergence:
| DTO | Field | Match | Annotation |
|---|---|---|---|
CreateCustomerRequest | securityNumber | name (National id) | @MaskSensitiveData(maskedType = MaskedType.DOCUMENT) |
CreateCustomerRequest | birthDate | name (Birth) | @MaskSensitiveData(maskedType = MaskedType.DATE) |
CreateCustomerRequest | name | none — counter-list | — |
CustomerResponse | id | none — counter-list | — |
… cut here, the rest is in the file
## 3 · Error map
| Situation | Domain exception | Status | errorCode | Extra fields |
|---|---|---|---|---|
| Security number already registered | SecurityNumberAlreadyRegisteredException (ConflictException) | 409 | SECURITY_NUMBER_ALREADY_REGISTERED | errorCode |
| Customer not strictly over 18 | BusinessRuleViolationException | 422 | CUSTOMER_UNDERAGE | errorCode |
| Name blank after trimming | ValidationException | 400 | CUSTOMER_NAME_REQUIRED | errorCode |
| Name outside 2 to 120 characters after trimming | ValidationException | 400 | CUSTOMER_NAME_LENGTH | errorCode |
| Security number not exactly 11 digits | ValidationException | 400 | SECURITY_NUMBER_INVALID | errorCode |
| Birth date in the future | ValidationException | 400 | BIRTH_DATE_IN_FUTURE | errorCode |
| Age over 130 | ValidationException | 400 | BIRTH_DATE_TOO_OLD | errorCode |
| Same key, different body | ConflictException | 409 | IDEMPOTENCY_KEY_REUSED | errorCode |
| Same key, request still in progress | ConflictException | 409 + Retry-After | IDEMPOTENCY_KEY_IN_PROGRESS | errorCode |
Idempotency-Key missing or not a UUID | — (MissingIdempotencyKeyException, structural) | 400 | none | violations |
name, securityNumber or birthDate missing | — (bean validation, @NotNull) | 400 | none | violations |
Unreadable JSON, birthDate not yyyy-MM-dd, wrong type | — (deserialization) | 400 | none | — |
| Unmapped | — | 500 | none | traceId |
Unreachable through this endpoint, listed so no test asserts them: CUSTOMER_ID_REQUIRED and REGISTERED_AT_REQUIRED (set by the use case, never by the client); CUSTOMER_NAME_REQUIRED, SECURITY_NUMBER_REQUIRED and BIRTH_DATE_REQUIRED for a missing field — intercepted by @NotNull. CUSTOMER_NAME_REQUIRED stays reachable for a blank or whitespace-only name.
… cut here, the rest is in the file
## 4 · Pagination, idempotency, and dependencies
Pagination — none: no collection endpoint.
Idempotency — @Idempotent + IdempotencyAspect, first Idempotency-Key endpoint of the project, so every piece is NEW:
| Half | Where it lives | In this partial |
|---|---|---|
| Header presence and format | idempotent/IdempotencyKeyInterceptor + idempotent/MissingIdempotencyKeyException, registered by idempotent/IdempotencyWebConfig for handlers annotated @Idempotent | yes — 400 above |
| Annotation and aspect | idempotent/Idempotent, idempotent/IdempotencyAspect, idempotent/IdempotencyRequestHasher, idempotent/IdempotencyResponseCodec, idempotent/AspectInvocationException (templates/IdempotencyAspect.java.example) | yes |
| Claim (own transaction), then effect + stored response (one transaction) | application/shared/IdempotentExecution and its port | no — declared; shape in persistence-architect's templates |
idempotency_keys table | 20-persistencia.md | no — handed off to persistence-architect |
Caller identity stored with the key: the constant anonymous — the endpoint is public and the project has no principal to read. When a later case secures the endpoint, the identity becomes the principal's, in that case's impact section.
Schema requirements — read by persistence-architect in its first pass:
| Requirement | Why |
|---|---|
idempotency_keys table, shared — NEW, first in the project (route, caller, body hash, status, stored response, lease, TTL ≥ 24 h) | POST /api/v1/customers requires Idempotency-Key (api-rest.md § Idempotency) |
A retention for expired idempotency_keys rows — the body hash and stored response are kept ≥ 24 h, never forever | TTL in api-rest.md § Idempotency; the stored response holds only id, so no personal data — the request body is stored as a hash only |
customers expected volume: not given; no collection endpoint, so pagination doesn't depend on it | — |
Dependencies to add — the executor applies them to pom.xml:
| Artifact | Why | Version |
|---|---|---|
org.springframework.boot:spring-boot-starter-aspectj | IdempotencyAspect (@Aspect, @Around) — Boot 4's name for the former spring-boot-starter-aop | managed by the Spring Boot BOM (parent 4.1.1) |
… cut here, the rest is in the file
## 5 · Contract test cases
| Case | Request | Asserts |
|---|---|---|
| Happy creation | valid POST + key | 201, Location = /api/v1/customers/{id}, body id equals the id in Location, no other field |
| Underage — 18th birthday today | birthDate = today − 18 years | 422 and errorCode = CUSTOMER_UNDERAGE |
| Duplicate security number | second POST, new key, same securityNumber | 409 and errorCode = SECURITY_NUMBER_ALREADY_REGISTERED |
| Security number with 10 digits / with punctuation | "1234567890" / "123.456.789-01" | 400 and errorCode = SECURITY_NUMBER_INVALID |
| Blank name | name = " " | 400 and errorCode = CUSTOMER_NAME_REQUIRED |
| Name of 1 / 121 characters | 400 and errorCode = CUSTOMER_NAME_LENGTH | |
| Future birth date | birthDate = tomorrow | 400 and errorCode = BIRTH_DATE_IN_FUTURE |
| Age 131 | birthDate = today − 131 years | 400 and errorCode = BIRTH_DATE_TOO_OLD |
| Missing field | POST without securityNumber | 400, violations[0].field = securityNumber, errorCode absent |
| Unparseable date | birthDate = "17/05/1990" | 400, no errorCode |
| Missing key | POST without Idempotency-Key | 400, violations, no errorCode |
| Idempotent replay | two identical POSTs, same key | one customer created; both 201 with identical Location and body |
| Reused key | same key, different body | 409 and errorCode = IDEMPOTENCY_KEY_REUSED |
| Unmapped failure | use case throws an unexpected exception | 500, traceId present, generic detail |
| No leak | any error above | detail contains neither the security number nor the birth date |
The clock is fixed in the test so "today" is deterministic. Body-shape fixture: templates/error-responses.json.example.
## 6 · Personal data
none — the only response body is CustomerResponse { id }, and an aggregate's id is on the counter-list of @.claude/rules/logging.md § Masking candidates. personal-data.md § How to verify grep 1 over the response DTOs designed here: no hit. Security number and birth date enter in the request and never leave in a response or an error detail.
## Design patterns
none — no force in the spec, no symptom on disk: one resource, one operation, no @RestController in src/ to repeat. The idempotency aspect is api-rest.md's mandated mechanism, not a pattern chosen here.
## Impact on approved use cases
none — UC-001 is the first case.
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; a controller or DTO written under src/ is refused with exit 2.
Reads
docs/use-cases/UC-NNN-<slug>/00-use-case.md and 10-domain.md (mandatory — stops without the second), @.claude/rules/api-rest.md, @.claude/rules/architecture-ddd.md (Adapters and Composition sections), @.claude/rules/naming.md, @.claude/rules/error-handling.md, @.claude/rules/code-quality.md, @.claude/rules/logging.md (masking mechanism for DTO fields 10-domain.md flagged sensitive), @.claude/rules/observability.md § Metrics (the bound on the per-version tag), the 30-rest.md that wrote the versioning setup when an earlier case did, @.claude/rules/inbound-admission.md and the partial that wrote the admission setup when the case has an Admission row, the root CLAUDE.md's **Transport: paragraph (trusted proxies, for a public endpoint's key), and the active blueprint's packages.map. Also reads 20-persistence.md when it exists (a resumed or hand-run case) — the table's expected volume decides offset or cursor. In /new-feature this skill runs first, so the volume comes from its own interview ("does the collection grow without bound") and is written in block 4, where persistence-architect reads it instead of asking again.
Writes
docs/use-cases/UC-NNN-<slug>/30-rest.md. Only that file.
Writes no Java code
The controller, the DTOs, the mapper, the interceptor, the ApiExceptionHandler, and the contract tests come from the executor agent. It doesn't edit pom.xml nor .github/workflows/** — the openapi.json diff job belongs to project-bootstrap.
Doesn't decide
the use case boundary (00-use-case.md), the domain model (10-domain.md), the schema or the idempotency key table (20-persistence.md), nor the test strategy (40-tests.md). Doesn't touch .claude/rules/**.
Doesn't collide with `domain-modeling`
: that one declares the inbound port, this one says over which HTTP it's called. The port's signature belongs to the other one; if it needs to change, report the divergence instead of rewriting it.
Source: SKILL.md § Contract, at nerviz e4cae27.
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'sdocker-compose.yml: another class's filea use case already approved: its specs are frozen
A refusal exits with code 2 and the message goes back to the model.
PreToolUse Skill(rest-api-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/30-rest.md) guard write exit 0 PostToolUse audit file exit 0
PreToolUse Write(docker-compose.yml) guard write exit 2 ❌ `rest-api-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/30-rest.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.
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 orchestrator · opus/new-feature-implement Who reads the partial nextAfter 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.