Saltar al contenido
Mis Guidelines
Guías
En esta guía (73 elementos)

Messaging

Messaging Guidelines

MANDATORY FOR ALL AI AGENTS: Every event publish MUST go through the transactional outbox pattern — never direct broker calls inline with business writes. Every consumer MUST be idempotent. Topology (exchanges, queues, topics, partitions) MUST be externalized — no @Bean Queue/Exchange/Binding/NewTopic in microservice code. Message envelopes MUST follow the canonical EventMessage<T> shape (Header + Body). Retries follow exponential backoff bounded by maxAttempts; permanent failures land in DLT/DLQ.

Project-agnostic: code samples use placeholder package com.example.platform, service name platform-service. Replace with your project’s actual values. The canonical reference implementation for the outbox pattern is codehuntersio-sdk-event-messaging — treat it as the source of truth for the JVM outbox shape and adapt configuration as needed.

Companion guides:

  • hexagonal-architecture.md — ports/adapters for publishers and consumers, outbox + chain of responsibility.
  • adr.md — capture broker choice (RabbitMQ vs Kafka vs SQS/SNS), topology, schema evolution policy.
  • security.md — message signing, in-transit encryption, mTLS to broker, never log payloads with PII.
  • observability.md — metrics + tracing propagation (W3C traceparent in headers).
  • liquibase.md — outbox + DLQ schema migrations follow expand/contract.
  • arch-doc-spec.md §Event Catalog — board-grade event inventory.
  • api-design.md §21 — webhook signing (related but distinct).

Companion commands/agents:

  • /arch-outbox → arch-outbox-verifier (audits outbox impl correctness).
  • /arch-saga → arch-saga-verifier (audits saga choreography/orchestration).
  • arch-outbox-pattern skill — canonical write-side pattern.
  • arch-saga-pattern skill — distributed transactions across services.
  • kafka-rabbitmq skill — broker-specific patterns.

Table of Contents

  1. Principles
  2. Broker Selection Matrix
  3. Canonical Event Envelope
  4. Transactional Outbox Pattern
  5. Claim-Based Dispatch
  6. Retry, Backoff & Dead-Letter
  7. Idempotent Consumers
  8. Schema Evolution & Versioning
  9. Topology Externalization
  10. RabbitMQ Specifics
  11. Kafka Specifics
  12. Choreography vs Orchestration
  13. Tracing Propagation
  14. Hexagonal Placement
  15. Testing
  16. Operational Metrics & Alerting
  17. Anti-patterns
  18. Pre-Merge Checklist

1. Principles

  • Reliability over freshness. Better to publish 200ms late atomically than instantly with data-loss risk.
  • At-least-once delivery is the default. Exactly-once is rare, expensive, and usually a consumer-idempotency problem dressed up.
  • The producer commits the business write AND the outbox row in one local transaction. No separate “send” call inline with the business change.
  • Brokers are dumb pipes. Business logic lives in services; topology lives in broker config; transformations live in adapters.
  • Schemas are contracts. Treat them with API-versioning rigor (see api-design.md §5).
  • Consumers MUST be idempotent. Duplicates always happen — partition rebalance, retry on network blip, restart.
  • Origin scoping: each microservice claims and dispatches only the outbox rows it produced. Cross-service dispatch is an anti-pattern.

2. Broker Selection Matrix

NeedChooseWhy
Cross-team domain events, broadcast (1→N), low throughput (< 10k msg/s)RabbitMQSimple topology, manual ack, mature ops, easy DLQ.
High throughput (>50k msg/s), event sourcing, replay, ordered partitioningKafkaLog-structured, partitioned, retention, consumer groups.
AWS-native, serverless, ≤256KB payloads, no replaySQS + SNSManaged, IAM-bound, deep AWS integration; SNS fan-out + SQS work-queue.
Cloud-agnostic managedGCP Pub/Sub / Azure Service BusEquivalent to SQS+SNS on respective clouds.
Internal RPC-like with backpressuregRPC (not messaging)If it’s synchronous request/reply, don’t use a broker.

Decision is an ADR. Don’t mix brokers in one bounded context without a documented reason.


3. Canonical Event Envelope

Every cross-service event uses the same envelope. Reference shape, language-agnostic:

