432 lines
18 KiB
Markdown
432 lines
18 KiB
Markdown
# 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
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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
|
|
|
|
```java
|
|
@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
|
|
|
|
```java
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```groovy
|
|
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.
|