---
name: messaging-architect
description: >
  Designs the Kafka producer/consumer adapter for an already-modeled domain event —
  topic, partition key, serialization, consumer group, delivery semantics, retries and
  DLQ — into the `25-mensageria.md` partial. Use when the request involves publishing a
  domain event to Kafka, consuming a Kafka topic, designing a producer or consumer
  adapter, deciding topic/partition/serialization, or wiring retry/DLQ for a message
  listener. Piece of the `/new-feature` pipeline: requires `10-dominio.md` in the given
  folder with an Events block that names external delivery, and stops without it.
argument-hint: "[path of the UC-NNN-<slug> folder]"
allowed-tools: Read, Write, Glob, Grep, Bash, AskUserQuestion
---

## Available specs

!`find "${CLAUDE_PROJECT_DIR:-.}/docs/use-cases" -mindepth 1 -maxdepth 1 -type d -name 'UC-*' 2>/dev/null | sort`

Empty above → none yet, run `/use-case-design` first. (`find`, not an `ls` glob: under zsh an unmatched glob
aborts the command before any fallback runs.)

## Target

$ARGUMENTS

---

# Messaging Architect

Designs **how a domain event leaves the process and how another one gets consumed**: topic,
key, serialization, delivery semantics, retry and DLQ, and the two adapters that carry it.
What `domain-modeling` already named as an event with a consumer, this skill gives a broker
transport to.

**Entry rule: without `10-dominio.md` naming an event for external delivery, there's nothing
to transport.** This skill reads `docs/use-cases/UC-NNN-<slug>/00-caso-de-uso.md` and
`10-dominio.md` and treats them as a contract. Without the domain partial, it stops and tells
you to run `/domain-modeling`. If the domain partial's Events block is "none," or names only
a sibling use case in the same process, it also stops: an in-process call has no broker to
design — designing Kafka config for it would be scope no one asked for.

**Exit rule: it doesn't write code.** It emits `25-mensageria.md`. The Java classes come from
the executor agent, which reads the partial and the `templates/` exemplars.

**Rule rule: the rules don't live here.** Idempotent consumer, `acks=all`, topic naming,
retry/DLQ, and the rest are `@.claude/rules/messaging.md`. This skill applies them and cites
them; it doesn't reproduce them.

## How it's invoked

Two paths, and both matter: `/messaging-architect` by hand, or chained by `/new-feature` once
that orchestrator's pipeline reaches this point. That's why it does **not** carry
`disable-model-invocation` — a skill the model can't see is a skill the orchestrator can't
call.

The guard against out-of-order or unnecessary firing isn't the frontmatter: it's the **entry
rule** above. Recorded in `@.claude/decisions/0007-pipeline-skills-invocation.md`.

## Why this is a skill and not a subagent

Form 1, motivated by axis 2 (chained by `/new-feature`, also invocable by hand) and axis 9
(`domain-modeling` already owns the event; this skill only owns its transport). The closest
rejected form was a fourth option in the same decision — a dedicated "domain events" skill —
which failed invariant 2: `domain-modeling` already asks whether the aggregate emits an event
and already writes the Events block of `10-dominio.md`. A subagent was never viable either: it
fails the § 5 counter-test in `claude-code-architect-designer`'s decision matrix on all three
points — the interview over topic/partitioning/DLQ is the heart of the task, the reference
content fits in `templates/`, and the partial it produces is short.

## Boundary with neighboring skills

The division is by **moment and artifact**, not technology:

| Piece | When it acts | What it produces |
|---|---|---|
| `use-case-design` | Before the domain exists | `00-caso-de-uso.md` — boundary and canonical names |
| `domain-modeling` | After the mother spec | `10-dominio.md` — aggregate, invariants, ports, **and the event itself** |
| `rest-api-architect` | After the domain partial | `30-rest.md` — transport, no schema, plus the schema requirements it creates |
| **this skill** | After the domain partial, only when an event needs external delivery — and **before** `persistence-architect` | `25-mensageria.md`, § 6 included |
| `persistence-architect` | After this skill | `20-persistencia.md` + migration — builds what § 6 asked for |
| `test-architect` | After all of them | `40-testes.md` |

`domain-modeling` decides **whether** an event exists and **who consumes it**. This skill
never redefines the event or its payload's business meaning — it only decides how it travels.
If the consuming use case is a sibling in the same process, there is no broker to design and
this skill doesn't run at all (see Entry rule).

