Skip to content
My Guidelines
Guides
In this guide (73 items)

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-outboxarch-outbox-verifier (audits outbox impl correctness).
  • /arch-sagaarch-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

Need Choose Why
Cross-team domain events, broadcast (1→N), low throughput (< 10k msg/s) RabbitMQ Simple topology, manual ack, mature ops, easy DLQ.
High throughput (>50k msg/s), event sourcing, replay, ordered partitioning Kafka Log-structured, partitioned, retention, consumer groups.
AWS-native, serverless, ≤256KB payloads, no replay SQS + SNS Managed, IAM-bound, deep AWS integration; SNS fan-out + SQS work-queue.
Cloud-agnostic managed GCP Pub/Sub / Azure Service Bus Equivalent to SQS+SNS on respective clouds.
Internal RPC-like with backpressure gRPC (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

Field Convention
eventName Past tense, PascalCase: OrderCreated, PaymentFailed, UserRegistered. Never CreateOrder (that’s a command).
eventType EVENT (something happened) or COMMAND (something must happen).
aggregate The aggregate root ID — drives idempotency + partitioning.
eventId Stable 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).
traceId Propagated from the inbound request that triggered the change. Never generated here.
schemaVersion Bumped on breaking payload changes only (see §8).
origin Auto-injected. Never set manually.
payload Typed, 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):

Bean Default impl
EventMessageFactory Stamps origin = spring.application.name
OutboxEventFactory Wraps EventMessage<T>OutboxMessage<EventMessage<T>>
OutboxRepositoryPort OutboxJdbcAdapter (Postgres / SQL Server)
MessagePublisherPort RabbitMessagePublisherAdapter (override with a Kafka adapter to switch broker)
SendOutboxMessagesPort SendOutboxMessagesUseCase
OutboxScheduler @SchedulerLock-protected scheduled bean
LockProvider JdbcTemplateLockProvider (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 PENDINGIN_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:

Strategy Pros Cons
Manual triage via dashboard query Simple, no extra infra Requires ops attention
Auto-publish to DLQ exchange/topic Replay/redrive tool can re-enqueue DLQ schema decoupling
Both: keep in outbox + publish to DLQ Audit + redrive Slightly 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

Broker DLQ mechanism
RabbitMQ DLX (dead-letter exchange) + per-queue x-dead-letter-exchange arg, retry-count via x-death header
Kafka Separate <topic>.dlt topic via Spring Kafka DefaultErrorHandler + DeadLetterPublishingRecoverer
SQS Native 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)

Strategy When How
Idempotency key table Most cases Insert (eventId, processedAt); unique constraint rejects duplicate. Skip processing on conflict.
Aggregate version check Aggregate has version WHERE id = ? AND version = ? — fails if already advanced.
Natural idempotency Operation is naturally idempotent “Set status to PAID” — same input, same outcome. No tracking needed.
Upsert Updating projected state INSERT ... 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

Mode Meaning Rule for change
Backward compatible (default) New consumers read old + new messages Only add optional fields with defaults. Never remove or rename.
Forward compatible Old consumers read new messages Same constraint, plus consumers must ignore unknown fields.
Full compatible Both Strictly additive.
Breaking New schemaVersion Coexistence 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.02.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

Approach When
Broker management UI (manual) Tiny teams, early-stage. Drift-prone.
definitions.json (RabbitMQ) One file in infra repo; imported via rabbitmqctl import_definitions.
Terraform Cloud-native, multi-broker, audit-friendly.
Kubernetes Operators (RabbitMQ Cluster Operator, Strimzi) K8s-native. Declarative. CI-friendly.
AsyncAPI spec Living 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

Type Routing When
fanout Ignore routing key, broadcast to all bindings Domain events 1→N
direct Exact routing key match Targeted commands
topic Pattern match (order.*.paid) Hierarchical events
headers Match message headers Rare; 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 are deprecated.

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.

Broker Header carrier
RabbitMQ MessageProperties.headers map — keys traceparent, tracestate
Kafka ProducerRecord.headers() — same keys
SQS MessageAttributeValue — 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

