Files
clean-architecture-backend-…/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md

18 KiB

Application Outbox Failure Reporting Refactoring Design

  • Date: 2026-07-25
  • Status: Approved
  • Scope: application-core outbox failure reporting, its messaging adapter, bootstrap wiring, dependency purity enforcement, tests, and affected module documentation
  • Source: user-requested Clean Architecture refactoring review plus repository evidence gathered on 2026-07-25

1. Problem Statement

application-core declares org.springframework.boot:spring-boot-starter, although its production sources use no Spring type or annotation. The only external observability types in the module are org.slf4j.Logger and org.slf4j.LoggerFactory in PublishPendingOutboxEventsUseCase. The broad starter consequently places Boot autoconfiguration, Spring Context/AOP, Micrometer Observation, Logback, Log4j bridges, JUL bridges, and SnakeYAML on a core application classpath for two logging calls.

This contradicts the module's framework-free design statement and weakens the dependency direction the template is intended to teach. It also hides an important semantic distinction: the two log lines are not arbitrary diagnostic messages. They report confirmed FAILED and DEAD outbox state transitions that feed operational alerts and runbooks.

The build currently cannot resolve dependencies or run tests because src/settings.gradle fails when .harness/project/modules.yaml is absent. Harness registry recovery is therefore a prerequisite for implementation and verification, not part of this refactoring.

2. Evidence

  • src/application-core/build.gradle:14 declares spring-boot-starter.
  • src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:17-18 imports the only production observability framework types in the module.
  • The same use case creates an SLF4J logger at lines 38-39 and emits the two failure records at lines 139-165.
  • src/application-core/README.md:10-13 says the module is framework-free and accesses infrastructure only through *Port interfaces.
  • src/application-core/CLAUDE.md:27-30 and src/application-core/build.gradle:3-5 instead claim the starter is retained for optional @Service registration, although application-core contains no Spring stereotype.
  • The relay is manually constructed and must not be a Spring bean: src/application-core/README.md:332-337.
  • The outbox registry assigns OUTBOX_PUBLISH_FAILED and OUTBOX_DEAD_LETTER to the infrastructure owner layer and ERROR severity: docs/registries/error-codes.yaml:724-749.
  • The runbooks require structured error.code, event_id, event_type, and correlation_id fields: docs/runbooks/outbox-publish-failed.md:17-27 and docs/runbooks/outbox-dead-letter.md:17-24.
  • The current project-dependency verifier inspects only ProjectDependency instances, so it cannot reject an external starter added to a core module: src/build.gradle:612-619.

3. Goals

  1. Make application-core free of Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer types and main/test classpath dependencies.
  2. Express confirmed outbox publication failures as a typed application-owned outbound port.
  3. Keep report data safe by construction: no payload, idempotency key, arbitrary field map, log level, message template, or framework logger crosses the port.
  4. Implement structured failure reporting in adapter:outbound:messaging.
  5. Keep app-bootstrap limited to final wiring and runtime logging configuration.
  6. Preserve outbox state-machine behavior, transaction boundaries, per-event continuation, and at-least-once semantics.
  7. Ensure a reporting backend failure cannot change a persisted FAILED/DEAD outcome or stop the remaining relay batch.
  8. Remove the duplicate, misleading fail-open WARN currently emitted by the fail-closed outbox publisher.
  9. Add source- and dependency-level guardrails that prevent framework observability from returning to application-core.

4. Non-Goals

  • This change does not redesign outbox claiming, FIFO ordering, retry backoff, in-flight recovery, or broker selection.
  • It does not turn operational failures into domain events or business audit records.
  • It does not deliver a failure report through the same broker/outbox path; that would recurse when the broker is the failing dependency.
  • It does not add a generic LoggerPort, severity API, string template API, or untyped field map.
  • It does not add a twentieth module or a general observability adapter family.
  • It does not recover the missing .harness policy registry; the implementation waits for that independently governed recovery.
  • It does not change public HTTP response contracts.