**Out of scope, on purpose: in-process dispatch.** Spring's own `ApplicationEventPublisher`/
`@EventListener` is a same-JVM transport for the same kind of domain event this skill handles
for Kafka. No use case in this repo has needed it yet, so it isn't built — anti-pattern 9
(anticipation) in `claude-code-architect-designer`'s decision matrix. If one does, it's one
more transport option inside this skill (another `templates/` pair and a section in
`@.claude/rules/messaging.md`), not a new skill: the boundary above already put event
*definition* in `domain-modeling` and event *transport* here, and in-process dispatch is
still transport.

## Procedure

1. **Read the specs.** `00-caso-de-uso.md` and `10-dominio.md` from the folder in
   the target above. Without the second, stop. Extract: the event's name, payload fields, the
   consuming use case, and whether that consumer is external (another service, another
   deployable) — that's what makes this skill apply at all.

2. **Survey what already exists.** A topic or consumer group already wired gets reused, not
   duplicated. **The bounded context comes from here too — read it, never ask.** The topic
   name's first segment (`@.claude/rules/messaging.md` § Topics and serialization) is a
   project fact with the same standing as the base package: it is declared once, in the
   project's root `CLAUDE.md`, and every topic of every use case shares it.

   ```bash
   grep -i "bounded context" CLAUDE.md
   grep -rhoE "^ *(topic|topics?\.[a-z-]+): *[a-z0-9.-]+" src/main/resources/ 2>/dev/null
   ```

   Not declared **and** no topic exists yet → stop and say which line is missing from the
   root `CLAUDE.md`, rather than choosing a prefix inside this use case. A prefix decided
   per use case is how one system ends up with two namespaces: the next case, designed in
   another session, has no reason to pick the same one (lessons-learned-012 § 5).

   ```bash
   grep -rln "@KafkaListener\|KafkaTemplate" --include='*.java' src/ 2>/dev/null
   grep -rn "group-id\|bootstrap-servers" src/main/resources/ 2>/dev/null
   grep -rl "processed_events\|ProcessedEventStore" --include='*.java' src/ 2>/dev/null
   ```

