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

196 KiB
Raw Blame History

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. 확정 파일 구조

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. 핵심 패키지

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

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:

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:

./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:

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:

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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS with one test and zero failures.

  • Step 5: Commit
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

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:

./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
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");
        }
    }
}
package io.backend.skeleton.messaging.api;

public record SchemaVersion(int value) {
    public SchemaVersion {
        if (value < 1) {
            throw new IllegalArgumentException("schemaVersion must be positive");
        }
    }
}
package io.backend.skeleton.messaging.api.delivery;

public enum DeliveryGuarantee {
    AT_MOST_ONCE,
    AT_LEAST_ONCE
}
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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS with no unsupported guarantee constants.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS and all reserved/secret header tests succeed.

  • Step 5: Commit
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

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:

./gradlew :modules:messaging:messaging-core-api:test --tests '*DestinationCapabilityTest'

Expected: FAIL because destination types do not exist.

  • Step 3: Implement exact capability contracts
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) {
}
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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-schema-api:test :modules:messaging:messaging-schema-json:test

Expected: PASS.

  • Step 5: Commit
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

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:

./gradlew :modules:messaging:messaging-cloudevents:test --tests '*CloudEventMappingTest'

Expected: FAIL because mapping classes do not exist.

  • Step 3: Implement exact mapping rules
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:

./gradlew :modules:messaging:messaging-cloudevents:test

Expected: PASS.

  • Step 5: Commit
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

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:

./gradlew :modules:messaging:messaging-core-api:test --tests '*PublishResultTest'

Expected: FAIL because publish contracts are missing.

  • Step 3: Implement publish API and invariants
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:

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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
package io.backend.skeleton.messaging.api.delivery;

import java.util.concurrent.CompletionStage;

public interface MessageHandler<T> {
    CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
}
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:

./gradlew :modules:messaging:messaging-core-api:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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:

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:

./gradlew :modules:messaging:messaging-policy:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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();
}
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:

./gradlew :modules:messaging:messaging-transport-spi:test

Expected: PASS.

  • Step 5: Commit
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

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");
    }
}
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:

./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
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 {}
}
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:

./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test

Expected: PASS.

  • Step 5: Commit
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

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:

publishesAndConfirms
returnsAmbiguousWhenConfirmIsLost
redeliversWhenSettlementIsLost
preservesMessageIdAcrossRetryAndDlq
keepsSourceUnsettledWhenDlqPublishFails
rejectsOversizedPayloadBeforeTransport
stopsAcceptingNewWorkDuringShutdown
  • Step 2: Run testkit tests and verify missing abstract suite

Run:

./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
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:

./gradlew :modules:messaging:messaging-testkit:test

Expected: PASS with every abstract contract method executed by the in-memory harness.

  • Step 5: Commit
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

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:

./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
package io.backend.skeleton.messaging.policy;

public interface RetryDecisionEngine {
    RetryDecision decide(RetryContext context);
}

Implement the decision order exactly:

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:

./gradlew :modules:messaging:messaging-policy:test

Expected: PASS.

  • Step 5: Commit
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

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:

./gradlew :modules:messaging:messaging-policy:test --tests '*DeadLetterOrchestratorTest'

Expected: FAIL because DLQ orchestration types are missing.

  • Step 3: Implement confirmed publish before settlement
package io.backend.skeleton.messaging.policy;

import java.util.concurrent.CompletionStage;

public interface SourceSettlement {
    CompletionStage<Void> settle();
}

DeadLetterOrchestrator.deadLetter must:

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:

./gradlew :modules:messaging:messaging-policy:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' --tests '*KafkaContainerSmokeTest'

Expected: PASS and the broker reports a 4.3.x version.

  • Step 5: Commit
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

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();
        }
    }
}
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:

./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
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:

./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
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

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);
    }
}
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:

./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
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:

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:

./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
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

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);
    }
}
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:

./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:

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:

./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
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

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:

./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
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:

./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
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

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:

./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:

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:

./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' --tests '*KafkaTopologyValidationIT'

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-kafka-share-experimental:test

Expected: PASS. The module does not participate in the default starter classpath.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' --tests '*RabbitContainerSmokeTest'

Expected: PASS and the server reports a 4.3.x version.

  • Step 5: Commit
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

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();
        }
    }
}
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:

./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
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:

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:

./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' --tests '*RabbitConfirmLossIT'

Expected: PASS.

  • Step 5: Commit
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

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();
    }
}
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:

./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:

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:

./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' --tests '*RabbitAckLossIT'

Expected: PASS. ACK loss causes redelivery with redelivered=true.

  • Step 5: Commit
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

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);
    }
}
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:

./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:

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:

./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'

Expected: PASS.

  • Step 5: Commit
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

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");
    }
}
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:

./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
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:

./gradlew :modules:messaging:messaging-reliability-api:test :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test

Expected: PASS.

  • Step 5: Commit
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

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);
    }
}
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:

./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:

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:

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:

./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
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

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);
    }
}
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:

./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
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:

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:

./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
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

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:

./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
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:

./gradlew :modules:messaging:messaging-claim-check:test

Expected: PASS.

  • Step 5: Commit
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

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:

./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
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:

./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test

Expected: PASS.

  • Step 5: Commit
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

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);
    }
}
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:

./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:

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:

./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
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

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);
    }
}
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:

./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:

./gradlew :modules:messaging:messaging-observability:test

Expected: PASS. Static log scanning finds no payload, token, or full message ID.

  • Step 5: Commit
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

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:

./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:

./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
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

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:

./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:

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:

./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
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

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:

./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
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:

./gradlew :modules:messaging:messaging-spring-boot-starter:test

Expected: PASS.

  • Step 5: Commit
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

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();
    }
}
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:

./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
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:

./gradlew :modules:messaging:messaging-core-api:test :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest'

Expected: PASS.

  • Step 5: Commit
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

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:

./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:

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:

./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
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

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();
        }
    }
}
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:

./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:

./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
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

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);
    }
}
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:

./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:

./gradlew :modules:messaging:messaging-nats-experimental:test

Expected: PASS. The module remains Experimental and disabled by default.

  • Step 5: Commit
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

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:

./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:

./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test

Expected: PASS.

  • Step 5: Commit
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

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();
    }
}
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:

./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:

./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
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

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:

./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:

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:

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:

./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
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

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();
    }
}
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:

./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:

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:

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:

./gradlew messagingPerformance messagingCompatibility

Expected: PASS with generated reports under build/reports/messaging and no resource leak.

  • Step 5: Commit
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

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:

./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:

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:

./gradlew clean check messagingStableChaos messagingPerformance messagingCompatibility

Expected: PASS with zero test failures, zero architecture violations, and generated Stable release reports.

  • Step 5: Commit
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