EventMessage<T>
├── Header
│ ├── eventId // stable unique id of the physical event (UUID, generated in the factory)
│ ├── traceId // distributed tracing correlation
│ ├── version // envelope version, e.g. "1.0.0"
│ ├── schemaVersion // payload schema version, e.g. "1.0.0"
│ ├── origin // producing service (spring.application.name)
│ ├── timestamp // ISO-8601 instant the event was created
│ └── locale // optional, e.g. "es", "en-US"
└── Body<T>
├── aggregate // aggregate identifier (e.g., orderId, paymentId)
├── eventName // event name in past tense (OrderCreated, PaymentFailed)
├── eventType // EVENT | COMMAND
└── payload // T — typed business payload

3.1 Java reference

public class EventMessage<T> {
private Header header;
private Body<T> body;
public static class Header {
private String eventId; // stable unique id of the physical event (UUID), set in the factory
private String traceId;
private String version = "1.0.0";
private String schemaVersion = "1.0.0";
private String origin;
private String timestamp; // Instant.now().toString()
private String locale;
}
public static class Body<T> {
private String aggregate;
private String eventName;
private String eventType;
private T payload;
}
}

3.2 Construction via factory

EventMessage is always built via a factory that injects origin from spring.application.name. Caller never sets it manually — prevents copy-paste bugs across services.

@Component
public class OrderEventPublisher {
private final OutboxEventFactory factory;
private final OutboxRepositoryPort outbox;
public void publishOrderCreated(Order order, String traceId) {
var msg = factory.create(
"platform.notifications", // topic / exchange
traceId,
order.getId().toString(), // aggregate
"OrderCreated", // eventName
EventTypes.EVENT, // eventType
new OrderCreatedPayload(order.getId(), order.getAmount())
);
outbox.save(msg);
}
}

3.3 Field conventions

FieldConvention
eventNamePast tense, PascalCase: OrderCreated, PaymentFailed, UserRegistered. Never CreateOrder (that’s a command).
eventTypeEVENT (something happened) or COMMAND (something must happen).
aggregateThe aggregate root ID — drives idempotency + partitioning.
eventIdStable unique ID of the physical event (UUID). Generated in the factory alongside the outbox row. Survives retries/replays — use it for consumer dedup (§7.3).
traceIdPropagated from the inbound request that triggered the change. Never generated here.
schemaVersionBumped on breaking payload changes only (see §8).
originAuto-injected. Never set manually.
payloadTyped, immutable. JSON-serialized at adapter boundary.

4. Transactional Outbox Pattern

4.1 Why

Dual-write problem: writing to the DB AND the broker in two separate operations cannot be atomic — one may succeed, the other fail. Result: data corruption (DB committed, event lost) OR phantom events (event sent, DB rolled back).

Outbox solves this: write the business state AND a row in the outbox_message table in one local transaction. A separate dispatcher reads the outbox and publishes to the broker with at-least-once semantics. If the dispatcher fails, retry. If the broker drops the message, retry. The DB row is the source of truth.

4.2 Schema (relational reference)

CREATE TABLE outbox_message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
origin VARCHAR(255) NOT NULL, -- producing service (spring.application.name)
routing_key VARCHAR(255), -- broker routing key (nullable for fanout)
topic VARCHAR(255) NOT NULL, -- exchange/topic name
message_key VARCHAR(255), -- aggregate id (Kafka partition key)
payload TEXT NOT NULL, -- JSON-serialized EventMessage<T>
status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING | IN_FLIGHT | PUBLISHED | FAILED | ARCHIVED
attempts INT NOT NULL DEFAULT 0,
error TEXT,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
next_attempt_at TIMESTAMP,
published_at TIMESTAMP,
claimed_at TIMESTAMP, -- for stale-claim recovery
claimed_by VARCHAR(255) -- instance marker: appName@processId
);
CREATE INDEX idx_outbox_origin_status_occurred
ON outbox_message(origin, status, occurred_at ASC);
CREATE INDEX idx_outbox_origin_next_attempt
ON outbox_message(origin, status, next_attempt_at);
-- Partial index on stale-claim recovery (Postgres / SQL Server filtered index)
CREATE INDEX idx_outbox_claim_recovery
ON outbox_message(status, claimed_at)
WHERE status = 'IN_FLIGHT';

4.3 Hexagonal port contract

// application/port/outbound — port belongs to application, depends only on domain
public interface OutboxRepositoryPort {
<T> void save(OutboxMessage<T> message);
List<OutboxMessage<String>> claimPendingMessages(
String origin, int batchSize, String claimedBy, Duration claimTtl);
void update(OutboxMessage<String> message);
}

4.3.1 SDK starter (codehuntersio-sdk-event-messaging)

Reference implementation that ships every contract in this section as a Spring Boot auto-configuration. Use the starter; do NOT re-implement these primitives per service.