3. **Interview — only what the specs don't fix.** `AskUserQuestion`, at most 4 questions per
   call and **never fewer than 2 real options per question**: one option isn't a question —
   decide it and record the decision in the partial, since the runtime rejects the whole
   batch over a single one (`@CLAUDE.md` § Known pitfalls). Don't re-ask what
   `00-caso-de-uso.md` or `10-dominio.md` already answered.

   | Axis | Decides |
   |---|---|
   | **Publication timing** — must the event survive a broker outage between the commit and the publish? | Form A (publish after commit) or Form B (transactional outbox + relay), per `@.claude/rules/messaging.md` § Publication timing. Read the `Durability` column of `10-dominio.md` § Events **first**: when it already answers, record the form and don't ask again |
   | Partition key candidate (which field must stay ordered) | Whether the aggregate id is enough, or a composite key is needed |
   | Consumer group id, new or existing | Reuse vs. a fresh subscription with its own offset |
   | `auto-offset-reset` tolerance (losing vs. reprocessing on redeploy) | `earliest` or `latest` |
   | Ordering requirement across different aggregates | Whether one topic is enough or the event needs to fan out differently |
   | Existing `processed_events`-style dedupe table in this project | Reuse vs. ask `persistence-architect` to model one |
   | **Serialization** — do both sides need a contract they can validate at build time, and is there a second team on the other end asking for it? | JSON (the rule's default) or a schema registry. **Ask only with the cost in the question**, see below |
   | **Personal data in the payload** — does the event carry a field that identifies a natural person, and if so, what is the minimum the receiver needs? | The payload's field list: full value, reduced form (id, hash, last digits), or a reference the receiver resolves. `@.claude/rules/security.md` § In transit. **Always asked when a candidate field exists**, see below |
   | **Who guarantees dedupe**, when the consumer is not in this project | Whether at-least-once is actually absorbed anywhere. `@.claude/rules/messaging.md` § Delivery semantics |

   Every axis but the first is consumer-side. A use case that only **produces** still has the
   publication timing to settle, so "no axis applies, no `AskUserQuestion` this pass" is never
   the right conclusion for a producing use case: either the domain partial's `Durability`
   column already fixed the form and the partial records which, or this step asks.

   **The serialization axis, and the shape its question must have.**
   `@.claude/rules/messaging.md` § Topics and serialization fixes JSON as the default and
   makes a schema registry a deliberate upgrade — "not assumed until a use case actually
   needs it". So:

   - **No trigger written in `00-caso-de-uso.md` or `10-dominio.md` → don't ask.** Record
     `JSON` in the partial together with the rule's condition, in one line, and move on. A
     rule default is not a question, and "the consumer is another team" is not by itself the
     trigger — the trigger is that both sides need the contract validated **at build time**.
   - **Trigger written → ask, with the cost inside the question, not after it.** The upgrade
     is not one line of configuration; the option text names every place it lands:

     | The upgrade buys | The upgrade costs |
     |---|---|
     | A contract both sides validate at build time, and compatibility checked by the registry | A `schema-registry` service in `docker-compose.yml` · three dependencies (`org.apache.avro:avro`, `io.confluent:kafka-avro-serializer`, `avro-maven-plugin`) · the `confluent` repository, because the serializer is **not on Maven Central** · a new source directory (`src/main/avro/*.avsc`) and generated code · a JaCoCo exclusion for that generated code · `mock://` in the tests, with subject compatibility not really tested |

   A user who picks the upgrade after reading that row picked it knowingly. One who is
   offered "JSON (recommended)" versus "Avro + schema registry" with no cost attached is
   picking a name, and that is how the six items above entered a project in one answer.

   **The personal-data axis, and why it is asked and not inferred.** Run the candidate test of
   `@.claude/rules/security.md` § What counts as personal data over the payload's fields —
   against the types of `@.claude/rules/value-objects.md` § Catalog, never against field names
   alone: the field that leaked a national identifier in a real run was called
   `securityNumber`, and masking it in the logs (which `@.claude/rules/logging.md` did require,
   and which was applied) changed nothing about the copy serialized into the outbox column and
   the copy published on the topic.

   - **Candidate field present → ask, with the three legitimate answers as the options:** the
     full value, a reduced form, or a reference the receiver resolves under its own
     authorization. "The receiver's whole purpose is that value" is a correct answer — an
     identity-verification service does need the identifier it verifies — and it is the answer
     that most needs recording, because it is the one nobody revisits when the receiver
     changes.
   - **No candidate field → one line in the partial saying so**, not silence. `none` is an
     answer; an unasked question is what shipped a CPF in clear across a team boundary and into
     a `jsonb` column retained for seven days.
   - Whatever the answer, it lands in § 2 of the partial next to the payload's field list, with
     the receiver named. A decision recorded elsewhere is a decision the next run re-decides.

   **The dedupe-owner axis.** At-least-once delivery is only safe because something absorbs
   the duplicate, and `@.claude/rules/messaging.md` § Delivery semantics puts that on the
   consumer. When the consumer is **in this project**, step 5a is what makes it true. When it
   is **not** — another team, another company — the guarantee is being delegated across an
   organizational boundary, and this skill's job is to say so out loud rather than let a
   Javadoc sentence imply it is handled:

   - name the receiver, and state whether its idempotency is **contracted** (written down
     somewhere both sides can point at), **assumed**, or **unknown**;
   - when the answer is "assumed" or "unknown", that is a **finding for the final report**, not
     a line buried in § 3 — the run ends naming it, so the person who asked for the feature
     decides whether to accept it;
   - the event's own identity is what makes dedupe possible at all, so the payload carries it
     regardless of who dedupes: the same `event_id` the outbox row is keyed on.

4. **Design the producer adapter.** Implements the outbound port `domain-modeling` already
   declared — never a new interface. Payload is the minimum the consumer needs, mapped
   explicitly from the domain event; the event itself never serializes directly. The
   publication form from step 3 decides the shape, and only the shape — the port's signature
   is the same either way:

   | Form | What implements the port | Extra pieces | Exemplar |
   |---|---|---|---|
   | A — publish after commit (default) | The adapter sends to the broker directly | none | `templates/KafkaProducerAdapter.java.example` |
   | B — transactional outbox + relay | The adapter writes an outbox row inside the caller's transaction | relay component, `OutboxRelayGateway` port, shared `outbox_events` table | `templates/OutboxRelayPublisher.java.example` |

   **4a. Form B's outbox, when step 2 found none. Declare the need, never the columns.**
   The outbox belongs to `persistence-architect` — the table, its columns, its indexes, its
   migration, the claim query, and the pacing those columns encode. This skill's § 6 row
   says three things and stops:

   1. that the case needs the project's shared `outbox_events` (one for the whole project,
      never one per event or per aggregate — same nature as `idempotency_keys`);
   2. the **delivery guarantee** the relay has to honour: at-least-once, how many attempts
      before giving up, and the DLQ destination once it does;
   3. the addressee — that skill's **step 4b**, which models it from
      `persistence-architect/templates/OutboxEventTable.sql.example`.

   **Never list column names here.** Inventing `next_attempt_at`, `failure_reason`,
   `created_at` as "requirements" produced three guaranteed divergences in a real run, and
   one of them silently changed behaviour: the exemplar has no `next_attempt_at`, so
   per-row exponential backoff became a fixed 2s repoll, decided by a precedence rule
   written for table shape. The column set has one owner and one canonical source, and
   neither is this file.

   The guarantee has a second half, and it is the one a real run left unstated: **at-least-once
   only holds if something absorbs the duplicate.** § 6's row names the attempts and the DLQ;
   § 3 names who dedupes (step 3's axis). The claim query itself — locked, leased, or explicitly
   single-instance — is `persistence-architect`'s decision and belongs in `20-persistencia.md`;
   what this skill must not do is accept a method *named* like a claim as proof that a claim
   happens. A gateway method called `claimPending` implemented as an unlocked `SELECT` is the
   shape that shipped, and two relay instances then publish the whole batch twice, every pass.

   What this skill keeps is the guarantee, and it keeps it as a **requirement**, not a
   suggestion: a shape that cannot satisfy the declared backoff and attempt count is a
   divergence that stops the pipeline (`/new-feature`'s consolidation, § Precedence), not
   something either skill resolves alone. The port stays declared here too — the pair in
   step 4b implements `OutboxRelayGateway`, it doesn't re-declare it.

   The receiving end models the table once from
   `persistence-architect/templates/OutboxEventTable.sql.example` and
   `templates/OutboxEventStore.java.example` — the mirror of its own step 4a for
   `idempotency_keys`. Name the step in the partial, so the requirement has an addressee
   instead of a hope.

5. **Design the consumer adapter.** Translates the inbound payload into a call on the target
   use case's inbound port. Dedupe on the event's own identity before calling it; manual
   acknowledgment, offset commits only after the use case returns.
   Shape: `templates/KafkaConsumerAdapter.java.example`.

   **5a. Dedupe table, when step 2 found none yet.** Not per-consumer: one table
   (`processed_events` or equivalent), shared by every listener in the project, modeled once.
   If missing, add it as a second row of **§ 6 · Schema requirements** and flag that
   `persistence-architect` needs to model it — this skill doesn't design tables,
   `20-persistencia.md` does.

6. **Fix retry and DLQ.** Backoff attempts and the DLQ topic name, per
   `@.claude/rules/messaging.md` § Retry and DLQ. A business rejection (typed domain
   exception from the consumed use case) skips retry and goes straight to the DLQ.

7. **Fix the configuration.** Bootstrap servers, producer `acks`/idempotence, consumer group
   and offset reset, ack mode — from `templates/application-kafka.yml.example`. Mandatory
   values are the rule; what this skill decides is the per-use-case sizing (group id, offset
   reset tolerance) from step 3.

   **This skill owns the `bootstrap-servers` default, and it is host-first.** The application
   started with `./mvnw spring-boot:run` runs on the host, so the default has to be an address
   the host resolves — `${KAFKA_BOOTSTRAP_SERVERS:localhost:<external port>}`, the port
   `docker-architect`'s service block publishes for its EXTERNAL listener. The compose `app`
   service overrides the variable with the internal address. Defaulting to the internal one
   instead is `UnknownHostException: kafka` on the first host-side publish, with no path to
   recovery and no check that sees it — lessons-learned-013 § 11.

   **Declare the dependencies this transport needs, in § 7 of the partial.** A `KafkaTemplate`
   needs `org.springframework.boot:spring-boot-starter-kafka`: Spring Boot 4 moved Kafka
   autoconfiguration out of `spring-boot-autoconfigure`, so a plain `spring-kafka` library
   dependency wires nothing at all and every property above stays inert. Version column empty
   whenever the Boot parent manages it — never a version from memory (`@CLAUDE.md`
   invariant 8). The executor may write `pom.xml` for exactly the dependencies this block
   names; a requirement missing here is a run that stops at a missing type with nobody
   entitled to add it.

8. **Write the partial.** `docs/use-cases/UC-NNN-<slug>/25-mensageria.md`, from
   `templates/messaging-spec.md.example`. Seven blocks, all mandatory — § 6 and § 7 included,
   each written as `none` when there is nothing to ask for.

9. **Check Kafka has a container.** List the keys inside the `services:` block and look
   for `kafka` — never `grep -A2 "^services:"`, which reads two lines and then reports the
   children of `volumes:` as services (lessons-learned-012 § 12):

   ```bash
   awk '/^services:[[:space:]]*$/{s=1;next} /^[^[:space:]#]/{s=0} s&&/^  [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/{sub(/[[:space:]]*#.*$/,"");print}' docker-compose.yml
   ```

   Missing → **record it** in the partial's § 6 as a pending service: the broker, the image
   tag the spec assumes, and the one-line `/docker-architect` invocation that materializes it.
   Do **not** invoke `docker-architect` and do not edit `docker-compose.yml`: that skill is
   the file's single owner, it is class `build`, and `ArchHook.java guard` refuses the call
   while a design run is open.

10. **Report and stop.** Path of the file written, **the content of § 6** (each schema
    requirement handed to `persistence-architect`, or "none"), **the content of § 7** (each
    declared dependency the executor has to add, or "none"), the pending broker service if
    step 9 found one (and the command that fixes it),
    **the personal-data answer** (which fields cross, in what form, to which receiver — or
    "none"), **and, when the consumer is not in this project, whether its idempotency is
    contracted, assumed or unknown** — the last two are findings the person who asked for the
    feature has to see, not partial content to be read later,
    and what's missing for the folder to be complete (`20-persistencia.md`,
    `40-testes.md`). Don't invoke anyone else — `persistence-architect` runs next in the
    pipeline and reads § 6 in its first pass.

## What the partial contains

Seven blocks. An empty block is written as "none" — deleting it hides a question nobody
asked.

| Block | Fixes | Form exemplar |
|---|---|---|
| Topic and delivery | Topic name, partition key, serialization, delivery semantics | `@.claude/rules/messaging.md` § Topics and serialization |
| Producer adapter | The port from `10-dominio.md`, **the publication form (A or B) and why**, the adapter, the payload shape, and — Form B only — that the case needs the shared outbox plus the delivery guarantee its relay must honour | `KafkaProducerAdapter.java.example` (A) · `OutboxRelayPublisher.java.example` (B) |
| Consumer adapter and idempotency | The consuming use case, the listener, the dedupe key and table | `KafkaConsumerAdapter.java.example` |
| Retry and DLQ | Backoff, DLQ topic, which failures skip retry | `@.claude/rules/messaging.md` § Retry and DLQ |
| Configuration | Group id, offset reset, ack mode, with the decided value and why | `application-kafka.yml.example` |
| Schema requirements | Every table this transport needs that the domain didn't model — the shared `outbox_events` under Form B (step 4a), the shared dedupe table (step 5a) — one row each, stating **which table and which guarantee**, never a column name and never DDL. The outbox's columns and pacing belong to `20-persistencia.md`; a column named here becomes a divergence there. `none` when there are none | `@.claude/rules/messaging.md` § Publication timing · § Retry and DLQ |

The exemplars in `templates/` are **reference for form**, not files to copy. It's the
executor agent that reads them when generating code.

## Contract

**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-caso-de-uso.md` and `10-dominio.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`,
and the active blueprint's `packages.map`.

**Writes** `docs/use-cases/UC-NNN-<slug>/25-mensageria.md`. Nothing else.

**Does not write Java code.** The publisher, listener, and payload classes come from the
executor agent.

**Does not edit `docker-compose.yml`, and does not invoke `docker-architect` either.** When
step 9 finds no `kafka` service, it records the pending service in the partial's § 6 with the
`/docker-architect` command that creates it. 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-dominio.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-caso-de-uso.md`), whether an event exists or
its payload's business meaning (`10-dominio.md`, `domain-modeling`'s call), the transport for
synchronous HTTP (`30-rest.md`), or the tests (`40-testes.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.