5. Semantic Classification

An outbox publish failure is an application operational event:

  • the application state machine decides whether the confirmed result is retryable FAILED or terminal DEAD;
  • the persistence transition is authoritative;
  • an infrastructure adapter renders that fact as a structured operational record;
  • metrics and runbooks consume the result for operations.

It is not a business audit event. It has no actor/action audit semantics, is not retained as an immutable audit ledger, and must not be used as proof of a business transaction. It is also not a domain event: feeding it into the same outbox publisher would recursively fail.

6. Architecture

PublishPendingOutboxEventsUseCase
    │
    ├── OutboxStorePort ──────────────> persistence adapter
    ├── OutboxMessagePublishPort ─────> messaging publisher adapter
    └── OutboxRelayFailureReportPort ─> SLF4J structured reporter adapter
                                            (adapter:outbound:messaging)

app-bootstrap
    └── injects all three ports when manually constructing the relay use case

Dependency direction remains:

app-bootstrap
    -> adapter:outbound:messaging
        -> application-core
            -> shared-contract
            -> domain-core

application-core owns the port and safe report value. The messaging module owns the concrete rendering because its local responsibility explicitly includes outbox publication adaptation, it already depends on application/shared contracts, and it already has the SLF4J API. Bootstrap selects and injects the adapter but does not implement the port.

7. Application Contract

7.1 Port

@FunctionalInterface
public interface OutboxRelayFailureReportPort {

  /**
   * Attempts to report a confirmed FAILED or DEAD relay transition.
   *
   * <p>The implementation must not throw. The report is operational evidence, while the persisted
   * outbox state and returned relay outcome remain authoritative.
   */
  void report(OutboxRelayFailureReport report);
}

The use case also contains a defensive non-throwing invocation boundary. This makes the invariant explicit even if a custom implementation violates the port contract.

7.2 Safe immutable report

public record OutboxRelayFailureReport(
    OperationalError code,
    String eventId,
    String eventType,
    String aggregateId,
    String correlationId,
    int attemptCount,
    Instant nextAttemptAt,
    RuntimeException cause) {

  public static OutboxRelayFailureReport retryableFailure(
      String eventId,
      String eventType,
      String aggregateId,
      String correlationId,
      int attemptCount,
      Instant nextAttemptAt,
      RuntimeException cause);

  public static OutboxRelayFailureReport deadLetter(
      String eventId,
      String eventType,
      String aggregateId,
      String correlationId,
      int attemptCount,
      RuntimeException cause);
}

Invariants:

  • code is exactly OUTBOX_PUBLISH_FAILED or OUTBOX_DEAD_LETTER.
  • identifiers and type names are non-null and non-blank.
  • attemptCount is at least one.
  • nextAttemptAt is required for OUTBOX_PUBLISH_FAILED and absent for OUTBOX_DEAD_LETTER.
  • cause is required.
  • the record has no payload, idempotencyKey, logger, severity, template, or arbitrary map.

Static factories remove invalid combinations from ordinary call sites. The record deliberately accepts an allowlisted set of safe operational metadata plus the original cause rather than an OutboxEvent, whose full shape includes payload and idempotency data.

8. Relay Flow and Failure Semantics

The report attempt happens only after the corresponding write transaction succeeds:

publish throws
    ├── retry remains
    │   ├── inWrite(markFailed) succeeds
    │   ├── attempt OUTBOX_PUBLISH_FAILED report
    │   └── return FAILED
    └── attempts exhausted
        ├── inWrite(markDead) succeeds
        ├── attempt OUTBOX_DEAD_LETTER report
        └── return DEAD

Behavior matrix:

Situation Persistence Report Relay behavior
Publish succeeds, markPublished succeeds PUBLISHED none return PUBLISHED
Publish succeeds, markPublished fails remains recoverable IN_FLIGHT none propagate store failure; scheduler retries later
Publish fails, markFailed succeeds FAILED attempt retryable report return FAILED; continue batch
Publish fails, markDead succeeds DEAD attempt dead-letter report return DEAD; continue batch
Publish fails, status transition fails no confirmed FAILED/DEAD transition none propagate store failure; do not emit a false report
Reporter violates its contract and throws already FAILED or DEAD attempted contain reporter exception; preserve outcome and continue batch