Auto-wired beans (each @ConditionalOnMissingBean — override any to customize):

BeanDefault impl
EventMessageFactoryStamps origin = spring.application.name
OutboxEventFactoryWraps EventMessage<T> → OutboxMessage<EventMessage<T>>
OutboxRepositoryPortOutboxJdbcAdapter (Postgres / SQL Server)
MessagePublisherPortRabbitMessagePublisherAdapter (override with a Kafka adapter to switch broker)
SendOutboxMessagesPortSendOutboxMessagesUseCase
OutboxScheduler@SchedulerLock-protected scheduled bean
LockProviderJdbcTemplateLockProvider (usingDbTime())
outboxJsonMapper (JsonMapper)Jackson 3.x, default modules

Properties (prefix outbox.):

outbox:
enabled: true # default true
batch-size: 50 # rows per dispatcher tick
max-attempts: 5 # then status=FAILED
task:
fixed-delay: 5000 # ms between ticks
lock-at-most: 60000
lock-at-least: 10000
lock-name: outbox-${spring.application.name} # per-service lock — DO NOT collapse to a global name
claim:
ttl-ms: 300000 # stale-claim recovery window (5 min)
backoff:
base-delay-ms: 30000 # 30s, doubled per retry
max-delay-ms: 3600000 # capped at 1h

Swapping RabbitMQ → Kafka. Provide a KafkaMessagePublisherAdapter implements MessagePublisherPort bean — the SDK skips its Rabbit default via @ConditionalOnMissingBean. Nothing else changes; the use case, the scheduler, the JDBC outbox stay identical.

Hard rule. The starter exposes ONLY OutboxRepositoryPort, OutboxEventFactory (and EventMessageFactory) to application code. Use cases inject those. Never inject MessagePublisherPort into a domain use case — it is the dispatcher’s port, not the producer’s.

4.4 Producer call site

@Service
@RequiredArgsConstructor
public class CreateOrderUseCase implements CreateOrderPort {
private final OrderRepository orderRepo;
private final OutboxRepositoryPort outbox;
private final OutboxEventFactory factory;
private final TransactionPort<Order> tx; // see hexagonal-architecture.md — Transaction port
@Override
public Order execute(CreateOrderCommand cmd) {
return tx.run(() -> {
Order order = Order.create(cmd);
orderRepo.save(order); // business write
outbox.save(factory.create( // outbox write — same TX
"platform.notifications",
cmd.traceId(),
order.id().toString(),
"OrderCreated",
EventTypes.EVENT,
OrderCreatedPayload.from(order)
));
return order;
});
}
}

Both writes commit together. No broker call here. The dispatcher publishes asynchronously.

4.5 Alternative: Debezium CDC

When you can’t change application code (legacy migration), use Debezium to read the DB transaction log directly and publish to Kafka. The outbox table still exists; Debezium becomes the dispatcher.

Tradeoffs:

    • No application-level dispatcher to maintain.
    • Stronger ordering guarantees per partition key.
  • − Operational complexity (Debezium connectors, schema registry, Kafka Connect).
  • − Coupling to DB-specific WAL format.

Default: application-level dispatcher (next section). Debezium when scale justifies it.


5. Claim-Based Dispatch

5.1 Why

Multiple instances of a microservice run for HA. Naive dispatcher: each instance polls the outbox and publishes — same row published N times.

ShedLock is not enough on its own: it serializes ticks but a tick that takes 10s + crash mid-publish leaves rows in ambiguous state.

Claim-based dispatch combines both:

  1. ShedLock (@SchedulerLock) — only one instance ticks at a time.
  2. Claim flow — that instance atomically transitions a batch of rows from PENDING → IN_FLIGHT, stamping claimed_at and claimed_by = appName@pid. If the instance crashes mid-publish, a TTL (default 5 min) lets another instance re-claim the stuck rows.

5.2 Atomic claim SQL (Postgres example)

WITH eligible AS (
SELECT id FROM outbox_message
WHERE origin = :origin
AND (
(status = 'PENDING' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()))
OR (status = 'IN_FLIGHT' AND claimed_at IS NOT NULL AND claimed_at <= NOW() - :claim_ttl)
)
ORDER BY occurred_at ASC
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE outbox_message m
SET status = 'IN_FLIGHT', claimed_at = NOW(), claimed_by = :claimed_by
FROM eligible e
WHERE m.id = e.id
RETURNING m.*;

