Skip to main content
Version: 1.9.x

Handlers

Handler Types & Interfaces

The library provides two complementary handler interfaces for different use cases:

Typed Handlers (Type-Safe)

@Component
class OrderCreatedHandler : OutboxTypedHandler<OrderCreatedRecord> {
override fun getTypedHandlerIdentity() =
OutboxHandlerIdentity(id = "orders.publish-created")

override fun handle(payload: OrderCreatedRecord, metadata: OutboxRecordMetadata) {
println("Processing order: ${payload.orderId}")
eventPublisher.publish(payload)
}
}

Generic Handlers (Multi-Type)

@Component
class UniversalHandler : OutboxHandler {
override fun getGenericHandlerIdentity() =
OutboxHandlerIdentity(id = "events.publish-generic")

override fun handle(payload: Any, metadata: OutboxRecordMetadata) {
when (payload) {
is OrderCreatedRecord -> handleOrder(payload)
is PaymentProcessedRecord -> handlePayment(payload)
is CreateCustomerCommand -> createCustomer(payload)
else -> logger.warn("Unknown payload: ${payload::class.simpleName}")
}
}
}

Handler Invocation Order

When multiple handlers are registered:

  1. All matching typed handlers are invoked first (in registration order)
  2. All generic handlers are invoked second (catch-all)

Annotation-based Handlers

Use @OutboxHandler annotation for method-level handler registration as an alternative to implementing interfaces:

@Component
class MyHandlers {
@OutboxHandler(id = "orders.publish-created")
fun handleOrderCreated(payload: OrderCreatedRecord) {
// ...
}

@OutboxHandler(id = "payments.publish-processed")
fun handlePaymentProcessed(payload: PaymentProcessedRecord) {
// ...
}

@OutboxHandler(id = "events.publish-generic")
fun handleAny(payload: Any, metadata: OutboxRecordMetadata) {
// Generic handler via annotation
}
}
Handler Signature Requirements
  • Typed handlers can accept 1 or 2 parameters:
    • fun handle(payload: T) - Payload only
    • fun handle(payload: T, metadata: OutboxRecordMetadata) - Payload + metadata
  • Generic handlers must accept 2 parameters:
    • fun handle(payload: Any, metadata: OutboxRecordMetadata) - Required signature

Interface vs Annotation:

  • Interfaces: Best when entire class is dedicated to handling a single type
  • Annotations: Best when a class handles multiple types or mixing with other logic

Stable Handler Identities

The handler ID is persisted on every outbox record and later used to route that record to its handler. It is therefore a durable contract between the database and the application, not merely a runtime implementation detail.

Define an explicit, stable ID for production handlers. Prefer a name that describes the handler's logical responsibility, such as orders.publish-created, and keep it independent of Java or Kotlin package, class, method, and parameter names. Explicit handler IDs and aliases must be non-blank and unique across the application.

When no explicit ID is configured, Namastack Outbox remains backward compatible and generates an ID from the handler's class, method, and parameter types. Lambda handlers use their Spring bean name. Generated IDs are convenient for prototypes, but refactoring any participating name or type can leave pending records pointing to an ID that a later deployment no longer provides.

Annotation-based Identity

@OutboxHandler(
id = "orders.publish-created",
aliases = ["orders.send-created"]
)
fun handleOrderCreated(payload: OrderCreatedRecord) {
eventPublisher.publish(payload)
}

Interface-based Identity

Return an OutboxHandlerIdentity from the role-specific identity method. Typed handlers use getTypedHandlerIdentity() and generic handlers use getGenericHandlerIdentity().

@Component
class OrderCreatedHandler : OutboxTypedHandler<OrderCreatedRecord> {
override fun getTypedHandlerIdentity() =
OutboxHandlerIdentity(
id = "orders.publish-created",
aliases = setOf("orders.send-created")
)

override fun handle(payload: OrderCreatedRecord, metadata: OutboxRecordMetadata) {
eventPublisher.publish(payload)
}
}

Aliases are lookup-only: they resolve existing records but are never written to new records and do not create additional handler invocations. Use an alias only when both IDs represent the same logical handler and a compatible payload contract. For an incompatible change in meaning or payload, introduce a new handler ID instead.

Migrating an ID Safely

Changing an explicit ID during a rolling deployment requires two releases so that old and new application instances understand every ID that may be written:

  1. Keep the current ID canonical and add the future ID as an alias. Deploy this version everywhere.
  2. Promote the future ID to canonical and retain the previous ID as an alias.
  3. Remove the old alias only after no record can still contain it and no old application instance is running.

For example, release one uses:

@OutboxHandler(
id = "orders.send-created",
aliases = ["orders.publish-created"]
)

Release two then uses:

@OutboxHandler(
id = "orders.publish-created",
aliases = ["orders.send-created"]
)

