196 KiB
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, SpringMessage<?>, 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-apihas 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 localUuidV7.next()implementation created in this Task. -
No enum contains
EXACTLY_ONCEorGLOBAL. -
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 immutableMessageHeadersfor 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
MessageEnvelopeand 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.
-
AMBIGUOUSis 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, orRejectdecisions. -
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 toAMBIGUOUS. -
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_PARTITIONretry 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_committedtests
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, Request–Reply 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 request–reply.
-
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 request–reply tests and verify failure
Run:
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'
Expected: FAIL because retry and request–reply types are missing.
- Step 3: Implement bounded retry routing and request–reply 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 request–reply as the default RPC mechanism.
- Step 4: Run Rabbit retry, DLQ, and request–reply 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 LOCKEDleasing, 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
ClaimCheckStorewithout 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
MessagePublisherand 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_idas 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-Iddedupe, 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:
M1–M4 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 2–8, 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 33–35
- 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 15–20
- RabbitMQ Stable: Tasks 22–25
- Outbox·Inbox·Claim Check: Tasks 26–29, 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.publishalways returnsCompletionStage<PublishResult>.MessageHandler.handlealways returnsCompletionStage<HandleResult>.PublishCompletionconstants areCONFIRMED,REJECTED,AMBIGUOUS.SettlementCompletionconstants areSETTLED,REJECTED,UNKNOWN.RetryModeconstants match the design exactly.messageIdremainsMessageIdfrom 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