SQL Server equivalent uses OFFSET 0 ROWS FETCH NEXT :limit ROWS ONLY; its atomic claim-and-return analog of RETURNING is UPDATE … WITH (READPAST, UPDLOCK) … OUTPUT inserted.*, which locks and skips contended rows while returning the claimed rows in a single statement. See codehuntersio-sdk-event-messaging OutboxJdbcAdapter for the canonical SQL Server pattern.

5.3 Dispatcher loop

public void execute() {
var claimed = outbox.claimPendingMessages(origin, batchSize, instanceId, claimTtl);
for (var msg : claimed) {
try {
publish(msg); // → broker
markPublished(msg); // status=PUBLISHED, publishedAt=now, clear claim
} catch (Exception e) {
markRetryOrFailed(msg, e); // see §6
}
outbox.update(msg);
}
}

5.4 ShedLock scheduling

@Scheduled(fixedDelayString = "${outbox.task.fixed-delay:5000}")
@SchedulerLock(
name = "${outbox.task.lock-name:outbox-${spring.application.name:unknown}}",
lockAtMostFor = "${outbox.task.lock-at-most:60000}",
lockAtLeastFor = "${outbox.task.lock-at-least:10000}"
)
public void tick() {
sendOutboxMessages.execute();
}

Lock name MUST include the service name so each microservice runs independently. Sharing a global lock across services serializes the whole platform.

5.5 Instance ID

String instanceId = applicationName + "@" + ManagementFactory.getRuntimeMXBean().getName();
// e.g. "platform-service@12345@host42"

Used in claimed_by so post-mortem can pinpoint which pod stalled.


6. Retry, Backoff & Dead-Letter

6.1 Status transitions

PENDING ─claim──> IN_FLIGHT ─publish─┬─ ok ──> PUBLISHED
└─ err ─┬─ attempts<max ──> PENDING (next_attempt_at = now+backoff)
└─ attempts>=max ─> FAILED ─archive/DLT──> ARCHIVED

6.2 Exponential backoff

long computeBackoffMs(int attempts) {
int shift = Math.min(attempts - 1, 30);
long delay = baseBackoffMs << shift; // 30s, 60s, 120s, 240s, ...
return (delay <= 0 || delay > maxBackoffMs) ? maxBackoffMs : delay;
}

Defaults: base=30s, max=1h, maxAttempts=5. Tune per criticality.

6.3 Permanent failures (FAILED)

When attempts >= maxAttempts, status flips to FAILED and the row sits in the table. Options:

StrategyProsCons
Manual triage via dashboard querySimple, no extra infraRequires ops attention
Auto-publish to DLQ exchange/topicReplay/redrive tool can re-enqueueDLQ schema decoupling
Both: keep in outbox + publish to DLQAudit + redriveSlightly more code

Recommended: both. Outbox stays as the auditable record; DLQ is the operational handle.

SDK gap. codehuntersio-sdk-event-messaging deliberately stops at FAILED — it does NOT publish to a DLT broker topic. If you need a DLT, add a small downstream job that polls WHERE status = 'FAILED', publishes to your DLT (with error metadata appended), and flips the row to ARCHIVED. Cross-reference: §6.4.

6.4 Dead-letter on consumer side

BrokerDLQ mechanism
RabbitMQDLX (dead-letter exchange) + per-queue x-dead-letter-exchange arg, retry-count via x-death header
KafkaSeparate <topic>.dlt topic via Spring Kafka DefaultErrorHandler + DeadLetterPublishingRecoverer
SQSNative RedrivePolicy with maxReceiveCount

DLQ messages MUST retain the original envelope + add error metadata (failing service, exception class, stack trace, attempt count, original timestamp).


7. Idempotent Consumers

7.1 Why mandatory

At-least-once = duplicates are inevitable. The producer retried, the broker replayed on rebalance, the consumer crashed after processing but before ack. Without idempotency the consumer corrupts state.

7.2 Strategies (pick one per consumer)

StrategyWhenHow
Idempotency key tableMost casesInsert (eventId, processedAt); unique constraint rejects duplicate. Skip processing on conflict.
Aggregate version checkAggregate has versionWHERE id = ? AND version = ? — fails if already advanced.
Natural idempotencyOperation is naturally idempotent”Set status to PAID” — same input, same outcome. No tracking needed.
UpsertUpdating projected stateINSERT ... ON CONFLICT DO UPDATE.

7.3 Idempotency key table