The same two-release approach is recommended when moving from a generated ID to an explicit ID: first keep the generated ID by omitting id and add the future ID as an alias; in the next release, set the explicit ID. The generated ID is then retained automatically as a legacy alias where it can be reconstructed reliably.


OutboxRecordMetadata

Handlers that accept OutboxRecordMetadata receive processing metadata for the current handler invocation:

  • key - Logical record key used for ordered processing
  • handlerId - Handler assigned to the record
  • createdAt - Time when the outbox record was created
  • context - Propagated context such as trace IDs or tenant IDs
  • failureCount - Number of failed processing attempts before this invocation
  • attempt - One-based attempt number, equal to failureCount + 1
  • isRetry - true when failureCount > 0

For normal handlers, failureCount == 0 means the first delivery attempt. A retry is processed by the same @OutboxHandler with failureCount > 0.

@OutboxHandler(id = "orders.send-email")
fun handleOrder(payload: OrderEvent, metadata: OutboxRecordMetadata) {
if (metadata.isRetry) {
logger.info("Retrying order ${payload.orderId} on attempt ${metadata.attempt}")
}
emailService.send(payload.email)
}

Fallback Handlers

Fallback handlers provide a safety net when all retries are exhausted, allowing for compensating actions, dead letter queue publishing, or alternative processing strategies.

Fallback handlers are automatically invoked when:

  • Retries Exhausted: The record has exceeded the maximum retry count
  • Non-Retryable Exceptions: An exception is thrown that should not be retried (based on retry policy)

Fallback handlers are not invoked for each retry. Retry attempts are processed by the normal handler; fallback handlers run only when normal processing can no longer continue.

Interface-Based Fallback Handlers

@Component
class OrderFallbackHandler : OutboxFallbackHandler<OrderEvent> {
override fun handle(payload: OrderEvent, context: OutboxFailureContext) {
logger.error(
"Order ${payload.orderId} failed permanently after ${context.failureCount} attempts",
context.lastException
)
// Publish to dead letter queue
deadLetterQueue.publish(
payload = payload,
reason = "Max retries exceeded",
exception = context.lastException,
traceId = context.context["traceId"]
)
// Send alert
alertService.sendAlert(
"Order processing failed permanently: ${payload.orderId}"
)
}
}

Annotation-Based Fallback Handlers

@Component
class OrderHandlers {
@OutboxHandler(id = "orders.send-email")
fun handleOrder(payload: OrderEvent) {
emailService.send(payload.email) // May fail
}
@OutboxFallbackHandler
fun handleOrderFailure(payload: OrderEvent, context: OutboxFailureContext) {
logger.error(
"Order ${payload.orderId} failed after ${context.failureCount} attempts"
)
deadLetterQueue.publish(payload)
}
}

OutboxFailureContext

The OutboxFailureContext provides comprehensive failure information:

interface OutboxFailureContext {
val handlerId: String // Handler that failed
val recordId: String // Unique identifier of the record
val recordKey: String // Record key
val createdAt: Instant // When record was created
val failureCount: Int // Number of failed attempts
val lastFailure: Throwable? // Last exception thrown
val retriesExhausted: Boolean // True if retry limit was reached
val nonRetryableException: Boolean // True if failure was due to non-retryable exception
val context: Map<String, String> // Propagated context (traceId, tenantId, etc.)
}

Fallback Behavior

Record Status After Fallback:

  • Fallback Succeeds: Record marked as COMPLETED
  • Fallback Fails: Record marked as FAILED (requires manual intervention)

Automatic Matching:

Fallback handlers are automatically matched to primary handlers by payload type. One fallback handler can serve multiple primary handlers processing the same payload type.

@Component
class OrderHandlers {
// Both handlers share the same fallback
@OutboxHandler(id = "orders.create")
fun handleOrderCreated(payload: OrderEvent) {
orderService.create(payload)
}
@OutboxHandler(id = "orders.update")
fun handleOrderUpdated(payload: OrderEvent) {
orderService.update(payload)
}
@OutboxFallbackHandler
fun handleOrderFailure(payload: OrderEvent, context: OutboxFailureContext) {
// Handles failures from both handleOrderCreated and handleOrderUpdated
deadLetterQueue.publish(payload)
}
}
Fallback Handler Requirements
  • Only one fallback handler per payload type is supported
  • Fallback handlers must match the payload type exactly
  • Fallback signature: fun handle(payload: T, context: OutboxFailureContext)

Fallback Use Cases

Common use cases for fallback handlers:

  1. Dead Letter Queue: Publish failed records to a DLQ for later analysis
  2. Alert & Monitoring: Send alerts when records fail permanently
  3. Compensating Actions: Execute compensating transactions (e.g., refund, rollback)
  4. Alternative Processing: Route to alternative processing logic
  5. Audit Logging: Log failure details for compliance and debugging