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

Architecture
skill · class design · opus

messaging-architect

Designs the Kafka adapters: topic, partition key, delivery, consumer idempotency, retry and DLQ.

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

25-mensageria.md, block by block

A real partial: UC-005-record-kyc-verdict, 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-005-record-kyc-verdict/25-mensageria.md

UC-005 · Messaging

Partial of docs/use-cases/UC-005-record-kyc-verdict/. Owner: messaging-architect. Inherits the incoming message from 10-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.
FieldValue
Mother spec00-caso-de-uso.md
Domain partial10-dominio.md
EventKycVerificationCompleted — the KYC verdict, consumed; published by the KYC application on a contract this project defines
BrokerKafka
Divergences from 10-dominio.md / 00-caso-de-uso.md1. 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

PropertyValueWhy
Topicbank-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 keycustomerId (string form of the UUID) — a contract obligation of the publisherEvery 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 / replicas3 / ${KAFKA_TOPIC_REPLICAS:1} — same as the request topicDeclared as a NewTopic here because this project owns the contract; production topic provisioning follows the same values
SerializationJSON text — StringDeserializerNo build-time contract validation asked for by either side — rule default; no schema registry
Converted to JSON bythe KYC application (outside this project)—
Converted from JSON byone StringJacksonJsonMessageConverter bean, into the listener's typed parameterOne bean for every listener; the rule's wire contract
DeliveryAt-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.

FieldJSON typeRequiredMeaning
eventIdstring, UUID✅the message's own identity — logged and carried to the DLQ for tracing; not the dedupe key (§ 3)
customerIdstring, UUID✅the customerId received in bank-app.customer.kyc-verification-requested
verdictstring✅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 UCListenerGroup idDedupe key
RecordKycVerdictUseCaseinfrastructure/messaging/kycverificationcompleted/KycVerificationCompletedListenerbank-app.kyc-verdictnone — state comparison (user's answer)

Listener, package-private, per templates/KafkaConsumerAdapter.java.example minus the dedupe store:

  1. receives KycVerificationCompletedPayload (converted by the shared converter; unparseable
  2. maps verdict: "APPROVED" → KycVerdict.APPROVED, "REJECT" → KycVerdict.REJECT,
  3. recordKycVerdict.record(new RecordKycVerdictCommand(payload.customerId(), verdict));
  4. 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

… cut here, the rest is in the file

## 4 · Retry and DLQ

One DefaultErrorHandler + DeadLetterPublishingRecoverer for the listener container factory, in the shared listener wiring (§ 5).

FailureHandling
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 exhaustedDLQ

Not retryable: DomainException (the whole family) and the conversion failures the handler already classifies as fatal.

PropertyValue
DLQ topicbank-app.customer.kyc-verification-completed.dlq — <topic>.dlq; NewTopic, 3 partitions (the recoverer keeps the source partition), replicas ${KAFKA_TOPIC_REPLICAS:1}
DLQ headersthe recoverer's default exception headers (class, message, stack) — the domain errorCode is in the message
Monitoringevery 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.

PropertyValueWhy
spring.kafka.consumer.group-idbank-app.kyc-verdictOne stable id per logical consumer
spring.kafka.consumer.auto-offset-resetearliestA lost verdict leaves a customer KYC_IN_PROGRESS forever; reprocessing an old one is a no-op (§ 3)
spring.kafka.consumer.key-deserializer / value-deserializerorg.apache.kafka.common.serialization.StringDeserializer bothJSON text on the wire
spring.kafka.consumer.enable-auto-commitfalseOffsets commit through the ack only
spring.kafka.listener.ack-modemanualOffset commits only after record() returns
spring.kafka.listener.auto-startuptrue (main) · false in application-test.ymlThe 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-serversREUSE ${KAFKA_BOOTSTRAP_SERVERS:localhost:${KAFKA_EXTERNAL_PORT:29092}}Already host-first, UC-004
app.kafka.topics.kyc-verification-completed.partitions / .replicas3 / ${KAFKA_TOPIC_REPLICAS:1}Same binding shape as the request topic

Classes (all package-private unless noted):

ClassPackageRole
KafkaListenerConfiginfrastructure/messaging/listener/shared listener wiring: the single StringJacksonJsonMessageConverter bean, the DefaultErrorHandler (backoff, not-retryable DomainException) with the DeadLetterPublishingRecoverer and the DLQ log + counter
KycVerificationCompletedTopicPropertiesinfrastructure/messaging/kycverificationcompleted/binds app.kafka.topics.kyc-verification-completed.*
KycVerificationCompletedTopicConfiginfrastructure/messaging/kycverificationcompleted/NewTopic for the topic and its DLQ; holds the topic-name constant the listener uses
KycVerificationCompletedPayloadinfrastructure/messaging/kycverificationcompleted/adapter-local record, § 2
KycVerificationCompletedListenerinfrastructure/messaging/kycverificationcompleted/§ 3

… cut here, the rest is in the file

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

ServiceImageDecisionDetail
kafkaapache/kafka:<tag> — tag aligned with the Testcontainers Kafka image in KafkaContainerConfigurationmaterializeadded 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 caseChangeWhy
UC-004-initiate-kyc-verificationapplication.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 materializedfirst consumer in the project; same broker

## Implementation order

  1. KafkaListenerConfig (converter, error handler, recoverer)
  2. KycVerificationCompletedTopicProperties, KycVerificationCompletedTopicConfig
  3. KycVerificationCompletedPayload + KycVerificationCompletedListener
  4. Properties in application.yml and application-test.yml
  5. Integration test against a real broker (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: 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.

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(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
§ 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.

25-mensageria.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