CREATE TABLE consumer_processed_event (
event_id UUID PRIMARY KEY,
consumer_name VARCHAR(255) NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_consumer_processed_at
ON consumer_processed_event(consumer_name, processed_at);

Consumer flow:

⚠️ Dedup + @Transactional belong to a use case behind an inbound port — never on the @RabbitListener/@KafkaListener method. The adapter only deserializes and delegates handler.handle(event); orchestration (dedup + transaction + business logic) lives in the use case.

Adapter (listener) — deserialize + delegate only, no @Transactional, no dedup:

@RabbitListener(queues = "platform.notifications")
public void onMessage(@Payload String json, @Headers Map<String, Object> headers) {
EventMessage<OrderCreatedPayload> event = mapper.readValue(json, new TypeReference<>() {});
handler.handle(event); // delegates to ProcessOrderCreatedUseCase via inbound port
}

Use case (inbound port impl) — owns dedup + transaction:

@Transactional
public void handle(EventMessage<OrderCreatedPayload> event) {
UUID eventId = UUID.fromString(event.getHeader().getEventId()); // stable id from Header — NEVER traceId
try {
processed.insert(eventId, consumerName); // unique constraint
} catch (DuplicateKeyException e) {
log.info("Duplicate event {} skipped", eventId);
return; // ack and move on
}
process(event); // business logic
}

7.4 Retention

Idempotency table grows forever if untouched. Purge with TTL job — typically 7-30 days, sized to retry window.


8. Schema Evolution & Versioning

8.1 Compatibility modes

ModeMeaningRule for change
Backward compatible (default)New consumers read old + new messagesOnly add optional fields with defaults. Never remove or rename.
Forward compatibleOld consumers read new messagesSame constraint, plus consumers must ignore unknown fields.
Full compatibleBothStrictly additive.
BreakingNew schemaVersionCoexistence required (see §8.3).

8.2 What counts as breaking

  • Removing or renaming a field.
  • Changing a type (string → int, scalar → object).
  • Adding a required field (no default).
  • Narrowing an enum value set.
  • Changing semantic meaning of an existing field.

8.3 Coexistence

Bump schemaVersion (e.g. 1.0.0 → 2.0.0). Producer publishes BOTH versions for the deprecation window (typically 1-2 release cycles). Consumers migrate independently. Producer drops v1 only after telemetry confirms no consumer reads it.

8.4 Schema registry

For Kafka + Avro/Protobuf: use a schema registry (Confluent, Apicurio) with BACKWARD compatibility enforced. Reject incompatible schemas at registration time, not at runtime.

For JSON: enforce via JSON Schema + a /arch-api-style contract check in CI.

8.5 Consumer tolerance

Consumers MUST ignore unknown fields. In Jackson:

JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build();

9. Topology Externalization

9.1 Hard rule

The microservice MUST NOT declare exchanges, queues, topics, partitions, or bindings in code. No @Bean Queue, @Bean Exchange, @Bean Binding, @Bean NewTopic.

Why:

  • Multiple services publishing to the same exchange would duplicate the declaration with risk of drift.
  • Topology changes require redeploy if encoded in services.
  • Operators (RabbitMQ Operator, Strimzi for Kafka) can’t reconcile.

9.2 Where topology lives

ApproachWhen
Broker management UI (manual)Tiny teams, early-stage. Drift-prone.
definitions.json (RabbitMQ)One file in infra repo; imported via rabbitmqctl import_definitions.
TerraformCloud-native, multi-broker, audit-friendly.
Kubernetes Operators (RabbitMQ Cluster Operator, Strimzi)K8s-native. Declarative. CI-friendly.
AsyncAPI specLiving documentation of all topics + payloads. Generators emit definitions.json or Terraform.

9.3 Bootstrap order

Microservice startup MUST NOT block on exchange/topic existence — broker may be reconciled by an operator after deploy. Use lazy declarations if needed (@RabbitListener is fine; it auto-declares queues at first listen, NOT at startup), and rely on broker errors at first publish to alert ops.

9.4 Publish-time caveat

RabbitTemplate.send() to a non-existent exchange is silently dropped unless publisher confirms + mandatory=true are enabled. Outbox PUBLISHED status means “broker accepted bytes”, NOT “queue received them”. Enable publisher confirms for critical events:

@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory cf) {
var rt = new RabbitTemplate(cf);
rt.setMandatory(true);
rt.setConfirmCallback((corrData, ack, cause) -> {
if (!ack) log.error("Broker NACK: {}", cause);
});
rt.setReturnsCallback(returned ->
log.error("Unroutable message to {}: {}", returned.getExchange(), returned.getMessage()));
return rt;
}