The report is mandatory as an attempt after every confirmed failure transition. A production NOOP binding is forbidden. No logging system can guarantee durable emission, so metrics and persistence remain independent evidence when the logging backend itself is impaired.

9. Structured Logging Contract

The messaging adapter emits ERROR through SLF4J 2's fluent key-value API. The runtime Logstash encoder serializes key-value pairs as top-level JSON fields, while the message also carries a short safe summary for local pattern output.

Common fields:

  • error.code
  • error.category
  • dependency_name
  • dependency_type=messaging
  • outcome
  • event_id
  • event_type
  • aggregate_id
  • correlation_id
  • attempt_count
  • runbook_link

Retry-only field:

  • next_attempt_at

Mappings:

Code Outcome Runbook
OUTBOX_PUBLISH_FAILED FAILED runbook://outbox/publish-failed
OUTBOX_DEAD_LETTER DEAD runbook://outbox/dead-letter

The throwable is attached as the log cause. Payload, idempotency key, message envelope, recipient, and arbitrary exception-derived key/value fields are never added. Existing runtime masking remains defense in depth rather than the primary privacy boundary.

10. Duplicate Logging Removal

OutboxMessagePublishAdapter currently uses FailOpenDependencyLogger for a fail-closed operation: it emits WARN and rethrows. That logger's documented meaning is an optional dependency failure where the use case still succeeds. The relay then emits a second ERROR after deciding FAILED or DEAD.

After this refactoring:

  • OutboundMessagePublisher keeps FailOpenDependencyLogger because its contract is genuinely fail-open.
  • OutboxMessagePublishAdapter maps/sends and surfaces failures without logging.
  • Slf4jOutboxRelayFailureReportAdapter emits the single canonical ERROR after the application state transition succeeds.

This removes duplicate records and makes severity match the confirmed outcome.

11. Dependency Purity

11.1 Gradle declarations

application-core production declarations become:

dependencies {
    implementation project(':domain-core')
    implementation project(':shared-contract')
}

The root test baseline gives application-core JUnit Jupiter and AssertJ directly rather than Spring Boot test starters. Spring dependency-management may remain build tooling, but Spring/logging/ Micrometer artifacts must not appear on application-core main or test compile/runtime classpaths. Static-analysis tool configurations are outside this classpath rule.

11.2 Gradle verification

A blocking verifyApplicationCoreDependencyPurity task checks both:

  1. application-core production configurations contain no declared external module dependency;
  2. compileClasspath, runtimeClasspath, testCompileClasspath, and testRuntimeClasspath resolve no Spring, SLF4J, Logback, Log4j, or Micrometer component.

The task is wired into :application-core:check.

11.3 Source verification

ArchUnit adds an application logger/metrics ban covering:

  • org.slf4j..
  • java.util.logging..
  • ch.qos.logback..
  • org.apache.logging.log4j..
  • io.micrometer..

An intentional application-package fixture proves the rule is not vacuous. The Gradle task remains necessary because ArchUnit cannot detect an unused starter that is merely present on the classpath.

12. Module Ownership Alternatives

12.1 Messaging adapter — selected

Advantages:

  • highest cohesion with outbox publication failure;
  • existing application/shared/support and SLF4J dependencies;
  • no new project edge;
  • reusable by composition roots other than app-bootstrap;
  • preserves bootstrap as wiring rather than an adapter collection.

Counterargument: reporting is observability rather than broker transport. The selected design answers this by keeping the contract application-owned and the concrete class narrowly outbox-specific; generic logging support does not move into messaging.

12.2 Shared outbound support — rejected

Advantages:

  • already owns reusable correlation and fail-open dependency logging;
  • would centralize logging backend calls.

