Date: 2026-02-11
Accepted
Parent Requirement: specs/0010-aws-sqs-dead-letter-queue/requirements.md
Scope: This ADR covers SQS-specific implementation decisions for DLQ support. The general DLQ strategy (ADR 0034) and rejection routing logic (ADR 0036) apply unchanged; this ADR addresses only the transport-level concerns unique to SQS.
SqsMessageConsumer.RejectAsync() checks a _hasDlq flag:
- When true: calls
ChangeMessageVisibilityAsync(receiptHandle, 0), making the message immediately visible again and relying on the native SQS redrive policy to eventually move it to the DLQ aftermaxReceiveCountattempts. - When false: deletes the message.
This is indirect — a Reject with DeliveryError intent should move the message to the DLQ immediately, not cycle it through re-receives.
The consumer factory currently sets hasDlq as:
hasDlq: sqsSubscription.QueueAttributes.RedrivePolicy == nullThis is inverted: _hasDlq is true when there is no native redrive policy. The flag actually means "has no native DLQ, so use the visibility trick". This naming is misleading and will be replaced by the new approach.
Both Paramore.Brighter.MessagingGateway.AWSSQS (v3 SDK) and Paramore.Brighter.MessagingGateway.AWSSQS.V4 (v4 SDK) have identical reject logic. Both must be updated identically.
Apply the same pattern established by the Kafka DLQ implementation (spec 0001):
SqsSubscriptionimplementsIUseBrighterDeadLetterSupportandIUseBrighterInvalidMessageSupportSqsMessageConsumerFactoryextracts routing keys and passes them to the consumerSqsMessageConsumeruses lazyIAmAMessageProducerinstances for DLQ/Invalid Message channels- Routing logic follows ADR 0036 (route by
MessageRejectionReason) - Messages are enriched with metadata per ADR 0036
Instead of ChangeMessageVisibility(0), rejection will:
- Enrich the message with rejection metadata
SendMessageto the DLQ queue (or invalid message queue, per ADR 0036 routing)DeleteMessagefrom the source queue
This makes rejection immediate and deterministic — no dependency on the redrive policy cycle.
Users may have an SQS redrive policy configured independently of Brighter. Two scenarios:
| Brighter DLQ Configured | Native Redrive Policy | Behaviour |
|---|---|---|
| Yes | Any | Brighter sends directly to its configured DLQ queue. Native policy is irrelevant for rejected messages. |
| No | Exists | Message is deleted on reject (current fallback). The native policy only applies to visibility-timeout expiry, not explicit deletes. |
| No | None | Message is deleted on reject, logged as warning. |
Decision: Brighter-managed DLQ takes precedence when configured. The _hasDlq flag is removed entirely — its role is replaced by the presence or absence of DeadLetterRoutingKey.
The DLQ target is an SQS queue identified by a RoutingKey. The consumer needs to resolve this to a queue URL. We reuse the existing EnsureChannel / GetQueueUrl infrastructure that SqsMessageConsumer already uses for the source queue. The lazy producer handles this internally.
The SqsMessageConsumer will create lazy SqsMessageProducer instances for DLQ and invalid message channels, following the same pattern as KafkaMessageConsumer.
It needs to use the SqsMessageProducerFactory to create a producer, so as to pick up the call to ConfirmQueueExists or ConfirmQueueExistsAsync. The publication used to create the producer, should inherit the MakeChannels setting from the consumer.
The producer's sync/async variant matches the consumer per ADR 0034.
SQS does not have partition or offset concepts. The enriched metadata will use the subset relevant to SQS:
OriginalTopic— source queue nameRejectionTimestamp— when rejection occurredRejectionReason—DeliveryErrororUnacceptableRejectionMessage— description fromMessageRejectionReasonOriginalMessageType— the message type header
This follows the same HeaderNames constants used by the Kafka implementation.
- Rejected messages reach the DLQ immediately instead of cycling through re-receives
- Consistent pattern with Kafka — developers familiar with one transport understand the other
- Removes the misleading
_hasDlqflag - Enables
IUseBrighterDeadLetterSupportandIUseBrighterInvalidMessageSupporton SQS subscriptions
- Users relying on the
ChangeMessageVisibility(0)+ native redrive policy pattern will see a behaviour change when they configure a Brighter DLQ routing key - Both AWSSQS and AWSSQS.V4 packages must be updated in lockstep
Risk: Users who depend on native redrive policy counting may be surprised.
- Mitigation: Brighter DLQ is opt-in via
DeadLetterRoutingKey. Without it, behaviour is unchanged (message deleted on reject).
Risk: SendMessage to DLQ fails, then DeleteMessage also fails — message stuck.
- Mitigation: Per ADR 0036, log the error and continue. The message becomes visible again after its visibility timeout, which is the same as current behaviour for transient failures.
Continue using the visibility trick for native DLQ, and only use direct send for Brighter-managed DLQ.
Rejected: The visibility trick is always inferior — it causes re-processing cycles. If a user configures a Brighter DLQ routing key, they want immediate routing.
Put rejection metadata in SQS message attributes rather than the message header bag.
Rejected: Keeping metadata in the Brighter MessageHeader.Bag is consistent with the Kafka pattern and means DLQ consumers use the same deserialization path. SQS message attributes would require transport-specific handling.
- ADR 0034: Provide DLQ Where Missing — overall DLQ strategy
- ADR 0036: Message Rejection Routing Strategy — routing logic and metadata enrichment
- Spec 0001: Kafka Dead Letter Queue — reference implementation
- Requirements: specs/0010-aws-sqs-dead-letter-queue/requirements.md