10. RabbitMQ Specifics

10.1 Exchange types

TypeRoutingWhen
fanoutIgnore routing key, broadcast to all bindingsDomain events 1→N
directExact routing key matchTargeted commands
topicPattern match (order.*.paid)Hierarchical events
headersMatch message headersRare; prefer topic

10.2 Publisher adapter pinning

public class RabbitMessagePublisherAdapter implements MessagePublisherPort {
private final RabbitTemplate rabbitTemplate;
@Override
public void publish(String topic, String routingKey, String payload) {
var props = new MessageProperties();
props.setContentType(MessageProperties.CONTENT_TYPE_JSON);
props.setContentEncoding(StandardCharsets.UTF_8.name());
props.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
rabbitTemplate.send(topic, routingKey, new Message(payload.getBytes(UTF_8), props));
}
@Override
public void publish(String topic, String payload) {
publish(topic, "", payload); // fanout: empty routing key
}
}

Pin every message: contentType=application/json, contentEncoding=UTF-8, deliveryMode=PERSISTENT. Use send() (not convertAndSend) so the adapter controls bytes end-to-end.

10.3 Consumer

@RabbitListener(queues = "platform.notifications")
public void onMessage(@Payload String json, @Headers Map<String, Object> headers) {
EventMessage<OrderCreatedPayload> event = mapper.readValue(json, new TypeReference<>() {});
handler.handle(event);
// auto-ack on return; throw to NACK + DLX
}

10.4 Quorum vs classic queues

Use quorum queues for durability + HA (Raft-based, replicated). Classic mirrored queues were removed in RabbitMQ 4.0 — declaring one fails; migrate to quorum queues (or streams for replay).

10.5 Convention: fanout without routing key

Default platform convention for cross-team broadcast:

  • Exchange: <domain>.notifications (durable, fanout).
  • Queue per consumer service: <domain>.<consumer-service> (durable).
  • Binding: routing key "".

Targeted routing (e.g., payment.RAFFLE) uses topic exchanges + explicit routing keys.


11. Kafka Specifics

11.1 Topic design

  • Naming: <bounded-context>.<aggregate>.<event-type> — e.g. orders.order.events, payments.payment.events.
  • Partitions: based on throughput + parallelism target. Default 12-24. Cannot reduce without rebuild — start conservative.
  • Replication: replication-factor=3 minimum for production.
  • Retention: time-based (retention.ms=604800000 = 7 days) OR compact for stateful keys.

11.2 Partition key

message_key = aggregate id. Same aggregate always lands on same partition → ordering preserved per aggregate.

kafkaTemplate.send(topic, msg.getKey(), msg.getMessage());

11.3 Producer config

spring.kafka.producer:
acks: all # wait for ISR replication
enable-idempotence: true # exactly-once-per-partition semantics
retries: 2147483647 # producer-level retry; outbox handles app-level
max-in-flight-requests-per-connection: 5
compression-type: lz4
linger-ms: 5 # small batching window
batch-size: 32768

11.4 Consumer config

spring.kafka.consumer:
group-id: ${spring.application.name}
auto-offset-reset: earliest # never lose events on first deploy
enable-auto-commit: false # manual commit after processing
max-poll-records: 100
isolation-level: read_committed # ignore aborted transactional writes

Manual commit pattern:

@KafkaListener(topics = "orders.order.events")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
handler.handle(record.value());
ack.acknowledge(); // commit only after successful processing
}

11.5 Exactly-once vs at-least-once

Kafka transactions + read_committed give exactly-once within Kafka. Most platforms still need consumer idempotency (§7) because:

  • Sink writes to non-Kafka systems (DB, external API).
  • Cross-service flows leave Kafka boundary.

Don’t pay the EOS cost unless you’re staying inside Kafka end-to-end. Use at-least-once + idempotency.


12. Choreography vs Orchestration

12.1 Choreography (default for 2-3 service flows)

Services react to events without a central coordinator. Loose coupling, no single point of failure.

OrderService ──OrderCreated──> PaymentService ──PaymentCompleted──> ShippingService

Risks: distributed monitoring, hard to reason about end-to-end flow, hidden coupling.

12.2 Orchestration (for complex sagas)

A coordinator (Temporal, Camunda, Spring State Machine) drives the flow, owns the state, handles compensation. Use when:

  • ≥4 services involved.
  • Long-running (hours/days).
  • Compensating actions required (saga).
  • Strong audit + observability needs.

