18 KiB
Application Outbox Failure Reporting Refactoring Design
- Date: 2026-07-25
- Status: Approved
- Scope:
application-coreoutbox 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:14declaresspring-boot-starter.src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:17-18imports 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-13says the module is framework-free and accesses infrastructure only through*Portinterfaces.src/application-core/CLAUDE.md:27-30andsrc/application-core/build.gradle:3-5instead claim the starter is retained for optional@Serviceregistration, 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_FAILEDandOUTBOX_DEAD_LETTERto the infrastructure owner layer and ERROR severity:docs/registries/error-codes.yaml:724-749. - The runbooks require structured
error.code,event_id,event_type, andcorrelation_idfields:docs/runbooks/outbox-publish-failed.md:17-27anddocs/runbooks/outbox-dead-letter.md:17-24. - The current project-dependency verifier inspects only
ProjectDependencyinstances, so it cannot reject an external starter added to a core module:src/build.gradle:612-619.
3. Goals
- Make
application-corefree of Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer types and main/test classpath dependencies. - Express confirmed outbox publication failures as a typed application-owned outbound port.
- Keep report data safe by construction: no payload, idempotency key, arbitrary field map, log level, message template, or framework logger crosses the port.
- Implement structured failure reporting in
adapter:outbound:messaging. - Keep
app-bootstraplimited to final wiring and runtime logging configuration. - Preserve outbox state-machine behavior, transaction boundaries, per-event continuation, and at-least-once semantics.
- Ensure a reporting backend failure cannot change a persisted
FAILED/DEADoutcome or stop the remaining relay batch. - Remove the duplicate, misleading fail-open WARN currently emitted by the fail-closed outbox publisher.
- 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
.harnesspolicy 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
FAILEDor terminalDEAD; - 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:
codeis exactlyOUTBOX_PUBLISH_FAILEDorOUTBOX_DEAD_LETTER.- identifiers and type names are non-null and non-blank.
attemptCountis at least one.nextAttemptAtis required forOUTBOX_PUBLISH_FAILEDand absent forOUTBOX_DEAD_LETTER.causeis 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.codeerror.categorydependency_namedependency_type=messagingoutcomeevent_idevent_typeaggregate_idcorrelation_idattempt_countrunbook_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:
OutboundMessagePublisherkeepsFailOpenDependencyLoggerbecause its contract is genuinely fail-open.OutboxMessagePublishAdaptermaps/sends and surfaces failures without logging.Slf4jOutboxRelayFailureReportAdapteremits 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:
- application-core production configurations contain no declared external module dependency;
compileClasspath,runtimeClasspath,testCompileClasspath, andtestRuntimeClasspathresolve 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
markPublishedfailure emit no failure report; - a throwing reporter does not change
FAILED/DEADresult 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
testandcheck.
14. Migration Sequence
- Recover and validate the harness module registry so Gradle can configure.
- Add red tests for safe report contracts and relay semantics.
- Add the application report value and port.
- Inject the port into the relay and make report failures outcome-neutral.
- Add red messaging adapter and wiring tests.
- Implement the structured messaging reporter.
- Remove fail-open logging from the fail-closed outbox publisher.
- Remove application-core Boot/Spring/logging dependencies and give it a pure test baseline.
- Add source and resolved-classpath purity guards.
- Regenerate dependency locks and update module/runbook documentation.
- Run focused, architecture, dependency, full test, and full check gates.
- 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-starteris absent fromsrc/application-core/build.gradle.- Every confirmed
FAILED/DEADtransition 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
checkpass after harness registry recovery, or any environmental blocker is reported with its remaining risk.