Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md
T

5057 lines
196 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Messaging Platform Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Java/Spring Backend Skeleton에 evidence 기반 publish 결과, duplicate-safe consumer settlement, Kafka·RabbitMQ Stable Adapter, Outbox·Inbox Reliability, Pulsar·NATS Experimental Adapter, Admin Plane, 보안·관측성·장애 검증을 갖춘 Messaging 플랫폼을 구현한다.
**Architecture:** `messaging-core-api`가 브로커 중립 공개 계약을 소유하고 `messaging-transport-spi`를 Kafka·RabbitMQ·Pulsar·NATS adapter가 구현한다. 일반 서비스는 M1 Typed Publisher·Handler만 사용하며, M2 Advanced, M3 Native Capability, M4 Admin Plane을 별도 모듈·권한으로 격리한다. publish 결과는 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence를 보존하고, consumer는 handler 성공 뒤에만 settlement하며 retry·DLQ·redrive에서 logical `messageId`를 유지한다.
**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Kafka, Spring AMQP, Apache Pulsar Client, NATS Java Client, Spring JDBC/JPA, PostgreSQL 16, Flyway, Jackson, optional Avro and Protobuf, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy.
## Global Constraints
- `messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message<?>`, Reactor 의존성을 넣지 않는다.
- Core 비동기 계약은 `CompletionStage`다. Blocking과 Reactor facade는 별도 모듈에서 제공한다.
- 일반 업무 모듈의 기본 진입점은 M1 Typed Publisher·Handler다.
- M2는 Batch, Manual Settlement, Pause/Resume, Delayed Delivery, Replay Request만 제공한다.
- M3는 broker-native capability를 typed interface로만 제공하며 raw broker client를 반환하지 않는다.
- M4 Admin Plane은 별도 credential과 security chain을 사용한다.
- 공통 API에 `EXACTLY_ONCE`, `GLOBAL_ORDERING`, DB+broker 원자 transaction 옵션을 만들지 않는다.
- Stable Adapter는 Kafka 4.2+·4.3.x와 RabbitMQ 4.3.x다.
- Kafka Share Group, Pulsar 4.0 LTS·4.2, NATS JetStream 2.14.x는 Experimental이다.
- Stable Kafka producer는 `enable.idempotence=true`, `acks=all`, `max.in.flight.requests.per.connection<=5`를 강제한다.
- Rabbit publisher는 correlated confirm, return, `mandatory=true`를 강제한다.
- durable Rabbit work queue 기본은 quorum queue다.
- publish timeout 또는 connection loss 뒤 broker 처리 여부를 확정할 수 없으면 `AMBIGUOUS`다.
- publish retry, redelivery, retry destination, DLQ, redrive는 같은 logical `messageId`를 유지한다.
- M1 consumer는 handler 성공 이전에 source settlement를 수행하지 않는다.
- DLQ·retry destination publish가 confirm되기 전에 source를 ACK하지 않는다.
- strict ordering destination에 reorder 가능한 retry 전략을 설정하면 startup을 실패시킨다.
- JSON은 Stable 기본 codec, Avro·Protobuf는 선택 Stable, Raw Bytes는 M2, Java Serialization은 비지원이다.
- Domain·Integration Event에 CloudEvents 1.0.2 compatible profile을 선택 제공한다.
- logical payload 기본 최대는 1,048,576 bytes, global hard maximum은 8,388,608 bytes다.
- header 총 크기는 32,768 bytes, 개수 64개, key 128 bytes, value 4,096 bytes다.
- 1 MiB 초과 payload는 Claim Check를 기본으로 사용한다.
- production topology는 IaC 생성 + application validate-only다.
- producer, consumer, admin credential을 분리하고 production TLS·broker authentication을 필수화한다.
- metric label에 message ID, actual key, payload, unbounded tenant ID, credential을 기록하지 않는다.
- Outbox ambiguous publish는 같은 message ID로 재시도한다.
- Inbox row와 business side effect는 같은 PostgreSQL transaction에서 commit한다.
- 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다.
- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다.
- 테스트 코드에 사용되는 `*Fixture`, `*Harness`, `Fake*`, `Test*` support type은 별도 경로가 명시되지 않으면 해당 Task의 listed test file 하단에 package-private top-level type으로 함께 작성한다.
---
## 1. 확정 파일 구조
```text
backend-skeleton/
├── settings.gradle.kts
├── build.gradle.kts
├── build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts
├── modules/messaging/
│ ├── messaging-core-api/
│ ├── messaging-schema-api/
│ ├── messaging-schema-json/
│ ├── messaging-schema-avro/
│ ├── messaging-schema-protobuf/
│ ├── messaging-cloudevents/
│ ├── messaging-policy/
│ ├── messaging-transport-spi/
│ ├── messaging-observability/
│ ├── messaging-security/
│ ├── messaging-kafka/
│ ├── messaging-kafka-share-experimental/
│ ├── messaging-rabbit/
│ ├── messaging-reliability-api/
│ ├── messaging-outbox-jpa/
│ ├── messaging-inbox-jpa/
│ ├── messaging-claim-check/
│ ├── messaging-admin-api/
│ ├── messaging-admin-runtime/
│ ├── messaging-pulsar-experimental/
│ ├── messaging-nats-experimental/
│ ├── messaging-spring-cloud-stream-bridge/
│ ├── messaging-spring-boot-starter/
│ └── messaging-testkit/
├── infra/messaging/
│ ├── kafka/
│ ├── rabbitmq/
│ ├── pulsar/
│ ├── nats/
│ ├── postgres/
│ ├── toxiproxy/
│ └── tls/
├── docs/messaging/
│ ├── support-matrix.md
│ ├── configuration-reference.md
│ ├── delivery-guarantees.md
│ ├── retry-dlq-redrive.md
│ ├── outbox-inbox.md
│ ├── security.md
│ ├── operations.md
│ ├── migration-guide.md
│ └── experimental-policy.md
└── docs/superpowers/specs/2026-08-10-messaging-platform-design.md
```
## 2. 핵심 패키지
```text
io.backend.skeleton.messaging.api
io.backend.skeleton.messaging.api.delivery
io.backend.skeleton.messaging.api.destination
io.backend.skeleton.messaging.api.error
io.backend.skeleton.messaging.api.header
io.backend.skeleton.messaging.api.publish
io.backend.skeleton.messaging.api.settlement
io.backend.skeleton.messaging.schema
io.backend.skeleton.messaging.schema.json
io.backend.skeleton.messaging.schema.avro
io.backend.skeleton.messaging.schema.protobuf
io.backend.skeleton.messaging.cloudevents
io.backend.skeleton.messaging.policy
io.backend.skeleton.messaging.transport
io.backend.skeleton.messaging.observation
io.backend.skeleton.messaging.security
io.backend.skeleton.messaging.kafka
io.backend.skeleton.messaging.rabbit
io.backend.skeleton.messaging.reliability
io.backend.skeleton.messaging.outbox
io.backend.skeleton.messaging.inbox
io.backend.skeleton.messaging.claimcheck
io.backend.skeleton.messaging.admin
io.backend.skeleton.messaging.pulsar
io.backend.skeleton.messaging.nats
io.backend.skeleton.messaging.streambridge
io.backend.skeleton.messaging.autoconfigure
io.backend.skeleton.messaging.testkit
```
---
### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성
**Files:**
- Modify: `settings.gradle.kts`
- Create: `build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts`
- Create: `modules/messaging/messaging-core-api/build.gradle.kts`
- Create: `modules/messaging/messaging-schema-api/build.gradle.kts`
- Create: `modules/messaging/messaging-schema-json/build.gradle.kts`
- Create: `modules/messaging/messaging-schema-avro/build.gradle.kts`
- Create: `modules/messaging/messaging-schema-protobuf/build.gradle.kts`
- Create: `modules/messaging/messaging-cloudevents/build.gradle.kts`
- Create: `modules/messaging/messaging-policy/build.gradle.kts`
- Create: `modules/messaging/messaging-transport-spi/build.gradle.kts`
- Create: `modules/messaging/messaging-observability/build.gradle.kts`
- Create: `modules/messaging/messaging-security/build.gradle.kts`
- Create: `modules/messaging/messaging-kafka/build.gradle.kts`
- Create: `modules/messaging/messaging-kafka-share-experimental/build.gradle.kts`
- Create: `modules/messaging/messaging-rabbit/build.gradle.kts`
- Create: `modules/messaging/messaging-reliability-api/build.gradle.kts`
- Create: `modules/messaging/messaging-outbox-jpa/build.gradle.kts`
- Create: `modules/messaging/messaging-inbox-jpa/build.gradle.kts`
- Create: `modules/messaging/messaging-claim-check/build.gradle.kts`
- Create: `modules/messaging/messaging-admin-api/build.gradle.kts`
- Create: `modules/messaging/messaging-admin-runtime/build.gradle.kts`
- Create: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts`
- Create: `modules/messaging/messaging-nats-experimental/build.gradle.kts`
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/build.gradle.kts`
- Create: `modules/messaging/messaging-spring-boot-starter/build.gradle.kts`
- Create: `modules/messaging/messaging-testkit/build.gradle.kts`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/ModuleSmokeTest.java`
**Interfaces:**
- Produces every Gradle project path used by later tasks.
- `messaging-core-api` has no Spring or broker dependency.
- Java toolchain is 21 and all tests use JUnit Platform.
- [ ] **Step 1: Write the failing core module smoke test**
```java
package io.backend.skeleton.messaging.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ModuleSmokeTest {
@Test
void coreApiModuleLoads() {
assertThat(ModuleSmokeTest.class.getPackageName())
.isEqualTo("io.backend.skeleton.messaging.api");
}
}
```
- [ ] **Step 2: Register module paths and verify configuration fails before build files exist**
Add to `settings.gradle.kts`:
```kotlin
include(
":modules:messaging:messaging-core-api",
":modules:messaging:messaging-schema-api",
":modules:messaging:messaging-schema-json",
":modules:messaging:messaging-schema-avro",
":modules:messaging:messaging-schema-protobuf",
":modules:messaging:messaging-cloudevents",
":modules:messaging:messaging-policy",
":modules:messaging:messaging-transport-spi",
":modules:messaging:messaging-observability",
":modules:messaging:messaging-security",
":modules:messaging:messaging-kafka",
":modules:messaging:messaging-kafka-share-experimental",
":modules:messaging:messaging-rabbit",
":modules:messaging:messaging-reliability-api",
":modules:messaging:messaging-outbox-jpa",
":modules:messaging:messaging-inbox-jpa",
":modules:messaging:messaging-claim-check",
":modules:messaging:messaging-admin-api",
":modules:messaging:messaging-admin-runtime",
":modules:messaging:messaging-pulsar-experimental",
":modules:messaging:messaging-nats-experimental",
":modules:messaging:messaging-spring-cloud-stream-bridge",
":modules:messaging:messaging-spring-boot-starter",
":modules:messaging:messaging-testkit"
)
```
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: FAIL because one or more registered module directories or build files are missing.
- [ ] **Step 3: Add the convention plugin and module build files**
`messaging-library-conventions.gradle.kts`:
```kotlin
plugins {
`java-library`
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
withSourcesJar()
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.assertj:assertj-core")
}
```
Every module build file starts with:
```kotlin
plugins {
id("messaging-library-conventions")
}
```
Add only the dependencies required by that module. `messaging-core-api` remains dependency-free except test libraries.
- [ ] **Step 4: Run the complete module smoke build**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS with one test and zero failures.
- [ ] **Step 5: Commit**
```bash
git add settings.gradle.kts build-logic modules/messaging
git commit -m "build: add messaging platform modules"
```
---
### Task 2: 핵심 식별자와 보장 Enum 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/UuidV7.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageId.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageType.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/SchemaVersion.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ProducerId.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CorrelationId.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CausationId.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryGuarantee.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ProcessingGuarantee.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/OrderingScope.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ExternalSideEffectGuarantee.java`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/CoreValueTypesTest.java`
**Interfaces:**
- Produces immutable value types used by every later task.
- `MessageId.newId()` uses the local `UuidV7.next()` implementation created in this Task.
- No enum contains `EXACTLY_ONCE` or `GLOBAL`.
- [ ] **Step 1: Write failing value-type tests**
```java
package io.backend.skeleton.messaging.api;
import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee;
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CoreValueTypesTest {
@Test
void messageTypeRejectsBlankValue() {
assertThatThrownBy(() -> new MessageType(" "))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void schemaVersionMustBePositive() {
assertThatThrownBy(() -> new SchemaVersion(0))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void guaranteeEnumsDoNotAdvertiseUnsupportedSemantics() {
assertThat(Arrays.stream(DeliveryGuarantee.values()).map(Enum::name))
.doesNotContain("EXACTLY_ONCE");
assertThat(Arrays.stream(OrderingScope.values()).map(Enum::name))
.doesNotContain("GLOBAL");
}
}
```
- [ ] **Step 2: Run the focused test and verify missing types**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test --tests '*CoreValueTypesTest'
```
Expected: FAIL because the value types and enums do not exist.
- [ ] **Step 3: Implement the value types and exact enum constants**
```java
package io.backend.skeleton.messaging.api;
public record MessageType(String value) {
public MessageType {
if (value == null || value.isBlank() || value.length() > 240) {
throw new IllegalArgumentException("messageType must contain 1 to 240 characters");
}
}
}
```
```java
package io.backend.skeleton.messaging.api;
public record SchemaVersion(int value) {
public SchemaVersion {
if (value < 1) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
}
}
```
```java
package io.backend.skeleton.messaging.api.delivery;
public enum DeliveryGuarantee {
AT_MOST_ONCE,
AT_LEAST_ONCE
}
```
```java
package io.backend.skeleton.messaging.api.delivery;
public enum OrderingScope {
NONE,
DESTINATION,
PARTITION,
KEY
}
```
Implement the remaining records with null, blank, and length validation. Implement `MessageId` as a non-null UUID value and provide `newId()`.
- [ ] **Step 4: Run tests and architecture compilation**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS with no unsupported guarantee constants.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api
git commit -m "feat: add messaging core value types"
```
---
### Task 3: 제한형 Header와 MessageEnvelope 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderName.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderValue.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/MessageHeaders.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/ReservedHeaders.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ContentType.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TenantContext.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TraceContext.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageEnvelope.java`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/MessageEnvelopeTest.java`
**Interfaces:**
- Consumes Task 2 value types.
- Produces `MessageEnvelope<T>` and immutable `MessageHeaders` for publisher, schema, and adapters.
- Enforces 64 headers, 32 KiB total, 128-byte keys, 4 KiB values, reserved and secret header rejection.
- [ ] **Step 1: Write failing envelope policy tests**
```java
package io.backend.skeleton.messaging.api;
import io.backend.skeleton.messaging.api.header.HeaderName;
import io.backend.skeleton.messaging.api.header.HeaderValue;
import io.backend.skeleton.messaging.api.header.MessageHeaders;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MessageEnvelopeTest {
@Test
void applicationCannotSetReservedHeader() {
assertThatThrownBy(() -> MessageHeaders.application(Map.of(
new HeaderName("msg.id"), new HeaderValue("forged"))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void secretHeadersAreRejected() {
assertThatThrownBy(() -> MessageHeaders.application(Map.of(
new HeaderName("Authorization"), new HeaderValue("Bearer secret"))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void payloadCannotBeNull() {
assertThatThrownBy(() -> TestEnvelopeFactory.envelope(null))
.isInstanceOf(NullPointerException.class);
}
}
```
- [ ] **Step 2: Run test and confirm missing envelope policy**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test --tests '*MessageEnvelopeTest'
```
Expected: FAIL because header and envelope types do not exist.
- [ ] **Step 3: Implement immutable header limits and envelope validation**
```java
package io.backend.skeleton.messaging.api.header;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
public final class MessageHeaders {
private static final int MAX_COUNT = 64;
private static final int MAX_TOTAL_BYTES = 32_768;
private static final Set<String> SECRET_NAMES = Set.of(
"authorization", "proxy-authorization", "cookie", "set-cookie",
"access_token", "refresh_token", "api_key", "password", "client_secret");
private final Map<HeaderName, HeaderValue> values;
private MessageHeaders(Map<HeaderName, HeaderValue> values) {
this.values = Map.copyOf(values);
}
public static MessageHeaders application(Map<HeaderName, HeaderValue> input) {
if (input.size() > MAX_COUNT) {
throw new IllegalArgumentException("message header count exceeds 64");
}
int bytes = 0;
Map<HeaderName, HeaderValue> copy = new LinkedHashMap<>();
for (Map.Entry<HeaderName, HeaderValue> entry : input.entrySet()) {
String normalized = entry.getKey().value().toLowerCase();
if (ReservedHeaders.isReserved(normalized) || SECRET_NAMES.contains(normalized)) {
throw new IllegalArgumentException("message header is not allowed: " + normalized);
}
bytes += entry.getKey().value().getBytes(StandardCharsets.UTF_8).length;
bytes += entry.getValue().value().getBytes(StandardCharsets.UTF_8).length;
copy.put(entry.getKey(), entry.getValue());
}
if (bytes > MAX_TOTAL_BYTES) {
throw new IllegalArgumentException("message header bytes exceed 32768");
}
return new MessageHeaders(copy);
}
public Map<HeaderName, HeaderValue> asMap() {
return values;
}
}
```
Implement `MessageEnvelope<T>` as the exact record from the design and validate all required values with `Objects.requireNonNull`.
- [ ] **Step 4: Run focused and full core tests**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS and all reserved/secret header tests succeed.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api
git commit -m "feat: add message envelope and header policy"
```
---
### Task 4: 논리 Destination과 Capability 모델 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationName.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationKind.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessageDestination.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/ConfirmationRequirement.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessagingCapabilities.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilities.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/CapabilityRegistry.java`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilityTest.java`
**Interfaces:**
- Consumes Task 2 identifiers and guarantees.
- Produces logical destination and capability contracts used by policy and adapters.
- [ ] **Step 1: Write failing capability tests**
```java
package io.backend.skeleton.messaging.api.destination;
import io.backend.skeleton.messaging.api.MessageType;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DestinationCapabilityTest {
@Test
void logicalDestinationDoesNotContainBrokerSpecificAddress() {
MessageDestination<String> destination = new MessageDestination<>(
new DestinationName("order-events"),
new MessageType("order.created"),
String.class);
assertThat(destination.name().value()).isEqualTo("order-events");
}
@Test
void destinationNameRejectsBrokerSeparatorsAndWhitespace() {
assertThatThrownBy(() -> new DestinationName("topic://orders"))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run focused test and observe missing types**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test --tests '*DestinationCapabilityTest'
```
Expected: FAIL because destination types do not exist.
- [ ] **Step 3: Implement exact capability contracts**
```java
package io.backend.skeleton.messaging.api.destination;
public record MessagingCapabilities(
boolean brokerAcknowledgement,
boolean replicationOrPersistenceEvidence,
boolean perMessageSettlement,
boolean batchSettlement,
boolean orderedStream,
boolean keyedOrdering,
boolean replay,
boolean delayedDelivery,
boolean brokerTransaction,
boolean deduplicatedPublish,
boolean nativeDeadLetter,
boolean topologyManagement) {
}
```
```java
package io.backend.skeleton.messaging.api.destination;
public interface CapabilityRegistry {
DestinationCapabilities capabilities(DestinationName destination);
}
```
Implement `DestinationName` with `[a-z0-9][a-z0-9.-]{0,159}` validation. Implement `DestinationKind` with `ASYNC_COMMAND`, `DOMAIN_EVENT`, `INTEGRATION_EVENT`, `WORK_QUEUE`, `PUBLISH_SUBSCRIBE`, `EVENT_STREAM`, `REQUEST_REPLY`. Implement `MessageDestination<T>` as an immutable record requiring name, message type, and payload type.
- [ ] **Step 4: Run core tests**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api
git commit -m "feat: add logical destination capabilities"
```
---
### Task 5: Schema API와 JSON Codec 구현
**Files:**
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodec.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/EncodedMessage.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaReference.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibility.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodecRegistry.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/RawBytesMessageCodec.java`
- Create: `modules/messaging/messaging-schema-json/src/main/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodec.java`
- Test: `modules/messaging/messaging-schema-json/src/test/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodecTest.java`
**Interfaces:**
- Consumes `MessageType`, `SchemaVersion`, `ContentType`.
- Produces codec SPI and JSON Stable codec.
- Rejects unknown message types, oversized encoded payloads, trailing data, and excessive nesting.
- [ ] **Step 1: Write failing JSON round-trip and limit tests**
```java
package io.backend.skeleton.messaging.schema.json;
import io.backend.skeleton.messaging.api.MessageType;
import io.backend.skeleton.messaging.api.SchemaVersion;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JacksonMessageCodecTest {
record OrderCreated(String orderId, long amount) {}
@Test
void roundTripsRegisteredType() {
JacksonMessageCodec codec = JacksonMessageCodec.testingDefault(
new MessageType("order.created"), OrderCreated.class);
byte[] encoded = codec.encode(
new MessageType("order.created"),
new SchemaVersion(1),
new OrderCreated("o-1", 1000)).bytes();
assertThat(codec.decode(
new MessageType("order.created"),
new SchemaVersion(1),
encoded,
OrderCreated.class)).isEqualTo(new OrderCreated("o-1", 1000));
}
@Test
void rejectsPayloadOverOneMibibyte() {
JacksonMessageCodec codec = JacksonMessageCodec.testingDefault(
new MessageType("text.large"), String.class);
String value = "a".repeat(1_048_577);
assertThatThrownBy(() -> codec.encode(
new MessageType("text.large"), new SchemaVersion(1), value))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run JSON module test and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-schema-json:test --tests '*JacksonMessageCodecTest'
```
Expected: FAIL because codec classes are missing.
- [ ] **Step 3: Implement the schema SPI and Jackson codec**
```java
package io.backend.skeleton.messaging.schema;
import io.backend.skeleton.messaging.api.ContentType;
import io.backend.skeleton.messaging.api.MessageType;
import io.backend.skeleton.messaging.api.SchemaVersion;
public interface MessageCodec {
ContentType contentType();
EncodedMessage encode(MessageType type, SchemaVersion version, Object payload);
<T> T decode(
MessageType type,
SchemaVersion version,
byte[] encoded,
Class<T> payloadType);
}
```
Configure Jackson with a closed message-type registry, maximum nesting depth 100, trailing token rejection, unknown subtype rejection, and encoded byte limit 1,048,576 by default. Implement `RawBytesMessageCodec` as an explicit M2 codec that copies the input bytes, enforces the same byte limit, and is never selected as a default codec.
- [ ] **Step 4: Run schema tests**
Run:
```bash
./gradlew :modules:messaging:messaging-schema-api:test :modules:messaging:messaging-schema-json:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-json
git commit -m "feat: add messaging schema and json codec"
```
---
### Task 6: CloudEvents Event Profile 구현
**Files:**
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventMapper.java`
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/DefaultCloudEventMapper.java`
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventExtensions.java`
- Test: `modules/messaging/messaging-cloudevents/src/test/java/io/backend/skeleton/messaging/cloudevents/CloudEventMappingTest.java`
**Interfaces:**
- Consumes `MessageEnvelope` and schema encoded payload.
- Produces CloudEvents 1.0.2 compatible mapping for events only.
- Does not map null data to a Kafka tombstone.
- [ ] **Step 1: Write failing CloudEvents mapping test**
```java
package io.backend.skeleton.messaging.cloudevents;
import io.cloudevents.CloudEvent;
import org.junit.jupiter.api.Test;
import java.net.URI;
import static org.assertj.core.api.Assertions.assertThat;
class CloudEventMappingTest {
@Test
void mapsLogicalIdentityAndExtensions() {
DefaultCloudEventMapper mapper = new DefaultCloudEventMapper();
CloudEvent event = mapper.toCloudEvent(
CloudEventFixture.orderCreatedEnvelope(),
URI.create("urn:service:order-api"));
assertThat(event.getId()).isEqualTo(
CloudEventFixture.orderCreatedEnvelope().messageId().value().toString());
assertThat(event.getType()).isEqualTo("order.created");
assertThat(event.getExtension("schemaversion")).isEqualTo("1");
}
}
```
- [ ] **Step 2: Run focused test and verify missing mapper**
Run:
```bash
./gradlew :modules:messaging:messaging-cloudevents:test --tests '*CloudEventMappingTest'
```
Expected: FAIL because mapping classes do not exist.
- [ ] **Step 3: Implement exact mapping rules**
```java
package io.backend.skeleton.messaging.cloudevents;
import io.backend.skeleton.messaging.api.MessageEnvelope;
import io.cloudevents.CloudEvent;
import java.net.URI;
public interface CloudEventMapper {
CloudEvent toCloudEvent(MessageEnvelope<?> envelope, URI source);
MessageEnvelope<byte[]> fromCloudEvent(CloudEvent event);
}
```
Map `messageId→id`, `producer/source→source`, `messageType→type`, `occurredAt→time`, `contentType→datacontenttype`, and the four documented extensions. Reject command envelopes without `occurredAt` when event mode is requested.
- [ ] **Step 4: Run tests**
Run:
```bash
./gradlew :modules:messaging:messaging-cloudevents:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-cloudevents
git commit -m "feat: add cloudevents message profile"
```
---
### Task 7: Publisher API, 결과 Evidence, 안정 예외 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/MessagePublisher.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishOptions.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishResult.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishCompletion.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishEvidence.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/TransmissionEvidence.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/ConfirmationLevel.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/RoutingOutcome.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BrokerPosition.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureCategory.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureDescriptor.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingConfigurationException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingCapabilityUnavailableException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageValidationException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSerializationException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSchemaIncompatibleException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTooLargeException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHeaderRejectedException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishRejectedException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishAmbiguousException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishTimeoutException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRoutingException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthenticationException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthorizationException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageConsumerException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHandlerTimeoutException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementUnknownException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRetryExhaustedException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageDeadLetterException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRedriveException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTopologyException.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBrokerUnavailableException.java`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/publish/PublishResultTest.java`
**Interfaces:**
- Consumes destination and envelope contracts.
- Produces the stable publish contract used by all adapters and Outbox.
- `AMBIGUOUS` is a first-class completion and cannot be marked as retryable success.
- [ ] **Step 1: Write failing publish result invariant tests**
```java
package io.backend.skeleton.messaging.api.publish;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PublishResultTest {
@Test
void confirmedResultRequiresBrokerAcceptance() {
assertThatThrownBy(() -> new PublishResult(
PublishCompletion.CONFIRMED,
new PublishEvidence(false, TransmissionEvidence.TRANSMITTED,
false, ConfirmationLevel.NONE),
RoutingOutcome.UNKNOWN,
Optional.empty(),
1,
Duration.ofMillis(10),
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void ambiguousResultCannotClaimReplicationConfirmation() {
assertThatThrownBy(() -> new PublishResult(
PublishCompletion.AMBIGUOUS,
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED,
true, ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK),
RoutingOutcome.UNKNOWN,
Optional.empty(),
1,
Duration.ofSeconds(5),
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run focused test and confirm missing result model**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test --tests '*PublishResultTest'
```
Expected: FAIL because publish contracts are missing.
- [ ] **Step 3: Implement publish API and invariants**
```java
package io.backend.skeleton.messaging.api.publish;
import io.backend.skeleton.messaging.api.MessageEnvelope;
import io.backend.skeleton.messaging.api.destination.MessageDestination;
import java.util.concurrent.CompletionStage;
public interface MessagePublisher {
<T> CompletionStage<PublishResult> publish(
MessageDestination<T> destination,
MessageEnvelope<T> message,
PublishOptions options);
}
```
Implement `PublishResult` constructor validation:
```java
if (completion == PublishCompletion.CONFIRMED && !evidence.brokerAccepted()) {
throw new IllegalArgumentException("confirmed publish requires broker acceptance");
}
if (completion == PublishCompletion.AMBIGUOUS
&& evidence.confirmationLevel() != ConfirmationLevel.NONE) {
throw new IllegalArgumentException("ambiguous publish cannot claim confirmation");
}
```
Add the full stable exception hierarchy from the design with sanitized metadata and no payload fields.
- [ ] **Step 4: Run core tests**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api
git commit -m "feat: add publish evidence contract"
```
---
### Task 8: Consumer Delivery, HandleResult, Settlement 계약 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageHandler.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageDelivery.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryMetadata.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryContext.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/HandleResult.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementController.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementResult.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementCompletion.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/ManualMessageHandler.java`
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/delivery/ConsumerContractTest.java`
**Interfaces:**
- Consumes envelope, destination, broker position.
- Produces M1 handler result and M2 settlement contracts.
- M1 exposes no broker ACK handle.
- [ ] **Step 1: Write failing consumer contract tests**
```java
package io.backend.skeleton.messaging.api.delivery;
import io.backend.skeleton.messaging.api.error.FailureCategory;
import io.backend.skeleton.messaging.api.error.FailureDescriptor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ConsumerContractTest {
@Test
void retryCarriesStableFailureCategory() {
FailureDescriptor failure = new FailureDescriptor(
FailureCategory.PROCESSING_TRANSIENT,
"DOWNSTREAM_TIMEOUT",
true,
"downstream timed out",
java.util.Optional.of("TimeoutException"));
HandleResult result = new HandleResult.Retry(failure);
assertThat(((HandleResult.Retry) result).failure().retryable()).isTrue();
}
@Test
void deliveryMetadataCountsInitialDeliveryAsAttemptOne() {
DeliveryMetadata metadata = DeliveryMetadataFixture.initial();
assertThat(metadata.deliveryAttempt()).isEqualTo(1);
}
}
```
- [ ] **Step 2: Run test and verify missing delivery types**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test --tests '*ConsumerContractTest'
```
Expected: FAIL because delivery and settlement contracts are missing.
- [ ] **Step 3: Implement exact interfaces and sealed results**
```java
package io.backend.skeleton.messaging.api.delivery;
import java.util.concurrent.CompletionStage;
public interface MessageHandler<T> {
CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
}
```
```java
package io.backend.skeleton.messaging.api.delivery;
import io.backend.skeleton.messaging.api.error.FailureDescriptor;
public sealed interface HandleResult
permits HandleResult.Success, HandleResult.Retry,
HandleResult.DeadLetter, HandleResult.Reject {
record Success() implements HandleResult {}
record Retry(FailureDescriptor failure) implements HandleResult {}
record DeadLetter(FailureDescriptor failure) implements HandleResult {}
record Reject(FailureDescriptor failure) implements HandleResult {}
}
```
Implement `SettlementController` with `ack`, `retry`, `deadLetter`, `reject` returning `CompletionStage<SettlementResult>` and no native broker parameters.
- [ ] **Step 4: Run core tests**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api
git commit -m "feat: add consumer delivery and settlement contracts"
```
---
### Task 9: Destination Profile, Retry Policy, Startup Validator 구현
**Files:**
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfile.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PhysicalDestination.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SchemaPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ProducerPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ConsumerPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryMode.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/OrderingImpact.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadPolicy.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfileValidator.java`
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DestinationProfileValidatorTest.java`
**Interfaces:**
- Consumes destination guarantees and capabilities from Tasks 2 and 4.
- Produces immutable destination profiles used by adapters and starter configuration.
- Enforces ordering, confirmation, payload, DLQ, retry cycle, production topology, and capability rules.
- [ ] **Step 1: Write failing startup validation tests**
```java
package io.backend.skeleton.messaging.policy;
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DestinationProfileValidatorTest {
private final DestinationProfileValidator validator = new DestinationProfileValidator();
@Test
void strictOrderingRejectsRetryDestination() {
DestinationProfile profile = DestinationProfileFixtures.kafkaOrdered(
new RetryPolicy(
RetryMode.RETRY_DESTINATION,
5,
java.time.Duration.ofSeconds(1),
java.time.Duration.ofMinutes(1),
2.0,
true,
OrderingImpact.PRESERVE,
java.util.Set.of(),
java.util.Set.of()));
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ordering");
}
@Test
void payloadAboveHardLimitIsRejected() {
DestinationProfile profile = DestinationProfileFixtures.withPayloadLimit(8_388_609);
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("8388608");
}
@Test
void dlqCannotPointToItself() {
DestinationProfile profile = DestinationProfileFixtures.selfReferencingDlq();
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dead letter");
}
}
```
- [ ] **Step 2: Run policy tests and verify missing profile model**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test --tests '*DestinationProfileValidatorTest'
```
Expected: FAIL because the profile and validator types are missing.
- [ ] **Step 3: Implement profile records and exact validation rules**
```java
package io.backend.skeleton.messaging.policy;
import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee;
import io.backend.skeleton.messaging.api.delivery.ExternalSideEffectGuarantee;
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
import io.backend.skeleton.messaging.api.destination.DestinationKind;
import io.backend.skeleton.messaging.api.destination.DestinationName;
public record DestinationProfile(
DestinationName name,
String broker,
DestinationKind kind,
PhysicalDestination physical,
SchemaPolicy schema,
DeliveryGuarantee deliveryGuarantee,
OrderingScope orderingScope,
ExternalSideEffectGuarantee externalSideEffectGuarantee,
ProducerPolicy producer,
ConsumerPolicy consumer,
RetryPolicy retry,
DeadLetterPolicy deadLetter,
PayloadPolicy payload,
boolean production) {
}
```
Implement these exact guards:
```java
if (profile.retry().orderingImpact() == OrderingImpact.PRESERVE
&& profile.retry().mode() == RetryMode.RETRY_DESTINATION) {
throw new IllegalArgumentException("retry destination cannot preserve ordering");
}
if (profile.payload().maxBytes() > 8_388_608) {
throw new IllegalArgumentException("payload maximum exceeds 8388608 bytes");
}
if (profile.deadLetter().enabled()
&& profile.deadLetter().destination().equals(profile.name())) {
throw new IllegalArgumentException("dead letter destination cannot reference itself");
}
```
Add graph validation for retry and DLQ cycles and require a key resolver when `OrderingScope.KEY` is configured.
- [ ] **Step 4: Run policy tests**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-policy
git commit -m "feat: add messaging destination policies"
```
---
### Task 10: Transport SPI와 Immutable Runtime Registry 구현
**Files:**
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingTransport.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishRequest.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishResult.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerSpec.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerRegistration.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportSettlement.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntime.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeLease.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistry.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/DefaultMessagingRuntimeRegistry.java`
- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistryTest.java`
**Interfaces:**
- Consumes Core publish, delivery, schema, and destination policy contracts.
- Produces the adapter SPI and atomic runtime generation replacement used for credential and topology rotation.
- Does not return native client objects.
- [ ] **Step 1: Write failing runtime generation tests**
```java
package io.backend.skeleton.messaging.transport;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingRuntimeRegistryTest {
@Test
void replacingRuntimeReturnsNewGenerationAndKeepsOldUntilReleased() {
DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry();
MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1);
MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2);
registry.install(first);
MessagingRuntimeLease lease = registry.acquire("kafka-primary");
registry.install(second);
assertThat(lease.runtime().generation()).isEqualTo(1);
assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2);
lease.close();
assertThat(first.isClosed()).isTrue();
}
}
```
- [ ] **Step 2: Run focused test and verify missing runtime SPI**
Run:
```bash
./gradlew :modules:messaging:messaging-transport-spi:test --tests '*MessagingRuntimeRegistryTest'
```
Expected: FAIL because runtime registry types are missing.
- [ ] **Step 3: Implement transport interfaces and reference-counted runtime generations**
```java
package io.backend.skeleton.messaging.transport;
import java.util.concurrent.CompletionStage;
public interface MessagingTransport extends AutoCloseable {
CompletionStage<TransportPublishResult> publish(TransportPublishRequest request);
TransportConsumerRegistration register(TransportConsumerSpec spec);
String brokerName();
long generation();
@Override void close();
}
```
```java
package io.backend.skeleton.messaging.transport;
public interface MessagingRuntimeRegistry {
void install(MessagingRuntime runtime);
MessagingRuntimeLease acquire(String brokerName);
}
```
Use an atomic map swap and reference count. Mark old runtimes draining after replacement and close them only after the last lease closes or the configured drain deadline expires.
- [ ] **Step 4: Run transport SPI tests**
Run:
```bash
./gradlew :modules:messaging:messaging-transport-spi:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-transport-spi
git commit -m "feat: add messaging transport runtime spi"
```
---
### Task 11: 공통 Security·Redaction·Observation Primitive 구현
**Files:**
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerCredentialProfile.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerSecurityProfile.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialProvider.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/DestinationAccessPolicy.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/MessageSecurityValidator.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingObservation.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTags.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingRedactor.java`
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingRedactorTest.java`
- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/MessageSecurityValidatorTest.java`
**Interfaces:**
- Consumes envelope and destination identity.
- Produces low-cardinality observation context, redaction, credential abstraction, and destination ACL validation.
- Actual broker TLS/auth integration is implemented in Task 31.
- [ ] **Step 1: Write failing redaction and forbidden-header tests**
```java
package io.backend.skeleton.messaging.observation;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingRedactorTest {
@Test
void removesMessageIdentityAndSecretsFromDiagnosticMap() {
MessagingRedactor redactor = new MessagingRedactor();
Map<String, String> sanitized = redactor.sanitize(Map.of(
"messageId", "0190f4aa-0000-7000-8000-000000000001",
"Authorization", "Bearer secret",
"destinationProfile", "order-events"));
assertThat(sanitized)
.containsEntry("destinationProfile", "order-events")
.doesNotContainKeys("messageId", "Authorization");
}
}
```
```java
package io.backend.skeleton.messaging.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MessageSecurityValidatorTest {
@Test
void productionBrokerWithoutTlsIsRejected() {
BrokerSecurityProfile profile = BrokerSecurityProfileFixtures.productionWithoutTls();
assertThatThrownBy(() -> new MessageSecurityValidator().validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("TLS");
}
}
```
- [ ] **Step 2: Run both modules and verify missing primitives**
Run:
```bash
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test
```
Expected: FAIL because security and observation types are missing.
- [ ] **Step 3: Implement closed credential profiles and low-cardinality tags**
```java
package io.backend.skeleton.messaging.security;
public sealed interface BrokerCredentialProfile
permits BrokerCredentialProfile.SaslScram, BrokerCredentialProfile.OAuth2,
BrokerCredentialProfile.MutualTls, BrokerCredentialProfile.UsernamePassword,
BrokerCredentialProfile.Nkey {
String credentialId();
record SaslScram(String credentialId) implements BrokerCredentialProfile {}
record OAuth2(String credentialId) implements BrokerCredentialProfile {}
record MutualTls(String credentialId) implements BrokerCredentialProfile {}
record UsernamePassword(String credentialId) implements BrokerCredentialProfile {}
record Nkey(String credentialId) implements BrokerCredentialProfile {}
}
```
```java
package io.backend.skeleton.messaging.observation;
public record MessagingTags(
String broker,
String destinationProfile,
String operation,
String outcome,
String failureCategory,
String retryStage) {
}
```
Implement redaction using a fixed denylist for message ID, keys, credentials, payload, cookies, authorization, and exception messages. Validate production TLS and the separation of producer, consumer, and admin credential IDs.
- [ ] **Step 4: Run security and observability tests**
Run:
```bash
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-security modules/messaging/messaging-observability
git commit -m "feat: add messaging security and observation primitives"
```
---
### Task 12: Broker-neutral Contract Testkit 기반 구현
**Files:**
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterHarness.java`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterContract.java`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractMessage.java`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/FaultController.java`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractAssertions.java`
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryMessagingHarness.java`
- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryHarnessContractTest.java`
**Interfaces:**
- Consumes Core API and transport SPI.
- Produces the common adapter suite invoked by Kafka, Rabbit, Pulsar, and NATS tests.
- Contract covers confirmation, ambiguity, redelivery, settlement, DLQ failure, identity preservation, payload/header limits, and shutdown.
- [ ] **Step 1: Write failing in-memory harness contract**
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Nested;
class InMemoryHarnessContractTest {
@Nested
class Contract extends MessagingAdapterContract {
@Override
protected MessagingAdapterHarness harness() {
return InMemoryMessagingHarness.create();
}
}
}
```
The abstract contract must contain concrete `@Test` methods named:
```text
publishesAndConfirms
returnsAmbiguousWhenConfirmIsLost
redeliversWhenSettlementIsLost
preservesMessageIdAcrossRetryAndDlq
keepsSourceUnsettledWhenDlqPublishFails
rejectsOversizedPayloadBeforeTransport
stopsAcceptingNewWorkDuringShutdown
```
- [ ] **Step 2: Run testkit tests and verify missing abstract suite**
Run:
```bash
./gradlew :modules:messaging:messaging-testkit:test
```
Expected: FAIL because the testkit contract is missing.
- [ ] **Step 3: Implement the abstract contract and deterministic in-memory harness**
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Test;
public abstract class MessagingAdapterContract {
protected abstract MessagingAdapterHarness harness();
@Test
void publishesAndConfirms() {
MessagingAdapterHarness harness = harness();
ContractAssertions.assertConfirmed(
harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join());
}
@Test
void returnsAmbiguousWhenConfirmIsLost() {
MessagingAdapterHarness harness = harness();
harness.faults().dropPublishConfirmation();
ContractAssertions.assertAmbiguous(
harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join());
}
}
```
Implement the remaining named tests with deterministic latches. The in-memory harness exists only to validate the contract itself and is not a production adapter.
- [ ] **Step 4: Run testkit tests**
Run:
```bash
./gradlew :modules:messaging:messaging-testkit:test
```
Expected: PASS with every abstract contract method executed by the in-memory harness.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-testkit
git commit -m "test: add messaging adapter contract suite"
```
---
### Task 13: Retry Decision Engine 구현
**Files:**
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryContext.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecision.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecisionEngine.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DefaultRetryDecisionEngine.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/BackoffCalculator.java`
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/RetryDecisionEngineTest.java`
**Interfaces:**
- Consumes Task 9 policies, Task 8 failure descriptors, and Task 4 capabilities.
- Produces `RetryInline`, `PauseAndRetry`, `PublishToRetryDestination`, `DeadLetter`, or `Reject` decisions.
- [ ] **Step 1: Write failing retry decision tests**
```java
package io.backend.skeleton.messaging.policy;
import io.backend.skeleton.messaging.api.error.FailureCategory;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RetryDecisionEngineTest {
private final DefaultRetryDecisionEngine engine = new DefaultRetryDecisionEngine(
new BackoffCalculator());
@Test
void deserializationFailureGoesDirectlyToParking() {
RetryDecision decision = engine.decide(
RetryContextFixtures.failure(FailureCategory.DESERIALIZATION, 1));
assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class);
}
@Test
void transientOrderedKafkaFailureUsesPauseStrategy() {
RetryDecision decision = engine.decide(
RetryContextFixtures.orderedKafkaTransient(1));
assertThat(decision).isInstanceOf(RetryDecision.PauseAndRetry.class);
}
@Test
void exhaustedAttemptGoesToDeadLetter() {
RetryDecision decision = engine.decide(
RetryContextFixtures.transientAtMaximumAttempt());
assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class);
}
}
```
- [ ] **Step 2: Run focused tests and verify missing engine**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test --tests '*RetryDecisionEngineTest'
```
Expected: FAIL because retry decision types are missing.
- [ ] **Step 3: Implement deterministic decision and exponential jittered backoff**
```java
package io.backend.skeleton.messaging.policy;
public interface RetryDecisionEngine {
RetryDecision decide(RetryContext context);
}
```
Implement the decision order exactly:
```text
non-retryable category
→ parking or reject
attempt >= maxAttempts
→ dead letter
ordering preserve + Kafka partition capability
→ pause and retry
retry destination mode + reorder allowed
→ publish to retry destination
inline or blocking mode
→ retry inline
otherwise
→ dead letter
```
Backoff is `min(maxDelay, initialDelay * multiplier^(attempt-1))` and applies full jitter when enabled.
- [ ] **Step 4: Run policy tests**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-policy
git commit -m "feat: add messaging retry decision engine"
```
---
### Task 14: 공통 DLQ·Parking Orchestrator 구현
**Files:**
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterMetadata.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterEnvelopeFactory.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestrator.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterResult.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SourceSettlement.java`
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestratorTest.java`
**Interfaces:**
- Consumes `MessagePublisher`, `MessageDelivery`, failure descriptor, and source settlement callback.
- Produces the publish-confirm-before-source-settlement invariant used by every adapter.
- [ ] **Step 1: Write failing DLQ sequencing tests**
```java
package io.backend.skeleton.messaging.policy;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
class DeadLetterOrchestratorTest {
@Test
void settlesSourceOnlyAfterDlqConfirmation() {
AtomicBoolean sourceSettled = new AtomicBoolean(false);
FakePublisher publisher = FakePublisher.confirming();
DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(publisher);
orchestrator.deadLetter(
DeadLetterFixtures.delivery(),
DeadLetterFixtures.failure(),
() -> sourceSettled.set(true)).toCompletableFuture().join();
assertThat(publisher.confirmObservedBefore(sourceSettled)).isTrue();
}
@Test
void keepsSourceUnsettledWhenDlqPublishFails() {
AtomicBoolean sourceSettled = new AtomicBoolean(false);
DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(
FakePublisher.ambiguous());
DeadLetterResult result = orchestrator.deadLetter(
DeadLetterFixtures.delivery(),
DeadLetterFixtures.failure(),
() -> sourceSettled.set(true)).toCompletableFuture().join();
assertThat(result.sourceSettled()).isFalse();
assertThat(sourceSettled).isFalse();
}
}
```
- [ ] **Step 2: Run focused test and verify missing orchestrator**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test --tests '*DeadLetterOrchestratorTest'
```
Expected: FAIL because DLQ orchestration types are missing.
- [ ] **Step 3: Implement confirmed publish before settlement**
```java
package io.backend.skeleton.messaging.policy;
import java.util.concurrent.CompletionStage;
public interface SourceSettlement {
CompletionStage<Void> settle();
}
```
`DeadLetterOrchestrator.deadLetter` must:
```text
create dead-letter envelope with original messageId
publish to configured DLQ
if PublishCompletion.CONFIRMED then call source settlement
if REJECTED or AMBIGUOUS then do not call source settlement
return a result containing DLQ publish result and settlement state
```
Do not copy the full stack trace or secret headers into the dead-letter envelope.
- [ ] **Step 4: Run policy tests**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-policy
git commit -m "feat: add confirmed dead letter orchestration"
```
---
### Task 15: Kafka Test Topology와 Stable Profile Guard 구현
**Files:**
- Modify: `modules/messaging/messaging-kafka/build.gradle.kts`
- Create: `infra/messaging/kafka/docker-compose.yml`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBrokerProfile.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidator.java`
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidatorTest.java`
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerFixture.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerSmokeTest.java`
**Interfaces:**
- Consumes destination policy and security profile.
- Produces validated Kafka client configuration and Testcontainers topology for later adapter tests.
- Stable profile requires idempotence, `acks=all`, max in-flight at most five, manual consumer commit, and TLS/auth in production.
- [ ] **Step 1: Write failing Kafka profile guard tests**
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class KafkaProfileValidatorTest {
private final KafkaProfileValidator validator = new KafkaProfileValidator();
@Test
void stableProducerRequiresIdempotenceAndAcksAll() {
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer(
false, "1", 5);
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("idempotence");
}
@Test
void stableProducerRejectsTooManyInFlightRequests() {
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer(
true, "all", 6);
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("max.in.flight");
}
@Test
void consumerAutoCommitIsForbidden() {
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withAutoCommit(true);
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("auto commit");
}
}
```
- [ ] **Step 2: Run Kafka profile tests and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest'
```
Expected: FAIL because profile classes are missing.
- [ ] **Step 3: Implement validation and the Kafka 4.3.x container fixture**
```java
package io.backend.skeleton.messaging.kafka;
public final class KafkaProfileValidator {
public void validate(KafkaBrokerProfile profile) {
if (profile.stable()
&& (!profile.enableIdempotence() || !"all".equals(profile.acks()))) {
throw new IllegalArgumentException(
"stable Kafka producer requires idempotence and acks=all");
}
if (profile.maxInFlightRequestsPerConnection() > 5) {
throw new IllegalArgumentException(
"max.in.flight.requests.per.connection must be at most 5");
}
if (profile.enableAutoCommit()) {
throw new IllegalArgumentException("consumer auto commit is forbidden");
}
}
}
```
Create a KRaft-based Kafka 4.3.x Testcontainer fixture. Expose a factory for producer, consumer, topic creation, broker stop, and broker restart. Keep image tags centralized in the fixture.
- [ ] **Step 4: Run Kafka profile and container smoke tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' --tests '*KafkaContainerSmokeTest'
```
Expected: PASS and the broker reports a 4.3.x version.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka infra/messaging/kafka
git commit -m "feat: add kafka stable profile guard"
```
---
### Task 16: Kafka Producer Adapter와 Publish Evidence 구현
**Files:**
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaMessagingTransport.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishMapper.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapper.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPosition.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishFailureClassifier.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProducerContractTest.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapperTest.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaPublishAmbiguityIT.java`
**Interfaces:**
- Consumes transport SPI, schema codec, policy profile, and Kafka profile.
- Produces Stable Kafka publish implementation and `KafkaPosition(topic, partition, offset)`.
- Maps serializer and authorization failures to `REJECTED`; post-send confirmation loss to `AMBIGUOUS`.
- [ ] **Step 1: Write failing producer contract and header mapping tests**
```java
package io.backend.skeleton.messaging.kafka;
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
import org.junit.jupiter.api.Nested;
class KafkaProducerContractTest {
@Nested
class Contract extends MessagingAdapterContract {
@Override
protected MessagingAdapterHarness harness() {
return KafkaHarnessFixture.publisherHarness();
}
}
}
```
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class KafkaHeaderMapperTest {
@Test
void writesLogicalIdentityAndSchemaHeaders() {
var headers = new KafkaHeaderMapper().toKafkaHeaders(
KafkaFixtures.orderCreatedEnvelope());
assertThat(new String(headers.lastHeader("msg.type").value()))
.isEqualTo("order.created");
assertThat(new String(headers.lastHeader("msg.schema-version").value()))
.isEqualTo("1");
}
}
```
- [ ] **Step 2: Run Kafka producer tests and verify missing adapter**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaHeaderMapperTest'
```
Expected: FAIL because the transport and mapper are missing.
- [ ] **Step 3: Implement publish mapping and failure classification**
```java
package io.backend.skeleton.messaging.kafka;
import io.backend.skeleton.messaging.api.publish.PublishCompletion;
import io.backend.skeleton.messaging.api.publish.PublishResult;
import io.backend.skeleton.messaging.transport.MessagingTransport;
import io.backend.skeleton.messaging.transport.TransportPublishRequest;
import java.util.concurrent.CompletionStage;
public final class KafkaMessagingTransport implements MessagingTransport {
private final KafkaTemplate<byte[], byte[]> template;
private final KafkaPublishMapper mapper;
private final KafkaPublishFailureClassifier failures;
@Override
public CompletionStage<TransportPublishResult> publish(
TransportPublishRequest request) {
ProducerRecord<byte[], byte[]> record = mapper.toRecord(request);
return template.send(record).handle((result, error) -> {
if (error != null) {
return failures.classify(request, error);
}
return KafkaPublishMapper.confirmed(result.getRecordMetadata());
});
}
}
```
The failure classifier must return `AMBIGUOUS` for delivery timeout or connection failure after the record entered the producer, and `REJECTED` for serialization, invalid topic, authentication, authorization, and producer fencing.
- [ ] **Step 4: Run producer contract and ambiguity integration tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaPublishAmbiguityIT'
```
Expected: PASS. The ambiguity test drops the confirmation path after the broker accepted the record and receives `PublishCompletion.AMBIGUOUS`.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka
git commit -m "feat: add kafka publish evidence adapter"
```
---
### Task 17: Kafka Consumer Group, Partition Coordinator, Contiguous Offset Commit 구현
**Files:**
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeliveryMapper.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionWorkCoordinator.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionOffsetTracker.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTracker.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementCommand.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementQueue.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTrackerTest.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerSettlementIT.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRebalanceIT.java`
**Interfaces:**
- Consumes `MessageHandler`, retry decision, transport consumer SPI, and Kafka profile.
- Produces a Stable traditional consumer-group implementation.
- Handler workers never call Kafka consumer methods directly; the poll thread drains settlement commands.
- [ ] **Step 1: Write failing contiguous offset and settlement-order tests**
```java
package io.backend.skeleton.messaging.kafka;
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ContiguousPartitionOffsetTrackerTest {
@Test
void commitsOnlyThroughHighestContiguousCompletedOffset() {
TopicPartition partition = new TopicPartition("orders", 0);
ContiguousPartitionOffsetTracker tracker = new ContiguousPartitionOffsetTracker();
tracker.delivered(partition, 10);
tracker.delivered(partition, 11);
tracker.delivered(partition, 12);
tracker.completed(partition, 10);
tracker.completed(partition, 12);
assertThat(tracker.highestContiguousCompleted(partition)).hasValue(10);
tracker.completed(partition, 11);
assertThat(tracker.highestContiguousCompleted(partition)).hasValue(12);
}
}
```
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class KafkaConsumerSettlementIT {
@Test
void doesNotCommitOffsetBeforeHandlerSuccess() {
KafkaConsumerHarness harness = KafkaConsumerHarness.startPausedHandler();
harness.publish("order.created", "o-1");
harness.awaitHandlerStarted();
assertThat(harness.committedOffset()).isEmpty();
harness.completeHandlerSuccessfully();
assertThat(harness.awaitCommittedOffset()).hasValue(1L);
}
}
```
- [ ] **Step 2: Run focused tests and verify missing coordinator**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT'
```
Expected: FAIL because consumer coordinator classes are missing.
- [ ] **Step 3: Implement partition pause, bounded workers, and poll-thread settlement**
```java
package io.backend.skeleton.messaging.kafka;
import org.apache.kafka.common.TopicPartition;
import java.util.OptionalLong;
public interface PartitionOffsetTracker {
void delivered(TopicPartition partition, long offset);
void completed(TopicPartition partition, long offset);
OptionalLong highestContiguousCompleted(TopicPartition partition);
}
```
Implement `PartitionWorkCoordinator` with one in-flight handler per partition for strict ordering. The poll thread must:
```text
poll records
record delivered offsets
pause partitions with in-flight work
dispatch handler to bounded worker
drain settlement queue on every poll
commit highest contiguous completed offset + 1
resume partition after terminal settlement
```
On rebalance revoke, stop dispatching new work, drain completed settlements within the revoke deadline, and leave unfinished offsets uncommitted for redelivery.
- [ ] **Step 4: Run consumer and rebalance tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' --tests '*KafkaConsumerRebalanceIT'
```
Expected: PASS. No offset beyond a gap is committed and unfinished deliveries are redelivered after rebalance.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka
git commit -m "feat: add kafka consumer settlement coordinator"
```
---
### Task 18: Kafka Ordered Retry, Retry Topic, DLT 구현
**Files:**
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryExecutor.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPartitionRetryScheduler.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicPublisher.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeadLetterPublisher.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryMetadataMapper.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaOrderedRetryIT.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicIdentityIT.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaDltFailureIT.java`
**Interfaces:**
- Consumes Task 13 retry decisions and Task 14 DLQ orchestrator.
- Produces `PAUSE_PARTITION` retry for order-preserving destinations and retry-topic flow for reorder-allowed destinations.
- Preserves logical message ID and settles the source only after retry/DLT confirmation.
- [ ] **Step 1: Write failing ordering and identity tests**
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class KafkaRetryTopicIdentityIT {
@Test
void retryTopicPreservesOriginalMessageIdAndIncrementsAttempt() {
KafkaRetryHarness harness = KafkaRetryHarness.start();
var original = harness.publishFailingMessage();
var retried = harness.awaitRetryTopicRecord();
assertThat(retried.messageId()).isEqualTo(original.messageId());
assertThat(retried.deliveryAttempt()).isEqualTo(2);
}
}
```
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class KafkaDltFailureIT {
@Test
void sourceOffsetRemainsUncommittedWhenDltPublishCannotBeConfirmed() {
KafkaRetryHarness harness = KafkaRetryHarness.withUnavailableDlt();
harness.publishPermanentlyFailingMessage();
assertThat(harness.awaitSourceOffsetCommit()).isEmpty();
assertThat(harness.sourcePartitionPaused()).isTrue();
}
}
```
- [ ] **Step 2: Run tests and verify missing retry implementation**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT'
```
Expected: FAIL because Kafka retry executors are missing.
- [ ] **Step 3: Implement the two retry paths and confirmed DLT flow**
`KafkaRetryExecutor` dispatches by decision:
```java
return switch (decision) {
case RetryDecision.PauseAndRetry retry ->
partitionRetryScheduler.schedule(delivery, retry.delay());
case RetryDecision.PublishToRetryDestination retry ->
retryTopicPublisher.publish(delivery, retry.destination(), retry.delay());
case RetryDecision.DeadLetter deadLetter ->
deadLetterPublisher.publish(delivery, deadLetter.failure());
case RetryDecision.Reject reject ->
settlement.reject(reject.failure());
case RetryDecision.RetryInline inline ->
partitionRetryScheduler.schedule(delivery, inline.delay());
};
```
For retry-topic and DLT paths, wait for `PublishCompletion.CONFIRMED` before placing a source commit command. On `REJECTED` or `AMBIGUOUS`, leave the source uncommitted and pause the partition.
- [ ] **Step 4: Run retry integration suite**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT'
```
Expected: PASS. Ordered retry never processes a later record first; retry-topic mode explicitly allows reorder and preserves identity.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka
git commit -m "feat: add kafka retry and dead letter flows"
```
---
### Task 19: Kafka Native Transaction Capability 구현
**Files:**
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalProcessor.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalDelivery.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalPublisher.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/SpringKafkaTransactionalProcessor.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionProfileValidator.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionIT.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionFencingIT.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReadCommittedIT.java`
**Interfaces:**
- Consumes Kafka adapter and M3 capability injection.
- Produces Kafka-only read-process-write transaction support.
- Explicitly excludes external DB and HTTP side effects from the guarantee.
- [ ] **Step 1: Write failing commit, abort, and read-committed tests**
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class KafkaTransactionIT {
@Test
void commitsOutputAndInputOffsetTogether() {
KafkaTransactionHarness harness = KafkaTransactionHarness.start();
harness.publishInput("i-1");
harness.processSuccessfully();
assertThat(harness.readCommittedOutput()).containsExactly("i-1-processed");
assertThat(harness.inputOffsetCommitted()).isTrue();
}
@Test
void abortHidesOutputAndLeavesInputOffsetUncommitted() {
KafkaTransactionHarness harness = KafkaTransactionHarness.start();
harness.publishInput("i-2");
harness.processWithFailure();
assertThat(harness.readCommittedOutput()).isEmpty();
assertThat(harness.inputOffsetCommitted()).isFalse();
}
}
```
- [ ] **Step 2: Run transaction tests and verify missing native capability**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT'
```
Expected: FAIL because transactional interfaces are missing.
- [ ] **Step 3: Implement M3 transaction boundary and profile guard**
```java
package io.backend.skeleton.messaging.kafka;
import java.util.concurrent.CompletionStage;
public interface KafkaTransactionalProcessor<K, V, R> {
CompletionStage<R> process(
KafkaTransactionalDelivery<K, V> delivery,
KafkaTransactionalPublisher publisher);
}
```
Use Spring Kafka transaction management so consumed offsets and produced Kafka records commit or abort together. Require a unique transactional ID prefix per instance. Reject registration when the destination profile declares `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL`; that path belongs to the Inbox recipe, not Kafka transaction.
- [ ] **Step 4: Run transaction, abort, fencing, and `read_committed` tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' --tests '*KafkaReadCommittedIT'
```
Expected: PASS. A fenced producer fails permanently and aborted output is invisible to `read_committed` consumers.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka
git commit -m "feat: add kafka transactional capability"
```
---
### Task 20: Kafka Replay, Seek, Topology Validation Admin Capability 구현
**Files:**
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayCapability.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlanner.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTopologyInspector.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaOffsetResetExecutor.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlannerTest.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTopologyValidationIT.java`
**Interfaces:**
- Consumes Admin API contracts implemented later through an internal adapter interface.
- Produces read-only replay planning, isolated replay consumer groups, topology inspection, and guarded offset reset.
- Existing production group reset is never performed without an approved M4 request.
- [ ] **Step 1: Write failing replay plan safety tests**
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class KafkaReplayPlannerTest {
@Test
void createsIsolatedConsumerGroupByDefault() {
KafkaReplayPlan plan = new KafkaReplayPlanner().plan(
KafkaReplayRequest.byTimestamp(
"order.events.v1", Instant.parse("2026-08-01T00:00:00Z")));
assertThat(plan.consumerGroup()).startsWith("replay-");
assertThat(plan.mutatesProductionOffsets()).isFalse();
}
@Test
void productionOffsetResetRequiresApproval() {
assertThatThrownBy(() -> new KafkaReplayPlanner().plan(
KafkaReplayRequest.productionResetWithoutApproval("order-projection")))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run replay tests and verify missing planner**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest'
```
Expected: FAIL because replay classes are missing.
- [ ] **Step 3: Implement isolated replay and topology inspection**
`KafkaReplayPlanner` supports:
```text
seek by absolute offset
seek by timestamp
replay to isolated consumer group
replay to a new destination
approved production offset reset
```
`KafkaTopologyInspector` returns partitions, replication factor, minimum ISR, retention, cleanup policy, and topic configuration drift. It performs no mutation.
- [ ] **Step 4: Run replay and topology tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' --tests '*KafkaTopologyValidationIT'
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka
git commit -m "feat: add kafka replay and topology capability"
```
---
### Task 21: Kafka Share Group Experimental Adapter 구현
**Files:**
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareWorkQueueCapability.java`
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareGroupRegistrar.java`
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidator.java`
- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidatorTest.java`
- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareDeliveryIT.java`
**Interfaces:**
- Consumes M1 handler and Kafka experimental client capability.
- Produces work-queue style record settlement only.
- Rejects ordered-stream profiles and remains disabled unless `backend.messaging.experimental.kafka-share=true`.
- [ ] **Step 1: Write failing ordering guard test**
```java
package io.backend.skeleton.messaging.kafka.share;
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class KafkaShareProfileValidatorTest {
@Test
void rejectsOrderedStreamUse() {
KafkaShareProfile profile = KafkaShareProfileFixtures.profile(OrderingScope.KEY);
assertThatThrownBy(() -> new KafkaShareProfileValidator().validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ordered stream");
}
}
```
- [ ] **Step 2: Run experimental module tests and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka-share-experimental:test
```
Expected: FAIL because the experimental adapter is missing.
- [ ] **Step 3: Implement record-level work queue registration and explicit acknowledgement mapping**
```java
package io.backend.skeleton.messaging.kafka.share;
public interface KafkaShareWorkQueueCapability {
<T> AutoCloseable register(
MessageDestination<T> destination,
MessageHandler<T> handler,
KafkaShareOptions options);
}
```
Allow only `DestinationKind.WORK_QUEUE` and `OrderingScope.NONE`. Map share delivery attempt to `DeliveryMetadata.deliveryAttempt` and preserve `messageId` through redelivery.
- [ ] **Step 4: Run validator and Kafka 4.2+ Share Group integration tests**
Run:
```bash
./gradlew :modules:messaging:messaging-kafka-share-experimental:test
```
Expected: PASS. The module does not participate in the default starter classpath.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-kafka-share-experimental
git commit -m "feat: add experimental kafka share groups"
```
---
### Task 22: RabbitMQ Test Topology와 Stable Profile Guard 구현
**Files:**
- Modify: `modules/messaging/messaging-rabbit/build.gradle.kts`
- Create: `infra/messaging/rabbitmq/docker-compose.yml`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBrokerProfile.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitTopologyProfile.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidator.java`
- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerFixture.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerSmokeTest.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidatorTest.java`
**Interfaces:**
- Consumes destination and security policies.
- Produces Rabbit 4.3.x connection/topology configuration and Testcontainers fixture.
- Stable durable work queue requires correlated confirm, returns, mandatory publish, manual ACK, and quorum queue.
- [ ] **Step 1: Write failing Rabbit profile guard tests**
```java
package io.backend.skeleton.messaging.rabbit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RabbitProfileValidatorTest {
private final RabbitProfileValidator validator = new RabbitProfileValidator();
@Test
void stablePublisherRequiresCorrelatedConfirmAndReturns() {
RabbitBrokerProfile profile = RabbitProfileFixtures.withPublisher(false, false, false);
assertThatThrownBy(() -> validator.validate(profile))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("confirm");
}
@Test
void durableWorkQueueRequiresQuorumQueue() {
RabbitTopologyProfile topology = RabbitProfileFixtures.classicDurableWorkQueue();
assertThatThrownBy(() -> validator.validate(topology))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("quorum");
}
@Test
void autoAckIsForbiddenForAtLeastOnce() {
RabbitTopologyProfile topology = RabbitProfileFixtures.autoAckTopology();
assertThatThrownBy(() -> validator.validate(topology))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("manual acknowledgement");
}
}
```
- [ ] **Step 2: Run Rabbit profile tests and verify missing model**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest'
```
Expected: FAIL because Rabbit profile classes are missing.
- [ ] **Step 3: Implement exact Stable profile rules and Rabbit 4.3.x fixture**
```java
package io.backend.skeleton.messaging.rabbit;
public final class RabbitProfileValidator {
public void validate(RabbitBrokerProfile profile) {
if (profile.stable()
&& (!profile.correlatedConfirms()
|| !profile.publisherReturns()
|| !profile.mandatory())) {
throw new IllegalArgumentException(
"stable Rabbit publisher requires correlated confirm, returns, and mandatory publish");
}
}
public void validate(RabbitTopologyProfile topology) {
if (topology.durableWorkQueue() && topology.queueType() != RabbitQueueType.QUORUM) {
throw new IllegalArgumentException("durable work queue requires quorum queue");
}
if (topology.atLeastOnce() && topology.autoAck()) {
throw new IllegalArgumentException("at-least-once requires manual acknowledgement");
}
}
}
```
Create a RabbitMQ 4.3.x container fixture with management API support and helpers for quorum queue creation, node pause/restart, connection blocking, and queue depth inspection.
- [ ] **Step 4: Run profile and container smoke tests**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' --tests '*RabbitContainerSmokeTest'
```
Expected: PASS and the server reports a 4.3.x version.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-rabbit infra/messaging/rabbitmq
git commit -m "feat: add rabbit stable profile guard"
```
---
### Task 23: Rabbit Publisher Confirm·Return Evidence Adapter 구현
**Files:**
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishTracker.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishOutcome.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitHeaderMapper.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishFailureClassifier.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishReference.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProducerContractTest.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitUnroutableIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConfirmLossIT.java`
**Interfaces:**
- Consumes transport SPI, schema codec, and Rabbit profile.
- Produces publish result by joining publisher confirm and mandatory return.
- Confirm ACK with unroutable return is `REJECTED`, never confirmed success.
- [ ] **Step 1: Write failing producer contract and unroutable tests**
```java
package io.backend.skeleton.messaging.rabbit;
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
import org.junit.jupiter.api.Nested;
class RabbitProducerContractTest {
@Nested
class Contract extends MessagingAdapterContract {
@Override
protected MessagingAdapterHarness harness() {
return RabbitHarnessFixture.publisherHarness();
}
}
}
```
```java
package io.backend.skeleton.messaging.rabbit;
import io.backend.skeleton.messaging.api.publish.PublishCompletion;
import io.backend.skeleton.messaging.api.publish.RoutingOutcome;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RabbitUnroutableIT {
@Test
void confirmedButUnroutableMessageIsRejected() {
var result = RabbitHarnessFixture.publishToUnboundRoutingKey();
assertThat(result.completion()).isEqualTo(PublishCompletion.REJECTED);
assertThat(result.routingOutcome()).isEqualTo(RoutingOutcome.UNROUTABLE);
}
}
```
- [ ] **Step 2: Run producer tests and verify missing tracker**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT'
```
Expected: FAIL because Rabbit transport and confirm tracker are missing.
- [ ] **Step 3: Implement correlation of confirm and return**
```java
package io.backend.skeleton.messaging.rabbit;
public record RabbitPublishOutcome(
RabbitConfirmOutcome confirm,
RoutingOutcome routing,
java.util.Optional<String> replyCode,
java.util.Optional<String> replyText) {
}
```
`RabbitPublishTracker` must create one pending state per publish sequence, accept a return callback before confirm, and complete only when the result is determinable:
```text
return received + confirm ACK → REJECTED/UNROUTABLE
confirm ACK + no return by callback ordering boundary → CONFIRMED/ROUTED
confirm NACK → REJECTED
channel close or timeout before terminal evidence → AMBIGUOUS
```
Use sanitized reply metadata and never include body content in exceptions.
- [ ] **Step 4: Run confirm, return, and connection-loss tests**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' --tests '*RabbitConfirmLossIT'
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-rabbit
git commit -m "feat: add rabbit publish confirm evidence"
```
---
### Task 24: Rabbit Consumer Manual ACK, Prefetch, Async Settlement 구현
**Files:**
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryMapper.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryCoordinator.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementQueue.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementCommand.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerSettlementIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitPrefetchIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitAckLossIT.java`
**Interfaces:**
- Consumes M1 handler, retry engine, and Rabbit container factory.
- Produces manual-ack consumer flow with bounded concurrency and prefetch.
- Channel and delivery tag are never exposed to application handlers.
- [ ] **Step 1: Write failing settlement and prefetch tests**
```java
package io.backend.skeleton.messaging.rabbit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RabbitConsumerSettlementIT {
@Test
void messageRemainsUnackedUntilHandlerSuccess() {
RabbitConsumerHarness harness = RabbitConsumerHarness.startPausedHandler();
harness.publish("work-1");
harness.awaitHandlerStarted();
assertThat(harness.unackedCount()).isEqualTo(1);
assertThat(harness.readyCount()).isZero();
harness.completeHandlerSuccessfully();
assertThat(harness.awaitUnackedCount()).isZero();
}
}
```
```java
package io.backend.skeleton.messaging.rabbit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RabbitPrefetchIT {
@Test
void unackedDeliveriesNeverExceedConfiguredPrefetch() {
RabbitConsumerHarness harness = RabbitConsumerHarness.withPrefetch(4);
harness.publishRange(10);
harness.blockAllHandlers();
assertThat(harness.awaitUnackedCount()).isLessThanOrEqualTo(4);
}
}
```
- [ ] **Step 2: Run consumer tests and verify missing coordinator**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT'
```
Expected: FAIL because the consumer implementation is missing.
- [ ] **Step 3: Implement manual ACK after handler completion and channel-safe settlement queue**
`RabbitDeliveryCoordinator` must:
```text
receive delivery on listener container thread
map envelope and delivery metadata
dispatch handler to bounded executor
place terminal settlement command in a channel-bound queue
execute ACK/NACK/reject on the owning consumer channel context
```
If the channel closes after handler success but before ACK confirmation, return `SettlementCompletion.UNKNOWN` and allow broker redelivery. The handler is never reinvoked locally as a substitute for broker redelivery.
- [ ] **Step 4: Run settlement, prefetch, and ACK-loss tests**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' --tests '*RabbitAckLossIT'
```
Expected: PASS. ACK loss causes redelivery with `redelivered=true`.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-rabbit
git commit -m "feat: add rabbit consumer settlement flow"
```
---
### Task 25: Rabbit Retry Queue, Confirmed DLQ, RequestReply M2 구현
**Files:**
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryExecutor.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryTopology.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeadLetterPublisher.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitNativeDeadLetterCapability.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyClient.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRetryIdentityIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitDlxTargetOutageIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyTimeoutIT.java`
**Interfaces:**
- Consumes Task 13 retry decisions and Task 14 DLQ orchestration.
- Produces retry queue with bounded attempt metadata, platform-managed confirmed DLQ, optional native quorum at-least-once DLX, and limited requestreply.
- [ ] **Step 1: Write failing retry identity and DLQ outage tests**
```java
package io.backend.skeleton.messaging.rabbit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RabbitRetryIdentityIT {
@Test
void retryQueuePreservesLogicalMessageIdentity() {
RabbitRetryHarness harness = RabbitRetryHarness.start();
var first = harness.publishFailingWork();
var retry = harness.awaitRetryDelivery();
assertThat(retry.messageId()).isEqualTo(first.messageId());
assertThat(retry.deliveryAttempt()).isEqualTo(2);
}
}
```
```java
package io.backend.skeleton.messaging.rabbit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RabbitDlxTargetOutageIT {
@Test
void sourceMessageIsNotLostWhenDeadLetterTargetIsUnavailable() {
RabbitRetryHarness harness = RabbitRetryHarness.withUnavailableDlqTarget();
harness.publishPermanentFailure();
assertThat(harness.sourceUnackedOrReadyCount()).isEqualTo(1);
assertThat(harness.deadLetterCount()).isZero();
}
}
```
- [ ] **Step 2: Run retry and requestreply tests and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'
```
Expected: FAIL because retry and requestreply types are missing.
- [ ] **Step 3: Implement bounded retry routing and requestreply lifecycle**
Retry queue rules:
```text
increment platform delivery attempt
preserve original messageId
encode retry delay in topology policy, not arbitrary message expiration
cap cycle using platform attempt plus x-death inspection
confirm retry publish before source ACK
```
Native quorum at-least-once dead-letter capability must validate `dead-letter-strategy=at-least-once`, target availability policy, and overflow compatibility before activation.
`RabbitRequestReplyClient` requires correlation ID and finite timeout, removes late replies, and never reuses requestreply as the default RPC mechanism.
- [ ] **Step 4: Run Rabbit retry, DLQ, and requestreply tests**
Run:
```bash
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-rabbit
git commit -m "feat: add rabbit retry dlq and request reply"
```
---
### Task 26: Reliability API와 Outbox·Inbox Flyway Schema 구현
**Files:**
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/ReliableMessagePublisher.java`
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/IdempotentMessageHandler.java`
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/TransactionalMessageAction.java`
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/InboxResult.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/db/migration/messaging-outbox/V1__create_messaging_outbox.sql`
- Create: `modules/messaging/messaging-inbox-jpa/src/main/resources/db/migration/messaging-inbox/V1__create_messaging_inbox.sql`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxSchemaIT.java`
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxSchemaIT.java`
**Interfaces:**
- Consumes Core destination and envelope types.
- Produces broker-neutral reliability APIs and the exact PostgreSQL schema from the design.
- Uses PostgreSQL 16 Testcontainers and Flyway migration locations scoped to each module.
- [ ] **Step 1: Write failing schema tests**
```java
package io.backend.skeleton.messaging.outbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OutboxSchemaIT extends PostgreSqlReliabilityFixture {
@Test
void createsOutboxWithUniqueLogicalMessageIdAndPollIndex() {
migrate("classpath:db/migration/messaging-outbox");
assertThat(columnNames("messaging_outbox"))
.contains("message_id", "destination", "payload", "status",
"lease_owner", "lease_until", "version");
assertThat(uniqueConstraintColumns("messaging_outbox"))
.containsExactlyInAnyOrder("message_id");
assertThat(indexNames("messaging_outbox"))
.contains("ix_messaging_outbox_poll", "ix_messaging_outbox_lease");
}
}
```
```java
package io.backend.skeleton.messaging.inbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class InboxSchemaIT extends PostgreSqlReliabilityFixture {
@Test
void usesConsumerNameAndMessageIdAsPrimaryKey() {
migrate("classpath:db/migration/messaging-inbox");
assertThat(primaryKeyColumns("messaging_inbox"))
.containsExactly("consumer_name", "message_id");
}
}
```
- [ ] **Step 2: Run reliability schema tests and verify missing migrations**
Run:
```bash
./gradlew :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test
```
Expected: FAIL because migration files and reliability APIs are missing.
- [ ] **Step 3: Implement exact APIs and SQL migrations**
```java
package io.backend.skeleton.messaging.reliability;
public interface ReliableMessagePublisher {
<T> void addToOutbox(
MessageDestination<T> destination,
MessageEnvelope<T> message);
}
```
Create `messaging_outbox` and `messaging_inbox` exactly as defined in the design. Use `bytea` payload, `jsonb` headers, UUID message IDs, `timestamptz`, poll/lease/expiry indexes, and no broker-specific columns.
- [ ] **Step 4: Run schema and API tests**
Run:
```bash
./gradlew :modules:messaging:messaging-reliability-api:test :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-reliability-api modules/messaging/messaging-outbox-jpa modules/messaging/messaging-inbox-jpa
git commit -m "feat: add messaging reliability schema"
```
---
### Task 27: Transactional Outbox Repository와 Ambiguous-safe Relay 구현
**Files:**
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRecord.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxStatus.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRepository.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepository.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRelay.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRetryScheduler.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxCleanupJob.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxProperties.java`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepositoryIT.java`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxRelayCrashIT.java`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxAmbiguousPublishIT.java`
**Interfaces:**
- Consumes `MessagePublisher`, `MessageCodecRegistry`, and reliability API.
- Produces `SELECT FOR UPDATE SKIP LOCKED` leasing, same-message-ID retry, and PENDING/CLAIMED/PUBLISHED/RETRYABLE_FAILURE/PARKED transitions.
- [ ] **Step 1: Write failing lease and ambiguity tests**
```java
package io.backend.skeleton.messaging.outbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class JdbcOutboxRepositoryIT extends OutboxPostgresFixture {
@Test
void concurrentRelaysClaimDisjointRows() throws Exception {
insertPendingMessages(200);
var first = java.util.concurrent.CompletableFuture.supplyAsync(
() -> repository("relay-a").claimBatch(100));
var second = java.util.concurrent.CompletableFuture.supplyAsync(
() -> repository("relay-b").claimBatch(100));
var firstIds = first.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet());
var secondIds = second.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet());
assertThat(firstIds).doesNotContainAnyElementsOf(secondIds);
assertThat(firstIds).hasSize(100);
assertThat(secondIds).hasSize(100);
}
}
```
```java
package io.backend.skeleton.messaging.outbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OutboxAmbiguousPublishIT extends OutboxPostgresFixture {
@Test
void retriesAmbiguousPublishWithSameLogicalMessageId() {
var row = insertPendingMessage();
FakeMessagePublisher publisher = FakeMessagePublisher.ambiguousThenConfirmed();
OutboxRelay relay = relay(publisher);
relay.runOnce();
relay.runOnce();
assertThat(publisher.publishedMessageIds())
.containsExactly(row.messageId(), row.messageId());
assertThat(find(row.id()).status()).isEqualTo(OutboxStatus.PUBLISHED);
}
}
```
- [ ] **Step 2: Run focused tests and verify missing repository**
Run:
```bash
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*JdbcOutboxRepositoryIT' --tests '*OutboxAmbiguousPublishIT'
```
Expected: FAIL because repository and relay classes are missing.
- [ ] **Step 3: Implement lease query, state transitions, and relay**
The claim query is exact:
```sql
select id
from messaging_outbox
where status in ('PENDING', 'RETRYABLE_FAILURE')
and available_at <= now()
and coalesce(next_attempt_at, available_at) <= now()
and (lease_until is null or lease_until < now())
order by created_at
for update skip locked
limit :batch_size
```
Within the claim transaction, set `status='CLAIMED'`, increment attempts, set lease owner and lease until. Relay mapping:
```text
CONFIRMED → PUBLISHED, published_at, clear lease
REJECTED retryable → RETRYABLE_FAILURE, next_attempt_at
REJECTED permanent → PARKED
AMBIGUOUS → RETRYABLE_FAILURE with same messageId
```
Default batch is 100, lease 30 seconds, poll interval 500 ms. A shutdown releases leases owned by the current instance. `OutboxCleanupJob` deletes or archives only `PUBLISHED` rows older than the configured audit retention and never removes active or parked rows.
- [ ] **Step 4: Run repository, crash, lease-expiry, and ambiguity tests**
Run:
```bash
./gradlew :modules:messaging:messaging-outbox-jpa:test
```
Expected: PASS. A process stop after DB commit and before publish resumes later; a stop after broker acceptance and before confirm can duplicate publish but keeps the same message ID.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-outbox-jpa
git commit -m "feat: add transactional outbox relay"
```
---
### Task 28: Inbox Transactional Idempotent Consumer 구현
**Files:**
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRepository.java`
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/JdbcInboxRepository.java`
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandler.java`
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRetentionPolicy.java`
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxCleanupJob.java`
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandlerIT.java`
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxConcurrentDuplicateIT.java`
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxRollbackIT.java`
**Interfaces:**
- Consumes `IdempotentMessageHandler`, `MessageDelivery`, and Spring transaction management.
- Produces exactly one DB mutation for duplicate deliveries when the mutation and Inbox insert share the same transaction.
- [ ] **Step 1: Write failing duplicate and rollback tests**
```java
package io.backend.skeleton.messaging.inbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TransactionalInboxHandlerIT extends InboxPostgresFixture {
@Test
void duplicateDeliveryRunsBusinessMutationOnce() {
var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000001");
TransactionalInboxHandler<String> handler = handlerThatIncrementsBusinessCounter();
handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join();
handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join();
assertThat(businessCounter()).isEqualTo(1);
assertThat(inboxRowCount()).isEqualTo(1);
}
}
```
```java
package io.backend.skeleton.messaging.inbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InboxRollbackIT extends InboxPostgresFixture {
@Test
void businessFailureRollsBackInboxInsert() {
var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000002");
assertThatThrownBy(() -> handler().handleOnce(
"order-projection", delivery, failingAction()).toCompletableFuture().join())
.hasCauseInstanceOf(IllegalStateException.class);
assertThat(inboxRowCount()).isZero();
assertThat(businessCounter()).isZero();
}
}
```
- [ ] **Step 2: Run Inbox tests and verify missing implementation**
Run:
```bash
./gradlew :modules:messaging:messaging-inbox-jpa:test --tests '*TransactionalInboxHandlerIT' --tests '*InboxRollbackIT'
```
Expected: FAIL because Inbox repository and handler are missing.
- [ ] **Step 3: Implement insert-first transactional deduplication**
```java
package io.backend.skeleton.messaging.inbox;
public interface InboxRepository {
boolean insertIfAbsent(
String consumerName,
MessageDelivery<?> delivery,
java.time.Instant expiresAt);
}
```
`TransactionalInboxHandler.handleOnce` runs inside one DB transaction:
```text
insert Inbox row with ON CONFLICT DO NOTHING
if inserted=false return InboxResult.DUPLICATE
execute business action
commit Inbox row and business mutation together
return InboxResult.PROCESSED
```
Calculate `expiresAt` from broker retention + DLQ retention + maximum replay/redrive horizon + configured safety margin. Reject a configured Inbox retention shorter than that horizon.
- [ ] **Step 4: Run duplicate, concurrent, rollback, and cleanup tests**
Run:
```bash
./gradlew :modules:messaging:messaging-inbox-jpa:test
```
Expected: PASS. Two concurrent deliveries produce one business mutation, and a failed business transaction leaves no Inbox row.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-inbox-jpa
git commit -m "feat: add transactional inbox consumer"
```
---
### Task 29: Claim Check API와 Integrity·Retention Guard 구현
**Files:**
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckReference.java`
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckStore.java`
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPublisher.java`
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolver.java`
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPolicy.java`
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckIntegrityException.java`
- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolverTest.java`
- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckRetentionValidatorTest.java`
**Interfaces:**
- Consumes the future objectstorage/fileserver port through `ClaimCheckStore` without importing provider types.
- Produces large-payload upload, reference envelope, checksum validation, and retention validation.
- Never places a public signed URL in the message.
- [ ] **Step 1: Write failing integrity and URL-rejection tests**
```java
package io.backend.skeleton.messaging.claimcheck;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ClaimCheckResolverTest {
@Test
void rejectsPublicHttpUrlAsObjectIdentifier() {
assertThatThrownBy(() -> new ClaimCheckReference(
"object-storage",
"https://storage.example.com/signed?token=secret",
2048,
"SHA-256",
"abc",
"application/octet-stream",
Instant.now().plusSeconds(3600)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsChecksumMismatch() {
ClaimCheckResolver resolver = ClaimCheckFixtures.resolverReturning("tampered");
assertThatThrownBy(() -> resolver.resolve(ClaimCheckFixtures.reference()).toCompletableFuture().join())
.hasCauseInstanceOf(ClaimCheckIntegrityException.class);
}
}
```
- [ ] **Step 2: Run claim-check tests and verify missing types**
Run:
```bash
./gradlew :modules:messaging:messaging-claim-check:test
```
Expected: FAIL because claim-check types are missing.
- [ ] **Step 3: Implement internal object identifiers, checksum, size, and retention validation**
```java
package io.backend.skeleton.messaging.claimcheck;
public record ClaimCheckReference(
String store,
String objectId,
long size,
String checksumAlgorithm,
String checksum,
String contentType,
java.time.Instant expiresAt) {
public ClaimCheckReference {
if (objectId.contains("://")) {
throw new IllegalArgumentException("claim check objectId must not be a public URL");
}
}
}
```
`ClaimCheckPublisher` writes the payload, computes SHA-256, and publishes only the reference. `ClaimCheckResolver` streams the object, enforces declared size, and verifies checksum before returning it to the handler. Validate object expiry against message retention, retry, DLQ, and redrive horizon.
- [ ] **Step 4: Run claim-check tests**
Run:
```bash
./gradlew :modules:messaging:messaging-claim-check:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-claim-check
git commit -m "feat: add messaging claim check"
```
---
### Task 30: Avro·Protobuf Codec와 Schema Registry Compatibility Gate 구현
**Files:**
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaRegistry.java`
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibilityValidator.java`
- Create: `modules/messaging/messaging-schema-avro/src/main/java/io/backend/skeleton/messaging/schema/avro/AvroMessageCodec.java`
- Create: `modules/messaging/messaging-schema-protobuf/src/main/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufMessageCodec.java`
- Create: `modules/messaging/messaging-schema-avro/src/test/resources/schemas/order.created/v1.avsc`
- Create: `modules/messaging/messaging-schema-protobuf/src/test/proto/order_created_v1.proto`
- Test: `modules/messaging/messaging-schema-avro/src/test/java/io/backend/skeleton/messaging/schema/avro/AvroCompatibilityTest.java`
- Test: `modules/messaging/messaging-schema-protobuf/src/test/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufCompatibilityTest.java`
**Interfaces:**
- Extends Task 5 codec SPI.
- Produces optional Avro and Protobuf Stable codecs and registry compatibility validation.
- Raw bytes remain M2 and bypass attempts are audited.
- [ ] **Step 1: Write failing backward compatibility tests**
```java
package io.backend.skeleton.messaging.schema.avro;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AvroCompatibilityTest {
@Test
void optionalFieldWithDefaultIsBackwardCompatible() {
var validator = AvroSchemaFixture.validator();
assertThat(validator.isBackwardTransitiveCompatible(
AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithOptionalCurrency()))
.isTrue();
}
@Test
void requiredFieldWithoutDefaultIsRejected() {
var validator = AvroSchemaFixture.validator();
assertThat(validator.isBackwardTransitiveCompatible(
AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithRequiredCurrency()))
.isFalse();
}
}
```
- [ ] **Step 2: Run codec modules and verify missing implementations**
Run:
```bash
./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test
```
Expected: FAIL because codecs and registry interfaces are missing.
- [ ] **Step 3: Implement codecs and compatibility policies**
```java
package io.backend.skeleton.messaging.schema;
public interface SchemaRegistry {
SchemaReference register(
MessageType type,
SchemaVersion version,
byte[] schema,
SchemaCompatibility compatibility);
byte[] resolve(SchemaReference reference);
}
```
Avro default is `BACKWARD_TRANSITIVE`. Protobuf must preserve unknown fields and reject enum evolution without an unknown-value strategy documented in the generated type. Both codecs enforce the same 1 MiB default encoded size and 8 MiB hard maximum.
- [ ] **Step 4: Run schema compatibility and golden-message tests**
Run:
```bash
./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-avro modules/messaging/messaging-schema-protobuf
git commit -m "feat: add avro and protobuf messaging codecs"
```
---
### Task 31: Broker TLS·Authentication·ACL·Credential Rotation 통합
**Files:**
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntime.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistry.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerTlsPolicy.java`
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerAclManifest.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSecurityConfigurer.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSecurityConfigurer.java`
- Create: `infra/messaging/tls/generate-test-pki.sh`
- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistryTest.java`
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTlsAclIT.java`
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitTlsAclIT.java`
**Interfaces:**
- Extends Task 11 security primitives and Task 10 runtime generations.
- Produces TLS 1.2/1.3, hostname validation, SASL/SCRAM or OAuth/mTLS profiles, destination ACL manifests, and zero-downtime generation replacement.
- [ ] **Step 1: Write failing rotation and ACL tests**
```java
package io.backend.skeleton.messaging.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CredentialRuntimeRegistryTest {
@Test
void newCredentialGenerationServesNewPublishesWhileOldGenerationDrains() {
CredentialRuntimeRegistry registry = CredentialRuntimeFixtures.registry();
var oldLease = registry.acquire("kafka-producer");
registry.rotate(CredentialRuntimeFixtures.generation(2));
assertThat(oldLease.runtime().generation()).isEqualTo(1);
assertThat(registry.acquire("kafka-producer").runtime().generation()).isEqualTo(2);
oldLease.close();
assertThat(registry.closedGenerations()).contains(1L);
}
}
```
```java
package io.backend.skeleton.messaging.kafka;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class KafkaTlsAclIT {
@Test
void producerCredentialCannotResetConsumerOffsets() {
KafkaSecureHarness harness = KafkaSecureHarness.start();
assertThatThrownBy(() -> harness.resetOffsetsWithProducerCredential())
.isInstanceOf(org.apache.kafka.common.errors.AuthorizationException.class);
}
}
```
- [ ] **Step 2: Run security integration tests and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test --tests '*KafkaTlsAclIT' :modules:messaging:messaging-rabbit:test --tests '*RabbitTlsAclIT'
```
Expected: FAIL because broker security integration and PKI fixtures are missing.
- [ ] **Step 3: Implement fail-closed TLS, identity separation, and runtime rotation**
`BrokerTlsPolicy` permits TLS 1.2 and 1.3, requires hostname verification, and has no trust-all flag. `CredentialRuntimeRegistry` follows the reference-counted generation design from Task 10.
Create separate identities:
```text
messaging-producer
messaging-consumer
messaging-admin
```
Kafka producer identity gets write-only topic permissions, consumer gets read/group permissions, and admin gets topology/offset permissions. Rabbit identities get vhost configure/write/read permissions according to role. Startup validation compares expected ACL manifest with a dry-run or describe result where the broker supports it.
- [ ] **Step 4: Run TLS, hostname mismatch, ACL, and rotation tests**
Run:
```bash
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test :modules:messaging:messaging-rabbit:test --tests '*Tls*' --tests '*Acl*' --tests '*Credential*'
```
Expected: PASS. Unknown CA, hostname mismatch, expired certificate, and unauthorized destination fail closed without retry storms.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-security modules/messaging/messaging-kafka modules/messaging/messaging-rabbit infra/messaging/tls
git commit -m "feat: add messaging transport security"
```
---
### Task 32: Metrics, Tracing, Audit, Cardinality Guard 구현
**Files:**
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingMetrics.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTracer.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditEvent.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditSink.java`
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/DefaultMessagingObservationConvention.java`
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingMetricCardinalityTest.java`
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingTraceLinkTest.java`
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingSecretLeakTest.java`
**Interfaces:**
- Extends Task 11 primitives.
- Produces logical publish/consume observations, physical attempt metrics, asynchronous span links, and admin audit events.
- Internal semantic model is versioned independently of OpenTelemetry exporter names.
- [ ] **Step 1: Write failing cardinality and trace-link tests**
```java
package io.backend.skeleton.messaging.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingMetricCardinalityTest {
@Test
void tenThousandMessageIdsDoNotCreateNewMetricSeries() {
TestMeterRegistry registry = new TestMeterRegistry();
MessagingMetrics metrics = new MessagingMetrics(registry);
for (int index = 0; index < 10_000; index++) {
metrics.publishConfirmed(
"kafka", "order-events", "order.created",
"0190f4aa-0000-7000-8000-" + String.format("%012d", index));
}
assertThat(registry.seriesCount("messaging.publish"))
.isLessThanOrEqualTo(2);
}
}
```
```java
package io.backend.skeleton.messaging.observation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingTraceLinkTest {
@Test
void consumerSpanLinksToProducerContextInsteadOfAssumingDirectParent() {
TestTracing tracing = TestTracing.create();
MessagingTracer tracer = new MessagingTracer(tracing.tracer());
tracer.consume(MessagingObservationFixtures.delivery()).close();
assertThat(tracing.finishedConsumerSpan().links()).hasSize(1);
assertThat(tracing.finishedConsumerSpan().parentSpanId()).isNull();
}
}
```
- [ ] **Step 2: Run observability tests and verify missing implementation**
Run:
```bash
./gradlew :modules:messaging:messaging-observability:test
```
Expected: FAIL because metrics and tracing classes are missing.
- [ ] **Step 3: Implement bounded tags and observation lifecycle**
Implement counters/timers for publish, confirmation, ambiguity, receive, processing, settlement, redelivery, retry, DLQ, redrive, backlog, schema failure, Outbox, and Inbox. Allowed tags are broker, destination profile, operation, bounded message type, outcome, failure category, retry stage, and schema codec.
`MessagingTracer` creates a producer span, stores trace context in reserved headers, and creates a consumer processing span with a link to that context. Admin audit events include operator, approval ID, dry-run flag, operation, target profile, count, and result, but no payload.
- [ ] **Step 4: Run cardinality, trace, and secret leak tests**
Run:
```bash
./gradlew :modules:messaging:messaging-observability:test
```
Expected: PASS. Static log scanning finds no payload, token, or full message ID.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-observability
git commit -m "feat: add messaging metrics tracing and audit"
```
---
### Task 33: Topology Manifest, Broker Inspectors, Validate-only Runtime 구현
**Files:**
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManifest.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/DestinationTopology.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManagementMode.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidationReport.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyIssue.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidator.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/BrokerTopologyInspector.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/CompositeTopologyValidator.java`
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/TopologyValidatorTest.java`
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/ProductionAutoCreateGuardTest.java`
**Interfaces:**
- Consumes Kafka and Rabbit read-only topology inspectors.
- Produces manifest-based drift reports and environment-specific management modes.
- Production auto-create is rejected before application startup.
- [ ] **Step 1: Write failing topology drift and production guard tests**
```java
package io.backend.skeleton.messaging.admin;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TopologyValidatorTest {
@Test
void reportsKafkaPartitionAndRabbitQueueTypeDrift() {
TopologyValidator validator = TopologyFixtures.validatorWithDrift();
TopologyValidationReport report = validator.validate();
assertThat(report.errors())
.extracting(TopologyIssue::code)
.contains("KAFKA_PARTITION_MISMATCH", "RABBIT_QUEUE_TYPE_MISMATCH");
}
@Test
void productionRejectsAutoCreateMode() {
assertThatThrownBy(() -> TopologyManifestFixtures.production(
TopologyManagementMode.AUTO_CREATE_DEV))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run admin topology tests and verify missing contracts**
Run:
```bash
./gradlew :modules:messaging:messaging-admin-runtime:test
```
Expected: FAIL because topology contracts and validator are missing.
- [ ] **Step 3: Implement manifest, inspectors, and drift severity**
`TopologyValidationReport` contains `INFO`, `WARNING`, and `ERROR` issues with stable codes. Kafka inspection covers partition count, replication factor, minimum ISR, retention, and cleanup policy. Rabbit inspection covers exchange type, queue type, durability, bindings, dead-letter strategy, overflow, and consumer timeout.
`AUTO_CREATE_DEV` may create only non-destructive local/test resources. `VALIDATE_ONLY` performs no mutation. `ADMIN_MANAGED` requires the Admin application.
- [ ] **Step 4: Run topology unit and broker integration tests**
Run:
```bash
./gradlew :modules:messaging:messaging-admin-runtime:test :modules:messaging:messaging-kafka:test --tests '*Topology*' :modules:messaging:messaging-rabbit:test --tests '*Topology*'
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime modules/messaging/messaging-kafka modules/messaging/messaging-rabbit
git commit -m "feat: add messaging topology validation"
```
---
### Task 34: Replay·Redrive Admin Approval, Dry-run, Audit 구현
**Files:**
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayRequest.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayPlan.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveRequest.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedrivePlan.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/AdminApproval.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayResult.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveResult.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedReplayPlan.java`
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedRedrivePlan.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/MessagingAdminService.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DefaultMessagingAdminService.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DestructiveMessagingAdmin.java`
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/AdminOperationIdempotencyStore.java`
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/RedriveAdminTest.java`
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/DestructiveAdminGuardTest.java`
**Interfaces:**
- Consumes topology inspectors, broker replay capabilities, publisher, and audit sink.
- Produces dry-run planning, approval-bound execution, idempotent admin operations, and separate destructive interface.
- [ ] **Step 1: Write failing approval and identity tests**
```java
package io.backend.skeleton.messaging.admin;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RedriveAdminTest {
@Test
void redriveKeepsOriginalMessageIdAndAddsRedriveId() {
DefaultMessagingAdminService service = AdminFixtures.service();
RedrivePlan plan = service.planRedrive(AdminFixtures.redriveDryRun());
var result = service.executeRedrive(plan.approve(AdminFixtures.approval()));
assertThat(result.published().getFirst().messageId())
.isEqualTo(AdminFixtures.originalMessageId());
assertThat(result.published().getFirst().redriveId()).isNotNull();
}
@Test
void executionWithoutApprovalIsRejected() {
assertThatThrownBy(() -> AdminFixtures.service()
.executeRedrive(AdminFixtures.unapprovedPlan()))
.isInstanceOf(SecurityException.class);
}
}
```
- [ ] **Step 2: Run admin tests and verify missing implementation**
Run:
```bash
./gradlew :modules:messaging:messaging-admin-runtime:test --tests '*RedriveAdminTest' --tests '*DestructiveAdminGuardTest'
```
Expected: FAIL because admin execution contracts are missing.
- [ ] **Step 3: Implement plan-approve-execute and operation idempotency**
Every admin operation follows:
```text
plan with dry-run
capture topology version and estimated count
approve with approval ID, operator, reason, expiry
revalidate topology version
claim approval ID in idempotency store
execute bounded batches
write audit result
```
Redrive preserves original `messageId`, creates a new `redriveId`, increments `redriveCount`, and waits for target confirmation before marking source DLQ state. `DestructiveMessagingAdmin` is a separate bean and interface for offset reset, purge, and delete.
- [ ] **Step 4: Run approval, duplicate execution, audit, and broker replay tests**
Run:
```bash
./gradlew :modules:messaging:messaging-admin-runtime:test
```
Expected: PASS. The same approval cannot execute twice and app-role fixtures cannot obtain the destructive bean.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime
git commit -m "feat: add guarded replay and redrive admin"
```
---
### Task 35: Spring Boot Starter, Properties, Auto-configuration, Actuator 구현
**Files:**
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingProperties.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/KafkaMessagingAutoConfiguration.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/RabbitMessagingAutoConfiguration.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingReliabilityAutoConfiguration.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingAdminAutoConfiguration.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpoint.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-valid.yml`
- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-invalid-ordering.yml`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingAutoConfigurationTest.java`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpointTest.java`
**Interfaces:**
- Consumes every Stable module and optional Experimental modules by classpath/property.
- Produces configuration binding, startup validation, runtime registry, actuator endpoints, and conditional adapter beans.
- Experimental and Admin features are disabled by default.
- [ ] **Step 1: Write failing valid/invalid ApplicationContextRunner tests**
```java
package io.backend.skeleton.messaging.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
MessagingCoreAutoConfiguration.class));
@Test
void validConfigurationCreatesTypedPublisher() {
runner.withPropertyValues(
"backend.messaging.brokers.kafka-primary.type=KAFKA",
"backend.messaging.destinations.order-events.broker=kafka-primary",
"backend.messaging.destinations.order-events.kind=EVENT_STREAM")
.run(context -> assertThat(context)
.hasSingleBean(io.backend.skeleton.messaging.api.publish.MessagePublisher.class));
}
@Test
void invalidOrderingRetryConfigurationFailsStartup() {
runner.withPropertyValues(
"backend.messaging.destinations.order-events.ordering=KEY",
"backend.messaging.destinations.order-events.retry.mode=RETRY_DESTINATION",
"backend.messaging.destinations.order-events.retry.ordering-impact=PRESERVE")
.run(context -> assertThat(context).hasFailed());
}
}
```
- [ ] **Step 2: Run starter tests and verify missing auto-configuration**
Run:
```bash
./gradlew :modules:messaging:messaging-spring-boot-starter:test
```
Expected: FAIL because properties and auto-configurations are missing.
- [ ] **Step 3: Implement configuration binding, startup validation, and sanitized actuator output**
```java
package io.backend.skeleton.messaging.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("backend.messaging")
public record MessagingProperties(
java.util.Map<String, BrokerProperties> brokers,
java.util.Map<String, DestinationProperties> destinations,
MessagingLimitsProperties limits,
MessagingSecurityProperties security,
MessagingObservabilityProperties observability,
MessagingAdminProperties admin,
MessagingExperimentalProperties experimental) {
}
```
Auto-configuration order is core → schema → policy → security/observation → transport adapters → reliability → actuator. Admin and Experimental beans require explicit properties. `/actuator/messaging`, `/topology`, `/outbox`, `/capabilities` return only profile names, status, bounded counts, and capability enums.
- [ ] **Step 4: Run starter, endpoint, and startup guard tests**
Run:
```bash
./gradlew :modules:messaging:messaging-spring-boot-starter:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-spring-boot-starter
git commit -m "feat: add messaging spring boot starter"
```
---
### Task 36: Blocking·Reactive·Batch Facade와 Cancellation 계약 구현
**Files:**
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BlockingMessagePublisher.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchMessagePublisher.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishOptions.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishResult.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishItemResult.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishRequest.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/DelayedMessagePublisher.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageHandler.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageDelivery.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchDeliveryMetadata.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/PauseResumeController.java`
- Modify: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java`
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBatchConsumerRegistrar.java`
- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java`
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBatchConsumerRegistrar.java`
- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBlockingMessagePublisher.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/ReactiveMessagePublisher.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultReactiveMessagePublisher.java`
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBatchMessagePublisher.java`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/PublisherFacadeTest.java`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchPublisherTest.java`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchConsumerFacadeTest.java`
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/DelayedPublishCapabilityTest.java`
**Interfaces:**
- Consumes Core `MessagePublisher` and Spring Reactor dependency in the starter only.
- Produces blocking and `Mono<PublishResult>` facades, index-preserving batch publish, M2 batch consume, pause/resume, and capability-gated delayed publish.
- Batch is explicitly non-transactional and receives no transparent whole-batch retry.
- [ ] **Step 1: Write failing facade and batch-result tests**
```java
package io.backend.skeleton.messaging.autoconfigure;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class PublisherFacadeTest {
@Test
void blockingFacadeHonorsFiniteTimeout() {
DefaultBlockingMessagePublisher publisher = PublisherFacadeFixtures.neverCompletingBlocking();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> publisher.publish(
PublisherFacadeFixtures.destination(),
PublisherFacadeFixtures.envelope(),
PublisherFacadeFixtures.options(Duration.ofMillis(50))))
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessagePublishTimeoutException.class);
}
@Test
void reactiveCancellationCancelsUnderlyingStage() {
var fixture = PublisherFacadeFixtures.cancellableReactive();
var subscription = fixture.publisher().publish(
fixture.destination(), fixture.envelope(), fixture.options())
.subscribe();
subscription.dispose();
assertThat(fixture.underlyingCancelled()).isTrue();
}
}
```
```java
package io.backend.skeleton.messaging.autoconfigure;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BatchPublisherTest {
@Test
void preservesInputIndexAndIndividualAmbiguity() {
DefaultBatchMessagePublisher publisher = PublisherFacadeFixtures.batchWithSecondAmbiguous();
var result = publisher.publish(PublisherFacadeFixtures.threeRequests(),
PublisherFacadeFixtures.batchOptions()).toCompletableFuture().join();
assertThat(result.items()).extracting(BatchPublishItemResult::index)
.containsExactly(0, 1, 2);
assertThat(result.items().get(1).result().completion().name())
.isEqualTo("AMBIGUOUS");
}
}
```
`BatchConsumerFacadeTest` must assert that Kafka commits only completed contiguous records and Rabbit settles each item or the whole batch according to the configured settlement capability. `DelayedPublishCapabilityTest` must assert that Kafka Stable rejects delayed delivery while Rabbit capability profiles accept it.
- [ ] **Step 2: Run facade tests and verify missing implementations**
Run:
```bash
./gradlew :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest'
```
Expected: FAIL because facades and batch types are missing.
- [ ] **Step 3: Implement finite blocking wait, Reactor cancellation, and per-item batch completion**
```java
package io.backend.skeleton.messaging.api.publish;
public interface BlockingMessagePublisher {
<T> PublishResult publish(
MessageDestination<T> destination,
MessageEnvelope<T> message,
PublishOptions options);
}
```
`DefaultBlockingMessagePublisher` waits no longer than `PublishOptions.timeout` and translates timeout to `MessagePublishTimeoutException` without claiming the broker rejected the message. `DefaultReactiveMessagePublisher` uses `Mono.fromCompletionStage` with cancellation propagation where the transport supports it. `DefaultBatchMessagePublisher` assigns indexes, executes within the configured max in-flight limit, and returns every item result without retrying the whole batch. Implement `BatchMessageHandler<T>` as `CompletionStage<HandleResult> handle(BatchMessageDelivery<T> batch)`. Add Kafka and Rabbit batch registrars behind M2 capability checks. Implement `PauseResumeController` without exposing native partition/channel objects. Implement `DelayedMessagePublisher` so an adapter lacking delayed-delivery capability throws `MessagingCapabilityUnavailableException`; Rabbit maps supported delayed profiles, Kafka Stable rejects them.
- [ ] **Step 4: Run facade, cancellation, and batch tests**
Run:
```bash
./gradlew :modules:messaging:messaging-core-api:test :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest'
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-core-api modules/messaging/messaging-spring-boot-starter
git commit -m "feat: add messaging publisher facades"
```
---
### Task 37: Debezium Outbox Event Router 선택 Integration 구현
**Files:**
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxProfile.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapper.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumMappedRecord.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/debezium/outbox-event-router.properties`
- Create: `infra/messaging/kafka/debezium-compose.yml`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapperTest.java`
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxIT.java`
**Interfaces:**
- Consumes the Outbox table and Kafka destination profile.
- Produces a CDC relay option that uses outbox `message_id` as event identity and aggregate ID as optional Kafka key.
- Does not implement or operate a generic CDC engine and cannot run concurrently with the polling relay for the same rows.
- [ ] **Step 1: Write failing ID and routing-key mapping tests**
```java
package io.backend.skeleton.messaging.outbox;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DebeziumOutboxRecordMapperTest {
@Test
void mapsOutboxMessageIdAndAggregateIdWithoutCreatingNewIdentity() {
OutboxRecord row = OutboxFixtures.orderCreatedRow();
DebeziumMappedRecord mapped = new DebeziumOutboxRecordMapper().map(row);
assertThat(mapped.eventId()).isEqualTo(row.messageId().toString());
assertThat(mapped.key()).isEqualTo(row.aggregateId().orElseThrow());
}
}
```
- [ ] **Step 2: Run Debezium tests and verify missing mapper**
Run:
```bash
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT'
```
Expected: FAIL because the Debezium profile and fixture are missing.
- [ ] **Step 3: Implement the fixed Outbox Event Router mapping and exclusivity guard**
Map:
```text
message_id → event ID
aggregate_id → event key
destination → route field
message_type → event type
payload → event payload
headers → additional fields limited by allowlist
```
At startup, fail if both `polling-relay.enabled=true` and `debezium-relay.enabled=true` for the same Outbox namespace. Keep the CDC container configuration in infra and label the integration optional.
- [ ] **Step 4: Run mapper and end-to-end CDC tests**
Run:
```bash
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT'
```
Expected: PASS. Kafka receives the original message ID and aggregate key.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-outbox-jpa infra/messaging/kafka/debezium-compose.yml
git commit -m "feat: add optional debezium outbox integration"
```
---
### Task 38: Pulsar Experimental Adapter 구현
**Files:**
- Modify: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts`
- Create: `infra/messaging/pulsar/docker-compose.yml`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagingTransport.java`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfile.java`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfileValidator.java`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionMode.java`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionCapability.java`
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagePosition.java`
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarAdapterContractTest.java`
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionGuardTest.java`
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionIT.java`
**Interfaces:**
- Consumes Core transport SPI and policy contracts.
- Produces Experimental typed publish/consume, Exclusive/Failover/Shared/Key_Shared, schema integration, redelivery, replay, and native transaction capability.
- Module is absent unless the experimental property and dependency are both present.
- [ ] **Step 1: Write failing Core Contract and subscription guard tests**
```java
package io.backend.skeleton.messaging.pulsar;
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
import org.junit.jupiter.api.Nested;
class PulsarAdapterContractTest {
@Nested
class Contract extends MessagingAdapterContract {
@Override
protected MessagingAdapterHarness harness() {
return PulsarHarnessFixture.create();
}
}
}
```
```java
package io.backend.skeleton.messaging.pulsar;
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PulsarSubscriptionGuardTest {
@Test
void sharedSubscriptionRejectsKeyOrderingRequirement() {
PulsarProfile profile = PulsarProfileFixtures.profile(
PulsarSubscriptionMode.SHARED, OrderingScope.KEY);
assertThatThrownBy(() -> new PulsarProfileValidator().validate(profile))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run Pulsar tests and verify missing adapter**
Run:
```bash
./gradlew :modules:messaging:messaging-pulsar-experimental:test
```
Expected: FAIL because the adapter is missing.
- [ ] **Step 3: Implement publish, subscription mapping, schema, and transaction capability**
Map Pulsar message ID to a `PulsarMessagePosition` without exposing client types. Use Shared only with `OrderingScope.NONE`, Key_Shared for key ordering, and Failover/Exclusive for single active processing profiles. Transaction capability may atomically include Pulsar publish and acknowledge only; reject external DB guarantee claims.
- [ ] **Step 4: Run Core Contract against Pulsar 4.0 LTS and 4.2 profiles**
Run:
```bash
./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.0
./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.2
```
Expected: PASS for the experimental support matrix, including transaction commit/abort and subscription redistribution.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-pulsar-experimental infra/messaging/pulsar
git commit -m "feat: add experimental pulsar adapter"
```
---
### Task 39: NATS JetStream Experimental Adapter 구현
**Files:**
- Modify: `modules/messaging/messaging-nats-experimental/build.gradle.kts`
- Create: `infra/messaging/nats/docker-compose.yml`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamTransport.java`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfile.java`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfileValidator.java`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsAckMode.java`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingWorkflow.java`
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsStreamPosition.java`
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAdapterContractTest.java`
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsDeduplicationIT.java`
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAckSyncIT.java`
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingIT.java`
**Interfaces:**
- Consumes Core transport SPI and policy contracts.
- Produces JetStream publish PubAck, `Nats-Msg-Id` dedupe, pull consumer, explicit ACK, AckSync, Nak delay, MaxDeliver advisory, work queue, and replay.
- Does not claim multi-resource transaction or automatic native DLQ movement.
- [ ] **Step 1: Write failing dedupe and AckSync tests**
```java
package io.backend.skeleton.messaging.nats;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class NatsDeduplicationIT {
@Test
void sameMessageIdWithinWindowProducesOneStoredMessage() {
NatsHarness harness = NatsHarness.start();
var message = NatsFixtures.orderCreated();
harness.publish(message);
harness.publish(message);
assertThat(harness.streamMessageCount()).isEqualTo(1);
}
}
```
```java
package io.backend.skeleton.messaging.nats;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class NatsAckSyncIT {
@Test
void ackSyncReturnsSettlementEvidence() {
NatsHarness harness = NatsHarness.start();
var result = harness.consumeAndAckSync();
assertThat(result.completion().name()).isEqualTo("SETTLED");
assertThat(result.evidence().brokerAcknowledged()).isTrue();
}
}
```
- [ ] **Step 2: Run NATS tests and verify missing adapter**
Run:
```bash
./gradlew :modules:messaging:messaging-nats-experimental:test
```
Expected: FAIL because JetStream adapter types are missing.
- [ ] **Step 3: Implement JetStream evidence and advisory-based parking**
Use `messageId` as `Nats-Msg-Id`. Map PubAck to confirmed publish. Use pull consumers with explicit ACK and bounded `MaxAckPending`. Map `AckSync` server response to settlement evidence. On MaxDeliver advisory, publish to the configured parking destination, wait for PubAck, then terminate or mark the original according to the stream policy.
- [ ] **Step 4: Run NATS 2.14.x Core Contract, dedupe-window, AckSync, and MaxDeliver tests**
Run:
```bash
./gradlew :modules:messaging:messaging-nats-experimental:test
```
Expected: PASS. The module remains Experimental and disabled by default.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-nats-experimental infra/messaging/nats
git commit -m "feat: add experimental nats jetstream adapter"
```
---
### Task 40: Spring Cloud Stream Optional Bridge 구현
**Files:**
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/MessagingBindingBridge.java`
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamPublisherBridge.java`
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamConsumerBridge.java`
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingCapabilityReport.java`
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidator.java`
- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidatorTest.java`
- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BridgePublishEvidenceTest.java`
**Interfaces:**
- Consumes Core API and Spring Cloud Stream binding interfaces.
- Produces a migration/compatibility bridge only where binder callbacks can satisfy the requested evidence.
- Never promotes binder-specific retry or DLQ semantics into the Core policy automatically.
- [ ] **Step 1: Write failing evidence-capability guard test**
```java
package io.backend.skeleton.messaging.streambridge;
import io.backend.skeleton.messaging.api.destination.ConfirmationRequirement;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class BindingProfileValidatorTest {
@Test
void rejectsReplicationConfirmationWhenBinderCannotProveIt() {
BindingCapabilityReport capabilities = BindingCapabilityReport.brokerAckOnly();
assertThatThrownBy(() -> new BindingProfileValidator().validate(
ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK,
capabilities))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: Run bridge tests and verify missing implementation**
Run:
```bash
./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test
```
Expected: FAIL because bridge classes are missing.
- [ ] **Step 3: Implement capability-reporting bridge and migration-only degradation rules**
The bridge must expose a `BindingCapabilityReport` before registration. If the binder cannot distinguish confirm, routing, or settlement evidence required by a destination, reject the binding or label it `MIGRATION_ONLY`. Do not reuse binder retry settings as Core `RetryPolicy`; require explicit mapping and validation.
- [ ] **Step 4: Run bridge capability and mapping tests**
Run:
```bash
./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-spring-cloud-stream-bridge
git commit -m "feat: add optional spring cloud stream bridge"
```
---
### Task 41: Global Backpressure, Payload Guard, Graceful Shutdown 구현
**Files:**
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/MessagingAdmissionController.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/InFlightLimiter.java`
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadLimitGuard.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingLifecycle.java`
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinator.java`
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBackpressureException.java`
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/MessagingAdmissionControllerTest.java`
- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinatorTest.java`
**Interfaces:**
- Consumes payload policy, adapter runtime, handler coordinator, Outbox relay, and admin runtime.
- Produces bounded publish admission, handler concurrency, retry concurrency, and deterministic drain ordering.
- [ ] **Step 1: Write failing overload and shutdown-order tests**
```java
package io.backend.skeleton.messaging.policy;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MessagingAdmissionControllerTest {
@Test
void rejectsBeforeEncodingWhenPayloadExceedsDestinationLimit() {
PayloadLimitGuard guard = new PayloadLimitGuard(1024, 8192);
assertThatThrownBy(() -> guard.checkDeclaredSize(1025))
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessageTooLargeException.class);
}
@Test
void boundedProducerAdmissionRejectsAfterFiniteWait() {
MessagingAdmissionController controller = new MessagingAdmissionController(1);
var permit = controller.acquire(java.time.Duration.ofMillis(10));
assertThatThrownBy(() -> controller.acquire(java.time.Duration.ofMillis(10)))
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessageBackpressureException.class);
permit.close();
}
}
```
```java
package io.backend.skeleton.messaging.transport;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingShutdownCoordinatorTest {
@Test
void blocksAdmissionBeforePausingConsumersAndDrainingSettlements() {
ShutdownOrderRecorder recorder = new ShutdownOrderRecorder();
new MessagingShutdownCoordinator(recorder.components()).shutdown();
assertThat(recorder.events()).containsExactly(
"block-publish-admission",
"block-new-handlers",
"pause-consumers",
"drain-handlers",
"flush-settlements",
"await-confirms",
"release-outbox-leases",
"close-transports");
}
}
```
- [ ] **Step 2: Run policy and lifecycle tests and verify failure**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test --tests '*Admission*' --tests '*Shutdown*'
```
Expected: FAIL because admission and shutdown types are missing.
- [ ] **Step 3: Implement bounded permits and the exact shutdown sequence**
Use semaphore-style permits with finite acquire timeout for producer, handler, retry publish, and admin batch categories. Payload guard checks declared and encoded size. `MessagingShutdownCoordinator` uses Spring lifecycle phases and a default 30-second drain. After the deadline, unfinished handlers remain unsettled, unconfirmed publishes become ambiguous, and Outbox rows rely on lease expiry.
- [ ] **Step 4: Run overload, cancellation, and shutdown integration tests**
Run:
```bash
./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test :modules:messaging:messaging-kafka:test --tests '*Shutdown*' :modules:messaging:messaging-rabbit:test --tests '*Shutdown*'
```
Expected: PASS. No new work begins after shutdown admission closes and unfinished consumer messages are redelivered rather than falsely settled.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging/messaging-policy modules/messaging/messaging-transport-spi modules/messaging/messaging-core-api
git commit -m "feat: add messaging backpressure and shutdown"
```
---
### Task 42: Cross-broker 장애·보안·Reliability Contract Suite 구현
**Files:**
- Create: `infra/messaging/toxiproxy/docker-compose.yml`
- Create: `build-logic/src/main/kotlin/messaging-chaos-conventions.gradle.kts`
- Modify: `build.gradle.kts`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/NetworkFaultScenario.java`
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/BrokerFailureMatrix.java`
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/CrossBrokerContractSuite.java`
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaChaosSuiteIT.java`
- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitChaosSuiteIT.java`
- Create: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/ReliabilityCrashMatrixIT.java`
- Create: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/SecretLeakStaticScanTest.java`
**Interfaces:**
- Consumes all Stable adapters, Reliability modules, security, observation, and Toxiproxy fixtures.
- Produces the release-gating failure matrix required by the design.
- [ ] **Step 1: Write failing matrix completeness test**
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class CrossBrokerContractSuite {
@Test
void stableReleaseIncludesEveryRequiredFailureScenario() {
Set<String> scenarios = BrokerFailureMatrix.requiredScenarioNames();
assertThat(scenarios).contains(
"publish-confirm-loss",
"consumer-settlement-loss",
"leader-or-node-failover",
"network-partition",
"retry-exhausted",
"dlq-target-outage",
"schema-poison",
"credential-rotation",
"tls-hostname-mismatch",
"acl-denied",
"graceful-shutdown",
"outbox-ambiguous-publish",
"inbox-duplicate-delivery");
}
}
```
- [ ] **Step 2: Run cross-broker tests and verify missing matrix**
Run:
```bash
./gradlew :modules:messaging:messaging-testkit:test :modules:messaging:messaging-kafka:test --tests '*ChaosSuiteIT' :modules:messaging:messaging-rabbit:test --tests '*ChaosSuiteIT'
```
Expected: FAIL because fault scenarios and chaos suites are missing.
- [ ] **Step 3: Implement deterministic network and process fault scenarios**
The matrix injects:
```text
connection refusal
latency
packet loss
half-open connection
confirmation path drop
consumer settlement path drop
Kafka leader stop
Rabbit node stop or quorum minority
PostgreSQL restart
process kill at Outbox and Inbox crash points
credential rotation
certificate rotation
```
Each scenario asserts final state, allowed duplicate count, message identity, settlement state, metric tags, and secret-free logs. Keep broker-specific expected differences in adapter assertions, not the Core guarantee.
Register the aggregate task in `messaging-chaos-conventions.gradle.kts`:
```kotlin
tasks.register("messagingStableChaos") {
group = "verification"
dependsOn(
":modules:messaging:messaging-kafka:test",
":modules:messaging:messaging-rabbit:test",
":modules:messaging:messaging-outbox-jpa:test",
":modules:messaging:messaging-inbox-jpa:test"
)
}
```
- [ ] **Step 4: Run the complete Stable failure matrix**
Run:
```bash
./gradlew messagingStableChaos
```
Expected: PASS with Kafka, RabbitMQ, PostgreSQL, TLS, ACL, Outbox, and Inbox scenarios. Experimental adapters are not part of this Stable gate.
- [ ] **Step 5: Commit**
```bash
git add infra/messaging/toxiproxy modules/messaging/messaging-testkit modules/messaging/messaging-kafka modules/messaging/messaging-rabbit modules/messaging/messaging-outbox-jpa modules/messaging/messaging-observability
git commit -m "test: add messaging stable failure matrix"
```
---
### Task 43: 성능 인증, Compatibility Matrix, Resource Leak Gate 구현
**Files:**
- Create: `modules/messaging/messaging-testkit/src/jmh/java/io/backend/skeleton/messaging/testkit/EnvelopeCodecBenchmark.java`
- Create: `modules/messaging/messaging-kafka/src/jmh/java/io/backend/skeleton/messaging/kafka/KafkaPublishBenchmark.java`
- Create: `modules/messaging/messaging-rabbit/src/jmh/java/io/backend/skeleton/messaging/rabbit/RabbitPublishBenchmark.java`
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingLoadIT.java`
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/ResourceLeakIT.java`
- Create: `.github/workflows/messaging-compatibility.yml`
- Create: `build-logic/src/main/kotlin/messaging-verification-conventions.gradle.kts`
- Modify: `build.gradle.kts`
- Create: `.github/workflows/messaging-performance.yml`
- Create: `gradle/messaging-versions.properties`
**Interfaces:**
- Consumes all Stable modules and Experimental smoke tasks.
- Produces reproducible latency, throughput, heap, thread, connection, backlog, retry-amplification, and compatibility reports.
- [ ] **Step 1: Write failing performance-budget and leak tests**
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ResourceLeakIT {
@Test
void repeatedStartupShutdownReturnsThreadsAndConnectionsToBaseline() {
ResourceSnapshot before = ResourceSnapshot.capture();
for (int index = 0; index < 20; index++) {
try (MessagingSystemFixture fixture = MessagingSystemFixture.startAndStop()) {
fixture.publishAndConsume(100);
}
}
ResourceSnapshot after = ResourceSnapshot.capture();
assertThat(after.nonDaemonThreadDelta(before)).isLessThanOrEqualTo(2);
assertThat(after.openConnectionDelta(before)).isZero();
}
}
```
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingLoadIT {
@Test
void retryAmplificationStaysWithinConfiguredBudget() {
LoadReport report = MessagingLoadFixture.runWithTenPercentTransientFailures();
assertThat(report.physicalPublishes())
.isLessThanOrEqualTo(report.logicalPublishes() * 1.20);
assertThat(report.maxHeapBytes()).isLessThanOrEqualTo(512L * 1024 * 1024);
}
}
```
- [ ] **Step 2: Run performance smoke tests and verify missing harness**
Run:
```bash
./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingLoadIT' --tests '*ResourceLeakIT'
```
Expected: FAIL because performance and resource fixtures are missing.
- [ ] **Step 3: Implement benchmark scenarios and CI version matrix**
Measure:
```text
p50 p95 p99 max publish confirmation latency
consumer processing and settlement latency
throughput
producer buffered bytes
consumer in-flight and backlog
heap allocation and GC
thread count
broker connection/channel count
retry amplification
DLQ throughput
Outbox age and relay throughput
Inbox duplicate contention
```
Compatibility workflow runs Kafka 4.2 and 4.3.x, RabbitMQ 4.3.x, PostgreSQL 16, Spring 6.2 and 7.0 lines, plus non-blocking Experimental smoke for Pulsar 4.0/4.2 and NATS 2.14.x. Store baselines as versioned JSON and fail on agreed regression thresholds.
Register aggregate tasks in `messaging-verification-conventions.gradle.kts`:
```kotlin
tasks.register("messagingPerformance") {
group = "verification"
dependsOn(
":modules:messaging:messaging-testkit:test",
":modules:messaging:messaging-kafka:jmh",
":modules:messaging:messaging-rabbit:jmh"
)
}
tasks.register("messagingCompatibility") {
group = "verification"
dependsOn(
":modules:messaging:messaging-kafka:test",
":modules:messaging:messaging-rabbit:test",
":modules:messaging:messaging-pulsar-experimental:test",
":modules:messaging:messaging-nats-experimental:test"
)
}
```
- [ ] **Step 4: Run performance certification and compatibility jobs locally where supported**
Run:
```bash
./gradlew messagingPerformance messagingCompatibility
```
Expected: PASS with generated reports under `build/reports/messaging` and no resource leak.
- [ ] **Step 5: Commit**
```bash
git add modules/messaging .github/workflows gradle/messaging-versions.properties
git commit -m "test: add messaging performance and compatibility gates"
```
---
### Task 44: 지원 문서, Runbook, ADR, Release Gate 완성
**Files:**
- Create: `docs/messaging/support-matrix.md`
- Create: `docs/messaging/configuration-reference.md`
- Create: `docs/messaging/delivery-guarantees.md`
- Create: `docs/messaging/retry-dlq-redrive.md`
- Create: `docs/messaging/outbox-inbox.md`
- Create: `docs/messaging/security.md`
- Create: `docs/messaging/operations.md`
- Create: `docs/messaging/migration-guide.md`
- Create: `docs/messaging/experimental-policy.md`
- Create: `docs/adr/ADR-0041-messaging-core-native-adapters.md`
- Create: `docs/adr/ADR-0042-no-generic-exactly-once.md`
- Create: `docs/adr/ADR-0043-outbox-inbox-reliability.md`
- Create: `docs/messaging/release-checklist.md`
- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingDocumentationContractTest.java`
**Interfaces:**
- Consumes every design decision and test report.
- Produces implementation-independent usage, configuration, security, reliability, migration, operations, and release documentation.
- Documentation contract verifies that every Stable and Experimental capability is classified and every dangerous operation has a runbook.
- [ ] **Step 1: Write failing documentation contract**
```java
package io.backend.skeleton.messaging.testkit;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class MessagingDocumentationContractTest {
@Test
void supportMatrixClassifiesEveryAdapterAndCapability() throws Exception {
String matrix = Files.readString(Path.of("docs/messaging/support-matrix.md"));
assertThat(matrix).contains(
"Kafka | Stable",
"RabbitMQ | Stable",
"Kafka Share Group | Experimental",
"Pulsar | Experimental",
"NATS JetStream | Experimental",
"EXACTLY_ONCE | Unsupported");
}
@Test
void operationsRunbookCoversAmbiguousPublishAndDlqOutage() throws Exception {
String runbook = Files.readString(Path.of("docs/messaging/operations.md"));
assertThat(runbook).contains(
"Ambiguous publish",
"DLQ target outage",
"Consumer settlement unknown",
"Outbox backlog",
"Replay and redrive approval");
}
}
```
- [ ] **Step 2: Run documentation contract and verify missing files**
Run:
```bash
./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingDocumentationContractTest'
```
Expected: FAIL because the documentation files do not exist.
- [ ] **Step 3: Write exact operational and developer documentation**
The documents must include:
```text
M1M4 API examples
Kafka and Rabbit physical mapping examples
PublishResult evidence interpretation
Consumer duplicate and settlement rules
Retry strategy selection table
DLQ publish-before-ACK invariant
Redrive approval workflow
Outbox and Inbox crash diagrams
Schema compatibility rules
Claim Check lifecycle
TLS/ACL credential separation
Metric/tag allowlist and denylist
Kafka and Rabbit failure runbooks
Experimental feature enablement and rollback
Spring Cloud Stream migration limitations
```
`release-checklist.md` links every completion criterion to a Gradle task or CI job. ADRs record the own-Core/native-adapter decision, the rejection of generic exactly-once, and the Outbox+Inbox reliability model.
- [ ] **Step 4: Run full verification**
Run:
```bash
./gradlew clean check messagingStableChaos messagingPerformance messagingCompatibility
```
Expected: PASS with zero test failures, zero architecture violations, and generated Stable release reports.
- [ ] **Step 5: Commit**
```bash
git add docs modules/messaging/messaging-testkit
git commit -m "docs: complete messaging platform release guidance"
```
---
## 3. Plan Self-Review Checklist
### Spec coverage
- [ ] M1 Typed Publisher·Handler: Tasks 28, 35, 36
- [ ] M2 Batch·Manual Settlement·Replay: Tasks 8, 20, 34, 36
- [ ] M3 Kafka·Rabbit native capability: Tasks 19, 20, 25
- [ ] M4 Admin Plane: Tasks 3335
- [ ] Publish evidence and ambiguity: Tasks 7, 10, 12, 16, 23
- [ ] Consumer duplicate and settlement: Tasks 8, 12, 17, 24, 28
- [ ] Retry·DLQ·redrive identity: Tasks 13, 14, 18, 25, 34
- [ ] Kafka Stable: Tasks 1520
- [ ] RabbitMQ Stable: Tasks 2225
- [ ] Outbox·Inbox·Claim Check: Tasks 2629, 37
- [ ] JSON·Avro·Protobuf·CloudEvents: Tasks 5, 6, 30
- [ ] Security and ACL: Tasks 11, 31
- [ ] Observability and audit: Tasks 11, 32
- [ ] Topology and operations: Tasks 20, 33, 34
- [ ] Pulsar·NATS Experimental: Tasks 38, 39
- [ ] Spring Cloud Stream bridge: Task 40
- [ ] Backpressure and shutdown: Task 41
- [ ] Failure matrix and performance: Tasks 42, 43
- [ ] Documentation and release gate: Task 44
### Placeholder scan
The final document must contain no unresolved markers, deferred implementation phrases, or cross-task shorthand that omits required code.
### Type consistency
- `MessagePublisher.publish` always returns `CompletionStage<PublishResult>`.
- `MessageHandler.handle` always returns `CompletionStage<HandleResult>`.
- `PublishCompletion` constants are `CONFIRMED`, `REJECTED`, `AMBIGUOUS`.
- `SettlementCompletion` constants are `SETTLED`, `REJECTED`, `UNKNOWN`.
- `RetryMode` constants match the design exactly.
- `messageId` remains `MessageId` from Task 2 through Outbox, Inbox, DLQ, retry, replay, and redrive.
- Stable modules never depend on Experimental modules.
---
## 4. Execution Handoff
Plan execution begins only after the design and plan are reviewed in the target repository.
**Recommended:** `superpowers:subagent-driven-development`
- fresh implementation agent per Task
- requirements review after each Task
- code quality review after each Task
- full Stable gate after Tasks 25, 35, 42, and 44
**Alternative:** `superpowers:executing-plans`
- execute Tasks in the listed order
- checkpoint after Foundation, Kafka, RabbitMQ, Reliability, Operations, Experimental, Release phases
- never combine unreviewed Tasks into one commit