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

4.3 KiB

Application Outbox Failure Reporting — Harness-Free Design

Context

application-core currently carries Spring Boot and SLF4J only because PublishPendingOutboxEventsUseCase renders relay failures itself. That reverses the diagnostic dependency direction and also permits a duplicate WARN in OutboxMessagePublishAdapter.

This change is harness-free: src/config/architecture/modules.json, Gradle, ArchUnit, and focused module tests are the policy and evidence authorities. No .harness files or public paths change.

Boundary

application-core owns a specific OutboxRelayFailureReportPort and an immutable OutboxRelayFailureReport. The report is an allowlist containing only:

  • OperationalError code
  • event, aggregate, and correlation identifiers
  • event type, attempt count, optional next-attempt time
  • the originating RuntimeException

It never carries the payload, idempotency key, message template, severity, arbitrary fields, or the whole OutboxEvent. Factories and record invariants admit only retryable OUTBOX_PUBLISH_FAILED reports with a next-attempt time and terminal OUTBOX_DEAD_LETTER reports without one.

adapter:outbound:messaging owns Slf4jOutboxRelayFailureReportAdapter. It maps the typed report to one canonical SLF4J 2 fluent ERROR with fixed key names and runbook links. Bootstrap only wires the port.

Ordering and Failure Semantics

The persisted FAILED or DEAD transition is authoritative:

  1. broker publication fails;
  2. the application calculates the transition;
  3. the store transition succeeds inside TransactionPort;
  4. only then is the typed report emitted.

A transition failure propagates and emits no report. A reporter RuntimeException is contained by both the adapter and the use case, so it cannot change the relay outcome or prevent later events from running. Successful publication and markPublished failures emit no failure report.

There is no production no-op reporter. MessagingConfig always contributes exactly one reporter bean, using the configured broker name or disabled when blank. OutboxMessagePublishAdapter becomes mapping/send-only: runtime failures propagate, checked failures are wrapped with their cause, and it emits no success or failure log. The general OutboundMessagePublisher retains its existing fail-open dependency logging.

Structured ERROR Contract

Every confirmed transition produces one ERROR with the common fields:

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

Retryable failures additionally carry next_attempt_at. Mappings are:

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

The originating exception is attached as the throwable. Payload, idempotency key, envelope data, message templates derived from the exception, and arbitrary exception fields are forbidden. The adapter's fail-open boundary also applies to invalid direct calls: report(null) must never throw. The focused structured-adapter test pins this behavior.

Enforcement and Tests

  • Value tests enforce invariants and reflectively pin the exact record component allowlist.
  • Relay tests pin transition-before-report ordering, no-report paths, exact cardinality, and reporter containment.
  • Messaging tests capture Logback events and pin level, fields, throwable, and unsafe-data absence.
  • verifyApplicationCoreDependencyPurity rejects non-project production declarations and forbidden Spring/logging/metrics groups on resolved application classpaths.
  • APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK bans SLF4J, JUL, Logback, Log4j, and Micrometer from the exact dev.caskeleton.application.. scope. Its dedicated violation fixture also resides inside that scope, under dev.caskeleton.application.architecture.violations, proving the rule is non-vacuous.
  • application-core test dependencies are reduced to JUnit Jupiter and AssertJ; all other leaves keep the shared Spring Boot test baseline.

Scope

No public path, CI workflow, module-registry edge, payload shape, outbox persistence schema, or general publisher logging behavior changes. Agents do not stage, commit, amend, or push.