DlqStore is the SPI behind the @DeadLetterQueue annotation. It persists events that exhausted their retry budget so an operator (or an automated replay job) can investigate, fix, and re-process them. The MongoDB-backed implementation is auto-configured; any custom backend (Kafka topic, RabbitMQ queue, JDBC table, S3 bucket, …) plugs in by registering a DlqStore Spring bean.
When the SPI is exercised
| Path | Calls into the SPI |
|---|---|
| Handler exhausts its retry budget | Framework invokes save(failedEvent, policy) automatically. |
Manual ctx.sendToDlq(reason) from a handler | Same — save(failedEvent, policy). Re-throws on failure so the user code can react. |
| Operator inspects the DLQ | Custom callers (admin tooling, the FlowWarden Console) use findById and findByStreamName. |
Interface
Semantic contract
savemust store the entry underevent.id()and, if the backend supports it, set up native expiry frompolicy.retentionDays()(e.g. MongoDB TTL index, Kafka message TTL). WhenretentionDays == 0, entries are kept indefinitely.- The caller applies
policy.includeOriginalDocument()andpolicy.includeStackTrace()to theFailedEventpayload before callingsave. Implementations don’t re-check those flags — they just persist what they’re given. findByIdreturnsOptional.empty()for missing or already-expired entries.findByStreamNamereturns an empty list (nevernull) when no entry matches. Ordering is implementation-defined.
FailedEvent record
expiresAt is precomputed by the framework from createdAt + policy.retentionDays() (see DlqPolicy.computeExpiresAt). Backends with native TTL use this directly; backends without it can ignore the field and rely on a custom sweep.
DlqPolicy record
The backend-neutral view of @DeadLetterQueue:
@MongoDlqOptions(collection = "...")). Those live outside DlqPolicy — the implementation reads its own companion annotation directly from the @ChangeStream definition.
Provided implementations
| Implementation | When active | Storage |
|---|---|---|
MongoDlqStore | Default in both imperative and reactive modes | One collection per @ChangeStream (configurable via @MongoDlqOptions(collection = "..."), defaulting to a per-stream _fw_dlq_<streamName> name). A MongoDB TTL index on expiresAt is created at startup so retentionDays takes effect without manual setup. |
The Redis backend (
flowwarden-redis) does not ship a DlqStore implementation — only LockService and CheckpointStore. DLQ persistence in a Redis-backed deployment falls back to the default MongoDlqStore (so MongoDB is still required), or to a user-provided custom bean.No-op default
DlqStore.noOp() returns a shared singleton that silently discards everything:
Providing a custom implementation
The auto-configured bean is guarded by@ConditionalOnMissingBean, so any @Bean DlqStore you declare wins. A custom implementation routes events by reading event.streamName() and resolving it to its backend resource (a Kafka topic, a Rabbit queue, a JDBC table, …):
MongoDlqStore’s pattern (per-stream resources resolved from a companion annotation), the framework exposes a registerStream(streamName, collection)-style hook at startup — see MongoDlqStore in the core module as the reference implementation, which uses this hook to create the TTL index.
Testing the contract
If you write a customDlqStore, run it against the contract tests published in flowwarden-stream-core-testkit:
DlqStoreContractTest and implement the abstract factory method. The contract covers save / findById / findByStreamName semantics and policy-driven expiry — the same suite MongoDlqStore is validated against.
See Also
@DeadLetterQueue annotation
The user-facing annotation that drives this SPI.
Retry & DLQ Guide
DLQ routing, manual sends, document schema, best practices.
CheckpointStore SPI
The sister SPI that backs
@Checkpoint.LockService SPI
The sister SPI that backs
DeploymentMode.SINGLE_LEADER.