See arch-saga-pattern skill + /arch-saga for canonical implementation.

12.3 ADR required

Choreography vs orchestration is an architecturally significant choice. Document in an ADR per adr.md.


13. Tracing Propagation

13.1 W3C Trace Context

The producer copies the current traceId + spanId into broker headers; the consumer rehydrates them on the receiving side so a single trace spans services.

BrokerHeader carrier
RabbitMQMessageProperties.headers map — keys traceparent, tracestate
KafkaProducerRecord.headers() — same keys
SQSMessageAttributeValue — same keys

13.2 Spring config

Spring Boot 3+ with Micrometer Tracing handles propagation automatically when:

  • io.micrometer:micrometer-tracing-bridge-otel is on the classpath.
  • Auto-instrumentation for the broker is enabled.

For RabbitMQ specifically, attach a MessagePostProcessor that copies tracing context:

rabbitTemplate.addBeforePublishPostProcessors(tracePropagationMessagePostProcessor);

See obs-tracing.md for full setup.

13.3 Explicit traceId in envelope

Even with W3C headers, the EventMessage.Header.traceId field is the business-level correlation ID — survives across replays, DLQ redrives, and is searchable in audit logs without parsing broker headers. Always populate it.


14. Hexagonal Placement

ElementLayerModule
EventMessage<T>Domaindomain/ (pure model, no annotations)
OutboxMessage<T>Domaindomain/
EventMessageFactoryDomaindomain/factory/ (pure, no annotations)
OutboxEventFactoryDomaindomain/factory/
OutboxRepositoryPortApplicationapplication/port/outbound/
MessagePublisherPortApplicationapplication/port/outbound/
SendOutboxMessagesPort (inbound)Applicationapplication/port/inbound/
SendOutboxMessagesUseCaseApplicationapplication/usecase/
OutboxJdbcAdapterInfrastructureinfrastructure/driven-adapters/jdbc-repository/
RabbitMessagePublisherAdapter / KafkaMessagePublisherAdapterInfrastructureinfrastructure/driven-adapters/{rabbitmq|kafka-producer}/
OutboxSchedulerInfrastructure (inbound entry point)infrastructure/entry-points/scheduler-task/
@KafkaListener / @RabbitListener adaptersInfrastructureinfrastructure/entry-points/{kafka|rabbit}-consumer/
Spring Boot wiringBootstrapbootstrap/config/

Domain never imports spring-amqp, spring-kafka, or any broker SDK. The envelope is pure.


15. Testing

15.1 Test pyramid

Test typeToolWhat it catches
Unit: factory + use caseJUnit 5 + MockitoLogic, retry/backoff math, claim flow
Integration: JDBC outboxTestcontainers (Postgres/SQL Server)SQL correctness, claim atomicity
Integration: publisherTestcontainers RabbitMQ / KafkaAdapter sends real bytes
Contract: schemaJSON Schema + PactProducer ↔ consumer payload compatibility
E2E: end-to-end flowTestcontainers (broker + DB + service)Outbox → broker → consumer roundtrip

15.2 Outbox unit test essentials

  • claimPendingMessages returns claimed rows with claimed_at, claimed_by set.
  • Stale IN_FLIGHT (older than claimTtl) re-claimable.
  • PENDING with future next_attempt_at NOT claimed.
  • Concurrent claims by 2 instances → no double-claim (use FOR UPDATE SKIP LOCKED test).
  • Exponential backoff doubles per attempt, caps at maxBackoffMs.
  • attempts >= maxAttempts → status FAILED.

15.3 Consumer idempotency test

@Test
void duplicate_event_processed_only_once() {
var event = makeEvent("evt-1");
consumer.onEvent(event);
consumer.onEvent(event); // duplicate
verify(handler, times(1)).handle(any());
}

15.4 Contract testing

Producer publishes → Pact records the schema → Consumer verifies against the same. Catches schema drift before deploy.


16. Operational Metrics & Alerting

16.1 Producer-side metrics

MetricTypeAlert
outbox.messages.pendingGauge> 1000 for >5 min → page
outbox.messages.in_flight.stale (claimed_at > TTL)Gauge> 0 sustained → investigate stuck dispatcher
outbox.messages.failed.totalCounterrate > 1/min → page
outbox.dispatch.durationTimerp99 > 30s → investigate broker latency
outbox.dispatch.batch.sizeHistogramconsistent batchSize → throughput cap
outbox.publish.attemptsHistogramrising avg → broker degradation

16.2 Consumer-side metrics