Counterargument: the support module explicitly keeps feature-specific behavior in the owning leaf. Putting FAILED/DEAD outbox semantics there makes a low-level shared module feature-aware. Generalizing the interface would create the forbidden logger abstraction.

12.3 App bootstrap — viable fallback, not selected

Advantages:

  • owns runtime logging bootstrap and final wiring;
  • already contains outbox metrics and structured Logstash usage.

Counterargument: each feature-specific reporter placed there expands the composition root into an adapter implementation module and prevents straightforward reuse by another composition root.

12.4 Direct slf4j-api in application-core — rejected

This is the smallest dependency diff and would remove Spring Boot transitive dependencies, but it retains framework coupling, contradicts the module rule, and tests formatting calls instead of application meaning.

12.5 Generic operational event publisher — deferred

A typed cross-feature operational event sink could become valuable when several application features need the same routing. Introducing it for two outbox outcomes is premature, risks an untyped field bag, and must never be implemented through the failing outbox broker.

13. Testing Strategy

Application contract tests

  • report factory happy paths and invariant rejection;
  • record component whitelist proving payload and idempotency key are absent;
  • transient failure reports only after markFailed;
  • dead-letter failure reports only after markDead;
  • transition failure emits no report;
  • successful publish and markPublished failure emit no failure report;
  • a throwing reporter does not change FAILED/DEAD result and does not stop later events.

Messaging adapter tests

  • exactly one ERROR record;
  • code/category/outcome/runbook mapping;
  • required snake_case identifiers and attempt fields;
  • retry-only next_attempt_at;
  • throwable preservation;
  • no payload or idempotency key;
  • reporter bean exists when messaging is disabled and when a broker is active;
  • outbox publisher propagates runtime and checked broker failures without emitting the old fail-open WARN.

Architecture and Gradle tests

  • intentional application logger fixture is rejected;
  • production application packages pass the new rule;
  • application-core dependency purity task passes only with clean main/test classpaths;
  • lock verification passes after regeneration.

Regression tests

  • focused application, messaging, and bootstrap tests;
  • outbox PostgreSQL lifecycle tests when Docker is available;
  • full test and check.

14. Migration Sequence

  1. Recover and validate the harness module registry so Gradle can configure.
  2. Add red tests for safe report contracts and relay semantics.
  3. Add the application report value and port.
  4. Inject the port into the relay and make report failures outcome-neutral.
  5. Add red messaging adapter and wiring tests.
  6. Implement the structured messaging reporter.
  7. Remove fail-open logging from the fail-closed outbox publisher.
  8. Remove application-core Boot/Spring/logging dependencies and give it a pure test baseline.
  9. Add source and resolved-classpath purity guards.
  10. Regenerate dependency locks and update module/runbook documentation.
  11. Run focused, architecture, dependency, full test, and full check gates.
  12. Capture implementation evidence in the LLM Wiki as required by repository policy.

15. Acceptance Criteria

  • No application-core main or test source imports Spring, SLF4J, Logback, Log4j, JUL logging, or Micrometer.
  • No forbidden framework artifact appears on application-core main/test compile/runtime classpaths.
  • spring-boot-starter is absent from src/application-core/build.gradle.
  • Every confirmed FAILED/DEAD transition attempts exactly one typed report.
  • A status-transition failure emits no success-like failure report.
  • A reporter exception cannot change a relay outcome or stop the next claimed event.
  • Production wiring contains exactly one non-NOOP OutboxRelayFailureReportPort.
  • Structured ERROR fields and runbook links match the documented registry conventions.
  • Payload and idempotency key cannot cross the report contract and do not appear in adapter logs.
  • The fail-closed publisher no longer uses FailOpenDependencyLogger.
  • Application logger ArchUnit mutation and dependency purity checks are blocking.
  • Focused tests, dependency locks, architecture checks, full tests, and check pass after harness registry recovery, or any environmental blocker is reported with its remaining risk.