Element Layer Module
EventMessage<T> Domain domain/ (pure model, no annotations)
OutboxMessage<T> Domain domain/
EventMessageFactory Domain domain/factory/ (pure, no annotations)
OutboxEventFactory Domain domain/factory/
OutboxRepositoryPort Application application/port/outbound/
MessagePublisherPort Application application/port/outbound/
SendOutboxMessagesPort (inbound) Application application/port/inbound/
SendOutboxMessagesUseCase Application application/usecase/
OutboxJdbcAdapter Infrastructure infrastructure/driven-adapters/jdbc-repository/
RabbitMessagePublisherAdapter / KafkaMessagePublisherAdapter Infrastructure infrastructure/driven-adapters/{rabbitmq|kafka-producer}/
OutboxScheduler Infrastructure (inbound entry point) infrastructure/entry-points/scheduler-task/
@KafkaListener / @RabbitListener adapters Infrastructure infrastructure/entry-points/{kafka|rabbit}-consumer/
Spring Boot wiring Bootstrap bootstrap/config/

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


15. Testing

15.1 Test pyramid

Test type Tool What it catches
Unit: factory + use case JUnit 5 + Mockito Logic, retry/backoff math, claim flow
Integration: JDBC outbox Testcontainers (Postgres/SQL Server) SQL correctness, claim atomicity
Integration: publisher Testcontainers RabbitMQ / Kafka Adapter sends real bytes
Contract: schema JSON Schema + Pact Producer ↔ consumer payload compatibility
E2E: end-to-end flow Testcontainers (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

Metric Type Alert
outbox.messages.pending Gauge > 1000 for >5 min → page
outbox.messages.in_flight.stale (claimed_at > TTL) Gauge > 0 sustained → investigate stuck dispatcher
outbox.messages.failed.total Counter rate > 1/min → page
outbox.dispatch.duration Timer p99 > 30s → investigate broker latency
outbox.dispatch.batch.size Histogram consistent batchSize → throughput cap
outbox.publish.attempts Histogram rising avg → broker degradation

16.2 Consumer-side metrics

Metric Type Alert
consumer.lag (Kafka) / queue.depth (Rabbit) Gauge > 10k for >5 min → page
consumer.processing.duration Timer p99 > 5s → investigate
consumer.dlq.total Counter any non-zero → triage
consumer.duplicate.skipped Counter rising → producer over-retrying

16.3 Broker metrics

RabbitMQ Kafka
rabbitmq_queue_messages_ready kafka_server_brokertopicmetrics_messagesinpersec
rabbitmq_queue_messages_unacknowledged kafka_consumergroup_lag
rabbitmq_connections kafka_controller_kafkacontroller_activecontrollercount

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


17. Anti-patterns

Anti-pattern Why it’s wrong Use instead
Direct rabbitTemplate.send / kafkaTemplate.send inside a use case Dual-write problem; lost events or phantom events Outbox pattern (§4)
Use case injects MessagePublisherPort (or a domain-specific XxxPublisherPort) Back door to dual-write; skips outbox; bypasses claim-based dispatch Inject ONLY OutboxRepositoryPort + OutboxEventFactory in use cases. MessagePublisherPort is the dispatcher’s port. Enforce via ArchUnit.
“Publish first, outbox as fallback” / circuit-breaker pattern Still dual-write — fast path can succeed while DB rolls back; slow path runs different code path Outbox-only always. There is no fast path.
@Transactional use case calling broker TX commit may succeed but broker call fail after Outbox + dispatcher
@Bean Queue / Exchange / Binding / NewTopic in microservice Topology drift; can’t reconcile across services Externalize (§9)
Generating traceId at the publisher Loses correlation to triggering request Propagate from inbound port
Setting origin manually in callers Copy-paste bugs across services Auto-inject via factory (§3.2)
Consumer without idempotency Duplicates corrupt state §7
FAIL_ON_UNKNOWN_PROPERTIES=true on consumer Breaks on additive schema changes Tolerate unknowns
Removing fields without bumping schemaVersion Breaks live consumers §8
One ShedLock lock name for all services Serializes whole platform Per-service lock name
Outbox row published, business write rolled back Two separate TXs Single TX (§4.4)
Publishing to broker without confirms + mandatory Silent drops on missing exchange §9.4
Massive batch size (>1000) Long claim, increases stale risk 50-100 typical
Sharing one Kafka consumer group across two services Both miss half the events One group per consumer service
Manually deleting FAILED outbox rows Loses audit Mark ARCHIVED, retain N days
Treating PUBLISHED as “consumer received” Only means broker accepted bytes Use ack from consumer side for that signal
Embedded broker in production (embedded-kafka) Loses durability + HA Real 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.