MetricTypeAlert
consumer.lag (Kafka) / queue.depth (Rabbit)Gauge> 10k for >5 min → page
consumer.processing.durationTimerp99 > 5s → investigate
consumer.dlq.totalCounterany non-zero → triage
consumer.duplicate.skippedCounterrising → producer over-retrying

16.3 Broker metrics

RabbitMQKafka
rabbitmq_queue_messages_readykafka_server_brokertopicmetrics_messagesinpersec
rabbitmq_queue_messages_unacknowledgedkafka_consumergroup_lag
rabbitmq_connectionskafka_controller_kafkacontroller_activecontrollercount

Implement via Micrometer + Prometheus per obs-metrics.md.


17. Anti-patterns

Anti-patternWhy it’s wrongUse instead
Direct rabbitTemplate.send / kafkaTemplate.send inside a use caseDual-write problem; lost events or phantom eventsOutbox pattern (§4)
Use case injects MessagePublisherPort (or a domain-specific XxxPublisherPort)Back door to dual-write; skips outbox; bypasses claim-based dispatchInject ONLY OutboxRepositoryPort + OutboxEventFactory in use cases. MessagePublisherPort is the dispatcher’s port. Enforce via ArchUnit.
”Publish first, outbox as fallback” / circuit-breaker patternStill dual-write — fast path can succeed while DB rolls back; slow path runs different code pathOutbox-only always. There is no fast path.
@Transactional use case calling brokerTX commit may succeed but broker call fail afterOutbox + dispatcher
@Bean Queue / Exchange / Binding / NewTopic in microserviceTopology drift; can’t reconcile across servicesExternalize (§9)
Generating traceId at the publisherLoses correlation to triggering requestPropagate from inbound port
Setting origin manually in callersCopy-paste bugs across servicesAuto-inject via factory (§3.2)
Consumer without idempotencyDuplicates corrupt state§7
FAIL_ON_UNKNOWN_PROPERTIES=true on consumerBreaks on additive schema changesTolerate unknowns
Removing fields without bumping schemaVersionBreaks live consumers§8
One ShedLock lock name for all servicesSerializes whole platformPer-service lock name
Outbox row published, business write rolled backTwo separate TXsSingle TX (§4.4)
Publishing to broker without confirms + mandatorySilent drops on missing exchange§9.4
Massive batch size (>1000)Long claim, increases stale risk50-100 typical
Sharing one Kafka consumer group across two servicesBoth miss half the eventsOne group per consumer service
Manually deleting FAILED outbox rowsLoses auditMark ARCHIVED, retain N days
Treating PUBLISHED as “consumer received”Only means broker accepted bytesUse ack from consumer side for that signal
Embedded broker in production (embedded-kafka)Loses durability + HAReal broker, Testcontainers in tests only

18. Pre-Merge Checklist

For PRs that publish events, consume events, or touch the outbox:

  • Outbox pattern used — no direct broker calls in use case or controller.
  • Business write + outbox insert wrapped in same local transaction (Transaction port).
  • EventMessage<T> envelope (Header + Body) with traceId, origin, aggregate, eventName.
  • Event name in past tense, PascalCase; type is EVENT or COMMAND.
  • origin injected via factory from spring.application.name, NEVER set manually.
  • No @Bean Queue / Exchange / Binding / NewTopic in microservice code.
  • Topology declared externally (definitions.json / Terraform / Operator) — referenced in PR.
  • Consumer idempotency in place (§7) — strategy documented.
  • Retry policy + backoff configured; maxAttempts set per criticality.
  • DLQ/DLT path defined for permanent failures.
  • Schema change is backward-compatible OR schemaVersion bumped + coexistence plan.
  • FAIL_ON_UNKNOWN_PROPERTIES=false on consumer JsonMapper.
  • ShedLock lock name includes spring.application.name.
  • Publisher confirms + mandatory=true enabled for critical RabbitMQ events.
  • Kafka: acks=all, enable-idempotence=true, manual commit in consumer.
  • Tracing context propagated in broker headers (traceparent).
  • Metrics added: pending, in_flight stale, failed, dispatch duration, consumer lag.
  • Alert thresholds defined for pending backlog + failed rate + consumer lag.
  • No PII / secrets in payload OR logs (see security.md §10).
  • Tests: outbox JDBC integration (Testcontainers), publisher adapter (Testcontainers broker), consumer idempotency, schema contract.
  • ADR drafted if changing broker, topology approach, schema-evolution policy, or choreography↔orchestration.
  • /arch-outbox agent run on diff — no findings.