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/NewTopicin microservice code. Message envelopes MUST follow the canonicalEventMessage<T>shape (Header + Body). Retries follow exponential backoff bounded bymaxAttempts; permanent failures land in DLT/DLQ.
Project-agnostic: code samples use placeholder package
com.example.platform, service nameplatform-service. Replace with your project’s actual values. The canonical reference implementation for the outbox pattern iscodehuntersio-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
traceparentin 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-patternskill — canonical write-side pattern.arch-saga-patternskill — distributed transactions across services.kafka-rabbitmqskill — broker-specific patterns.
Table of Contents
- Principles
- Broker Selection Matrix
- Canonical Event Envelope
- Transactional Outbox Pattern
- Claim-Based Dispatch
- Retry, Backoff & Dead-Letter
- Idempotent Consumers
- Schema Evolution & Versioning
- Topology Externalization
- RabbitMQ Specifics
- Kafka Specifics
- Choreography vs Orchestration
- Tracing Propagation
- Hexagonal Placement
- Testing
- Operational Metrics & Alerting
- Anti-patterns
- 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 payload3.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.
@Componentpublic 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 domainpublic 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 1hSwapping 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@RequiredArgsConstructorpublic 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:
- ShedLock (
@SchedulerLock) — only one instance ticks at a time. - Claim flow — that instance atomically transitions a batch of rows from
PENDING→IN_FLIGHT, stampingclaimed_atandclaimed_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──> ARCHIVED6.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 +
@Transactionalbelong to a use case behind an inbound port — never on the@RabbitListener/@KafkaListenermethod. The adapter only deserializes and delegateshandler.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:
@Transactionalpublic 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.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
| 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:
@Beanpublic 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=3minimum 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: 3276811.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 writesManual 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──> ShippingServiceRisks: 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-otelis 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
claimPendingMessagesreturns claimed rows withclaimed_at,claimed_byset.- Stale
IN_FLIGHT(older thanclaimTtl) re-claimable. PENDINGwith futurenext_attempt_atNOT claimed.- Concurrent claims by 2 instances → no double-claim (use
FOR UPDATE SKIP LOCKEDtest). - Exponential backoff doubles per attempt, caps at
maxBackoffMs. attempts >= maxAttempts→ statusFAILED.
15.3 Consumer idempotency test
@Testvoid 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) withtraceId,origin,aggregate,eventName. - Event name in past tense, PascalCase; type is
EVENTorCOMMAND. -
origininjected via factory fromspring.application.name, NEVER set manually. - No
@Bean Queue / Exchange / Binding / NewTopicin microservice code. - Topology declared externally (definitions.json / Terraform / Operator) — referenced in PR.
- Consumer idempotency in place (§7) — strategy documented.
- Retry policy + backoff configured;
maxAttemptsset per criticality. - DLQ/DLT path defined for permanent failures.
- Schema change is backward-compatible OR
schemaVersionbumped + coexistence plan. -
FAIL_ON_UNKNOWN_PROPERTIES=falseon consumer JsonMapper. - ShedLock lock name includes
spring.application.name. - Publisher confirms +
mandatory=trueenabled 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-outboxagent run on diff — no findings.