messaging-architect
Projeta os adaptadores Kafka: tópico, chave de partição, entrega, idempotência do consumidor, retry e DLQ.
25-mensageria.md, bloco a bloco
Um parcial real: UC-005-record-kyc-verdict, no bank-app, em trechos. Passe o mouse ou clique num bloco para ver o que a skill fixa ali. As seções do arquivo também são chips: clique em um para ir até ela.
UC-005 · Messaging
Partial ofdocs/use-cases/UC-005-record-kyc-verdict/. Owner:messaging-architect. Inherits the incoming message from10-dominio.md§ 4 — doesn't redefine its business meaning. Contains no code. Form references in.claude/skills/messaging-architect/templates/. Rules applied, never reproduced:@.claude/rules/messaging.md.
| Field | Value |
|---|---|
| Mother spec | 00-caso-de-uso.md |
| Domain partial | 10-dominio.md |
| Event | KycVerificationCompleted — the KYC verdict, consumed; published by the KYC application on a contract this project defines |
| Broker | Kafka |
Divergences from 10-dominio.md / 00-caso-de-uso.md | 1. A business rejection (unknown customer, conflicting verdict) goes to the DLQ with no retry, not "discarded" — user's answer in this pass, which aligns with @.claude/rules/messaging.md § Retry and DLQ; the status still stays unchanged. 2. Requirement to 10-dominio.md: errorCode KYC_VERDICT_UNKNOWN (ValidationException) for a verdict string outside APPROVED / REJECT, raised by the listener's mapping — § 3 |
Package base: dev.nerviz.bankapp. Paths below are relative to src/main/java/dev/nerviz/bankapp/.
## 1 · Topic and delivery
| Property | Value | Why |
|---|---|---|
| Topic | bank-app.customer.kyc-verification-completed | <bounded-context>.<aggregate>.<event>; bounded context bank-app from the root CLAUDE.md. This project defines the contract (00-caso-de-uso.md), so the name follows this project's rule, mirroring bank-app.customer.kyc-verification-requested |
| Partition key | customerId (string form of the UUID) — a contract obligation of the publisher | Every verdict of one customer lands in one partition, in order; with one consumer per partition, two verdicts for the same customer are never applied concurrently |
| Partitions / replicas | 3 / ${KAFKA_TOPIC_REPLICAS:1} — same as the request topic | Declared as a NewTopic here because this project owns the contract; production topic provisioning follows the same values |
| Serialization | JSON text — StringDeserializer | No build-time contract validation asked for by either side — rule default; no schema registry |
| Converted to JSON by | the KYC application (outside this project) | — |
| Converted from JSON by | one StringJacksonJsonMessageConverter bean, into the listener's typed parameter | One bean for every listener; the rule's wire contract |
| Delivery | At-least-once + idempotent consumer (§ 3) | Rule default |
## 2 · Producer adapter
none — this case publishes nothing (10-dominio.md § 4: no outbound event). Publication form not applicable.
Payload contract — the message the KYC application publishes, defined here (user's answer: this project defines it). Handed to the KYC application as is.
| Field | JSON type | Required | Meaning |
|---|---|---|---|
eventId | string, UUID | ✅ | the message's own identity — logged and carried to the DLQ for tracing; not the dedupe key (§ 3) |
customerId | string, UUID | ✅ | the customerId received in bank-app.customer.kyc-verification-requested |
verdict | string | ✅ | APPROVED or REJECT |
Record key: customerId. No headers required. Adapter-local record: infrastructure/messaging/kycverificationcompleted/KycVerificationCompletedPayload(UUID eventId, UUID customerId, String verdict).
Personal data in the payload — see § 8: none.
## 3 · Consumer adapter and idempotency
| Consuming UC | Listener | Group id | Dedupe key |
|---|---|---|---|
RecordKycVerdictUseCase | infrastructure/messaging/kycverificationcompleted/KycVerificationCompletedListener | bank-app.kyc-verdict | none — state comparison (user's answer) |
Listener, package-private, per templates/KafkaConsumerAdapter.java.example minus the dedupe store:
- receives
KycVerificationCompletedPayload(converted by the shared converter; unparseable - maps
verdict:"APPROVED"→KycVerdict.APPROVED,"REJECT"→KycVerdict.REJECT, recordKycVerdict.record(new RecordKycVerdictCommand(payload.customerId(), verdict));ack.acknowledge()— only after the use case returns.
No branching on payload content beyond that mapping; no aggregate touched directly.
Why no dedupe table. The only effect is moving a customer out of KYC_IN_PROGRESS into a final status. A redelivered verdict finds the customer already in that status, and Customer.recordKycVerdict returns it unchanged — nothing is written (10-dominio.md § 1 and § 3). The end state of the redelivery equals the end state of the first delivery, which is the guarantee @.claude/rules/messaging.md § Delivery semantics asks of the dedupe. The identity deduped on is the aggregate id plus the verdict, carried by the state itself. A processed_events table becomes necessary only with a consumer whose effect is not naturally idempotent — that consumer's case adds it.
Who absorbs the duplicate
… cortado aqui, o resto está no arquivo
## 4 · Retry and DLQ
One DefaultErrorHandler + DeadLetterPublishingRecoverer for the listener container factory, in the shared listener wiring (§ 5).
| Failure | Handling |
|---|---|
| Transient (database unavailable, lock timeout, connection reset) | 3 retries, exponential backoff 1 s / 2 s / 4 s, then DLQ |
Business rejection — CustomerNotFoundException (CUSTOMER_NOT_FOUND), ConflictException (KYC_VERDICT_CONFLICT) | No retry — straight to the DLQ (user's answer). Status unchanged |
Malformed — unparseable JSON, ValidationException (KYC_VERDICT_UNKNOWN, KYC_VERDICT_REQUIRED, CUSTOMER_ID_REQUIRED) | No retry — straight to the DLQ |
| Retries exhausted | DLQ |
Not retryable: DomainException (the whole family) and the conversion failures the handler already classifies as fatal.
| Property | Value |
|---|---|
| DLQ topic | bank-app.customer.kyc-verification-completed.dlq — <topic>.dlq; NewTopic, 3 partitions (the recoverer keeps the source partition), replicas ${KAFKA_TOPIC_REPLICAS:1} |
| DLQ headers | the recoverer's default exception headers (class, message, stack) — the domain errorCode is in the message |
| Monitoring | every record sent to the DLQ: one WARN log line with eventId, customerId, the exception's errorCode (or class), and a Micrometer counter kyc.verdict.dead.lettered tagged reason (errorCode, or TRANSIENT_EXHAUSTED, or MALFORMED). This is the "monitored, not archived" of the rule |
## 5 · Configuration
Values decided for this project. Mandatory ones come from the rule — @.claude/rules/messaging.md § Configuration. Producer properties of UC-004 unchanged; the DLQ recoverer reuses Boot's KafkaTemplate, whose StringSerializer value serializer re-publishes the original JSON text untouched.
| Property | Value | Why |
|---|---|---|
spring.kafka.consumer.group-id | bank-app.kyc-verdict | One stable id per logical consumer |
spring.kafka.consumer.auto-offset-reset | earliest | A lost verdict leaves a customer KYC_IN_PROGRESS forever; reprocessing an old one is a no-op (§ 3) |
spring.kafka.consumer.key-deserializer / value-deserializer | org.apache.kafka.common.serialization.StringDeserializer both | JSON text on the wire |
spring.kafka.consumer.enable-auto-commit | false | Offsets commit through the ack only |
spring.kafka.listener.ack-mode | manual | Offset commits only after record() returns |
spring.kafka.listener.auto-startup | true (main) · false in application-test.yml | The test profile starts no broker (UC-004); a listener container at context start would poll a broker that is not there. The Kafka IT that exercises the listener turns it back on |
spring.kafka.bootstrap-servers | REUSE ${KAFKA_BOOTSTRAP_SERVERS:localhost:${KAFKA_EXTERNAL_PORT:29092}} | Already host-first, UC-004 |
app.kafka.topics.kyc-verification-completed.partitions / .replicas | 3 / ${KAFKA_TOPIC_REPLICAS:1} | Same binding shape as the request topic |
Classes (all package-private unless noted):
| Class | Package | Role |
|---|---|---|
KafkaListenerConfig | infrastructure/messaging/listener/ | shared listener wiring: the single StringJacksonJsonMessageConverter bean, the DefaultErrorHandler (backoff, not-retryable DomainException) with the DeadLetterPublishingRecoverer and the DLQ log + counter |
KycVerificationCompletedTopicProperties | infrastructure/messaging/kycverificationcompleted/ | binds app.kafka.topics.kyc-verification-completed.* |
KycVerificationCompletedTopicConfig | infrastructure/messaging/kycverificationcompleted/ | NewTopic for the topic and its DLQ; holds the topic-name constant the listener uses |
KycVerificationCompletedPayload | infrastructure/messaging/kycverificationcompleted/ | adapter-local record, § 2 |
KycVerificationCompletedListener | infrastructure/messaging/kycverificationcompleted/ | § 3 |
… cortado aqui, o resto está no arquivo
## 6 · Schema requirements
none — idempotency by state comparison (§ 3), no dedupe table; no outbox (no publication). The listener's update of customers.status uses the existing column through CustomerRepository.
## 7 · Declared dependencies
none — spring-boot-starter-kafka and testcontainers-kafka are already in pom.xml (UC-004); Micrometer arrives with the existing Actuator starter.
## 8 · Personal data
none — eventId and customerId are internal surrogate ids, verdict is a two-value outcome; no field matches @.claude/rules/logging.md § Masking candidates by type or name. Nothing personal crosses the boundary in either direction in this case. The DLQ copy carries the same three fields.
## 9 · Deferred
none
## Compose services
| Service | Image | Decision | Detail |
|---|---|---|---|
kafka | apache/kafka:<tag> — tag aligned with the Testcontainers Kafka image in KafkaContainerConfiguration | materialize | added by /new-feature-implement (user's answer). Single-node KRaft, EXTERNAL listener on ${KAFKA_EXTERNAL_PORT:-29092}, the app service gets KAFKA_BOOTSTRAP_SERVERS with the internal listener. Also closes UC-004's pending row |
## Design patterns
none — no force in the spec, no symptom on disk. This is the project's first listener; there is no repeated listener skeleton to factor (KafkaOutboxEventSender is a producer). The verdict mapping is a two-case translation, not a Strategy.
## Impact on approved use cases
| Approved case | Change | Why |
|---|---|---|
UC-004-initiate-kyc-verification | application.yml gains spring.kafka.consumer.* and spring.kafka.listener.*; application-test.yml gains spring.kafka.listener.auto-startup: false; the kafka compose service UC-004 left pending is materialized | first consumer in the project; same broker |
## Implementation order
KafkaListenerConfig(converter, error handler, recoverer)KycVerificationCompletedTopicProperties,KycVerificationCompletedTopicConfigKycVerificationCompletedPayload+KycVerificationCompletedListener- Properties in
application.ymlandapplication-test.yml - Integration test against a real broker (Testcontainers) —
40-testes.md
O que lê, escreve e recusa
O contrato da própria skill, do SKILL.md, no texto original.
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: the broker service belongs to docker-architect, and a write to the compose file 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, or without its Events block naming external delivery), @.claude/rules/messaging.md, @.claude/rules/architecture-ddd.md (Adapters section), @.claude/rules/naming.md, @.claude/rules/error-handling.md, @.claude/rules/lombok.md, the active blueprint's packages.map, and this skill's references/springwolf-reference.md.
Writes
docs/use-cases/UC-NNN-<slug>/25-messaging.md. Nothing else.
Does not write Java code
The publisher, listener, and payload classes come from the executor agent — the AsyncAPI annotations on them included; this skill only fixes the rows of § 1 they are written from. It never writes or edits the committed snapshot: that file is the executor's, from the document the project serves.
Does not edit `docker-compose.yml`, and does not invoke `docker-architect` either
When step 9 finds no kafka service, 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 model the dedupe table, nor the outbox table
When step 5a or step 4a finds none, it names the need as a row of § 6 for persistence-architect to pick up — this skill doesn't design schema. That block is the contract between the two: this skill runs before persistence-architect in /new-feature, so § 6 is input to that skill's first pass, not a correction to a partial already written. Form B's relay reads that state through the application-layer OutboxRelayGateway, never through the persistence adapter's entity or repository (@.claude/rules/architecture-ddd.md § Adapters).
Owns the publication form
(after-commit vs. outbox + relay), from step 3's first axis. domain-modeling records whether the event tolerates being lost — the Durability column of 10-domain.md § Events; this skill turns that property into a transport decision. A form chosen upstream of the Durability column is a divergence to report, not to adopt silently.
Does not decide
the use case boundary (00-use-case.md), whether an event exists or its payload's business meaning (10-domain.md, domain-modeling's call), the transport for synchronous HTTP (30-rest.md), or the tests (40-tests.md). Doesn't touch .claude/rules/**.
Does not cover in-process Spring events
(ApplicationEventPublisher/@EventListener) — see § Boundary with neighboring skills. No symptom for it yet in this repo.
Does not collide with `domain-modeling`
: that one declares the event and the outbound port, this one says how the port is served over Kafka. The event's payload meaning belongs to the other; if it needs to change, report the divergence instead of rewriting it.
Fonte: SKILL.md § Contract, no nerviz e4cae27.
O que o guard faz com ela
design é a classe da skill. O ArchHook abre uma fase quando a skill é chamada e confere cada escrita contra o território da classe. O texto à direita é o do próprio hook.
write_allow da classe design
- ✓
docs/use-cases/UC-*/** - ✓
docs/use-cases/BACKLOG.md src/**: migrations e classes são do executordocker-compose.yml: arquivo de outra classecaso de uso já aprovado: as specs ficam congeladas
A recusa sai com código 2 e a mensagem volta para o modelo.
PreToolUse Skill(messaging-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/25-mensageria.md) guard write exit 0 PostToolUse audit file exit 0
PreToolUse Write(docker-compose.yml) guard write exit 2 ❌ `messaging-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/25-mensageria.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.
Onde ela fica no trilho
Cada cartão é um passo do /new-feature; a seta leva o parcial que um passo entrega ao seguinte. Passos tracejados só rodam quando o caso de uso precisa.
/new-feature orchestrator · opus/new-feature-implement Quem lê o parcial depoisDepois da aprovação, o /new-feature-implement faz o executor construir o código. A spec é consolidada por referência e vale onde ela e um parcial divergem; o parcial é lido, por seção, para o detalhe.