Features
Namastack Outbox for Spring Boot provides a comprehensive set of features to implement the Outbox Pattern in distributed systems with reliability, scalability, and ease of use.
What's New in 1.0.0-RC2
New Features
- 🔌 JDBC Module: New lightweight persistence module without JPA/Hibernate dependency
- 🏷️ Custom Table Naming: Support for custom table prefixes and schema names (JDBC module)
- ⚡ Virtual Threads: Full support for Java 21 virtual threads
- 🔧 Auto-Configuration:
@EnableOutboxremoved - outbox is now auto-configured - 📊 JPA Index Definitions: JPA entities now include proper index definitions
Breaking Changes
@EnableOutboxannotation removed (useoutbox.enabled=falseto opt-out)- JPA module no longer supports automatic schema creation
- Timestamps changed from
OffsetDateTimetoInstant
See the Migration Guide for details.
Core Features
Transactional Outbox Pattern
Zero Message Loss
The library ensures that domain records are never lost by storing them in the same database transaction as your business data. This guarantees consistency between your domain state and persisted records.
Benefits
- ACID Compliance: Records are saved atomically with business data
- Consistency Guarantee: No partial updates or lost records
- Failure Recovery: System crashes don't result in data loss
- Reliable Processing: Records are processed with automatic retry logic
How it Works
sequenceDiagram
participant A as Application
participant DB as Entity Table
participant O as Outbox Table
participant S as Scheduler
participant P as Handler
A->>DB: Begin Transaction
A->>DB: Save Business Data
A->>O: Save Record
DB->>A: Commit Transaction
S->>O: Poll Records
S->>P: Invoke Handler
P->>P: Process Record
S->>O: Mark Complete
Record Ordering
Guaranteed Processing Order
Records with the same key are always processed in creation order, ensuring business logic consistency and preventing race conditions.
Key Benefits:
- Aggregate Consistency: Records with the same key maintain order
- Business Logic Safety: Dependent records process in correct sequence
- Parallel Processing: Different keys process independently
- Scalable Design: No global ordering bottlenecks
Controlling Failure Behavior
Control how the scheduler handles failures within a key sequence:
Stop on First Failure Configuration
outbox:
processing:
stop-on-first-failure: true
Behavior: - When one record fails, processing stops for remaining records with the same key - Maintains strict ordering within key sequences - Prevents cascading issues from dependent records - Recommended: When records within a key have dependencies
outbox:
processing:
stop-on-first-failure: false
Behavior: - Failed records don't block independent records with the same key - Maximizes throughput for independent records - Recommended: When records within a key are independent
Behavior Comparison:
| Configuration | Record 1 | Record 2 | Record 3 | Result |
|---|---|---|---|---|
true (default) |
Success | Fails | Skipped | Record 2 retried, Record 3 waits |
false |
Success | Fails | Success | Record 2 retried independently |
Hash-based Partitioning
Instead of distributed locking, the library uses hash-based partitioning to enable horizontal scaling across multiple instances while maintaining strict record ordering per key. This approach eliminates lock contention and provides better performance.
How Partitioning Works
graph TB
A["Key: order-123"] --> H[MurmurHash3]
H --> P[Partition 42]
P --> I1[Instance 1]
B["Key: user-456"] --> H2[MurmurHash3]
H2 --> P2[Partition 128]
P2 --> I2[Instance 2]
C["Key: order-789"] --> H3[MurmurHash3]
H3 --> P3[Partition 42]
P3 --> I1
subgraph "256 Fixed Partitions"
P
P2
P3["...other partitions"]
end
subgraph "Dynamic Instance Assignment"
I1["Instance 1: Partitions 0-127"]
I2["Instance 2: Partitions 128-255"]
end
Key Benefits
- Consistent Hashing: Each key always maps to the same partition using MurmurHash3
- No Lock Contention: Eliminates distributed lock overhead and deadlock risks
- Horizontal Scaling: Partitions automatically redistribute when instances join/leave
- Load Balancing: Even distribution of partitions across all active instances
- Ordering Guarantee: Records within the same key process in strict order
- Better Performance: No lock acquisition/renewal overhead
Partition Assignment
256 fixed partitions provide fine-grained load distribution. Partitions are automatically distributed among active instances. Each key always maps to the same partition.
val partition = PartitionHasher.getPartitionForAggregate("order-123")
int partition = PartitionHasher.getPartitionForAggregate("order-123");
Instance Coordination
Instances automatically coordinate partition assignments and rebalance when topology changes. Configuration controls how aggressive this coordination is:
outbox:
instance:
heartbeat-interval-seconds: 5 # How often to send heartbeats
stale-instance-timeout-seconds: 30 # When to consider an instance dead
graceful-shutdown-timeout-seconds: 15 # Time to wait for graceful shutdown
Scaling Behavior
Instance 1: Partitions 0-84 (85 partitions)
Instance 2: Partitions 85-169 (85 partitions)
Instance 3: Partitions 170-255 (86 partitions)
Instance 1: Partitions 0-84, 170-211 (127 partitions)
Instance 3: Partitions 85-169, 212-255 (129 partitions)
🔄 Automatic Rebalancing: Partitions from failed instances are redistributed
Instance 1: Partitions 0-63 (64 partitions)
Instance 2: Partitions 64-127 (64 partitions)
Instance 3: Partitions 128-191 (64 partitions)
Instance 4: Partitions 192-255 (64 partitions)
⚖️ Load Balancing: Partitions are redistributed evenly
Record Scheduling
Outbox Service API
Schedule records for processing via the Outbox service:
@Service
class OrderService(
private val outbox: Outbox,
private val orderRepository: OrderRepository
) {
@Transactional
fun createOrder(command: CreateOrderCommand) {
val order = Order.create(command)
orderRepository.save(order)
// Schedule record with explicit key
outbox.schedule(
payload = OrderCreatedRecord(order.id, order.customerId),
key = "order-${order.id}" // Groups records for ordered processing
)
}
@Transactional
fun updateOrder(orderId: String) {
// Schedule record with auto-generated UUID key
outbox.schedule(payload = OrderUpdatedRecord(orderId))
}
}
@Service
public class OrderService {
private final Outbox outbox;
private final OrderRepository orderRepository;
public OrderService(Outbox outbox, OrderRepository orderRepository) {
this.outbox = outbox;
this.orderRepository = orderRepository;
}
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
// Schedule record with explicit key
outbox.schedule(
new OrderCreatedRecord(order.getId(), order.getCustomerId()),
"order-" + order.getId() // Groups records for ordered processing
);
}
@Transactional
public void updateOrder(String orderId) {
// Schedule record with auto-generated UUID key
outbox.schedule(new OrderUpdatedRecord(orderId));
}
}
Record Lifecycle
Records go through the following states:
- NEW: Freshly scheduled, waiting for processing
- COMPLETED: Successfully processed by all handlers
- FAILED: Exhausted all retries, requires manual intervention
OutboxEventMulticaster
The OutboxEventMulticaster provides seamless integration with Spring's event system. It automatically intercepts and persists events annotated with @OutboxEvent directly to the outbox table, allowing you to use Spring's native ApplicationEventPublisher while getting outbox benefits.
How It Works
graph TB
A["publishEvent(event)"] --> B["OutboxEventMulticaster"]
B --> C{Check OutboxEvent<br/>Annotation}
C -->|Present| D["Serialize & Store"]
C -->|Not Present| E["Normal Event Flow"]
D --> F["Outbox Table"]
E --> G["Event Listeners"]
D -->|if publish-after-save=true| G
@OutboxEvent Annotation
Mark your events with @OutboxEvent to enable automatic outbox persistence:
@OutboxEvent(key = "#event.orderId") // SpEL expression for key resolution
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val amount: BigDecimal
)
@OutboxEvent(key = "#event.orderId") // SpEL expression for key resolution
public class OrderCreatedEvent {
private String orderId;
private String customerId;
private BigDecimal amount;
// constructor, getters, setters...
}
SpEL Key Resolution
The key parameter supports Spring Expression Language (SpEL) for dynamic key extraction:
| Expression | Description | Example |
|---|---|---|
#root.fieldName |
Access root object property | #root.orderId |
#event.fieldName |
Same as #root (alternative syntax) | #event.customerId |
#root.getId() |
Call method on root object | #root.getOrderId() |
#root.nested.field |
Access nested properties | #root.order.id |
#root.toString() |
Convert to string | #root.id.toString() |
SpEL Examples
@OutboxEvent(key = "#event.orderId")
data class OrderEvent(val orderId: String)
@OutboxEvent(key = "#event.getAggregateId()")
data class DomainEvent(val id: UUID) {
fun getAggregateId() = id.toString()
}
@OutboxEvent(key = "#event.order.id")
data class OrderConfirmedEvent(val order: Order)
Configuration
The multicaster can be configured to control event publishing behavior:
outbox:
multicaster:
enabled: true # Enable/disable automatic interception (default: true)
processing:
publish-after-save: true # Also forward to other listeners in same transaction (default: true)
| Configuration | Value | Effect |
|---|---|---|
multicaster.enabled |
true (default) |
@OutboxEvent annotations are intercepted and stored |
multicaster.enabled |
false |
OutboxEventMulticaster is disabled, events flow normally |
publish-after-save |
true (default) |
Events also published to other listeners in same transaction |
publish-after-save |
false |
Events only stored to outbox, not published to listeners |
Usage Example
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun createOrder(command: CreateOrderCommand) {
val order = Order.create(command)
orderRepository.save(order)
// Automatically saved to outbox + published to listeners
eventPublisher.publishEvent(
OrderCreatedEvent(order.id, order.customerId, order.amount)
)
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
// Automatically saved to outbox + published to listeners
eventPublisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getCustomerId(), order.getAmount())
);
}
}
@OutboxEvent with Context
When using @OutboxEvent with ApplicationEventPublisher, you can define context using SpEL expressions:
@OutboxEvent(
key = "#this.orderId",
context = [
OutboxContextEntry(key = "customerId", value = "#this.customerId"),
OutboxContextEntry(key = "region", value = "#this.region"),
OutboxContextEntry(key = "priority", value = "#this.priority")
]
)
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val region: String,
val priority: String
)
@OutboxEvent(
key = "#this.orderId",
context = {
@OutboxContextEntry(key = "customerId", value = "#this.customerId"),
@OutboxContextEntry(key = "region", value = "#this.region"),
@OutboxContextEntry(key = "priority", value = "#this.priority")
}
)
public class OrderCreatedEvent {
private String orderId;
private String customerId;
private String region;
private String priority;
// constructor, getters...
}
Context from @OutboxEvent annotations is merged with context from registered OutboxContextProvider beans.
Processing Chain
Internal Processing Pipeline
The library uses a Chain of Responsibility pattern to process outbox records through multiple stages. Each processor in the chain handles a specific concern and can delegate to the next processor when needed.
Chain Architecture
The processing chain consists of four processors, executed in this exact order:
flowchart LR
A((Start)) --> B[Invoke Handler]
B --> C{Success?}
C -- Yes --> Z[Mark COMPLETED]
C -- No --> D{Can Retry?}
D -- Yes --> E[Schedule Retry]
D -- No --> F{Fallback Exists?}
F -- Yes --> G[Invoke Fallback]
G --> H{Fallback Success?}
H -- Yes --> Z
H -- No --> Y[Mark FAILED]
F -- No --> Y
Y --> S((Stop))
Z --> S((Stop))
Processing Flow:
-
Primary Handler Processor - Invokes the registered handler for the payload type. On success, marks record as
COMPLETED. On exception, passes to Retry Processor. -
Retry Processor - Evaluates if the exception is retryable and if retry limit is not exceeded. Schedules next retry with calculated delay or passes to Fallback Processor.
-
Fallback Processor - Invokes registered fallback handler if available. On success, marks record as
COMPLETED. On failure or if no fallback exists, passes to Permanent Failure Processor. -
Permanent Failure Processor - Marks the record as permanently
FAILED. Final state - no further processing.
Processing Flow Examples
Scenario 1: Retries Exhausted with Successful Fallback
Order processing with 3 max retries and fallback handler:
sequenceDiagram
participant S as Scheduler
participant P as Primary Processor
participant R as Retry Processor
participant F as Fallback Processor
participant DB as Database
Note over S,DB: Attempts 1-3: Retries
loop 3 times
S->>P: Process Record
P->>P: SocketTimeoutException
P->>R: Handle Exception
R->>R: Can Retry? ✓
R->>DB: Schedule Retry (1s→2s→4s)
end
Note over S,DB: Attempt 4: Fallback
S->>P: Process Record
P->>P: SocketTimeoutException
P->>R: Handle Exception
R->>R: Retries Exhausted
R->>F: Invoke Fallback
F->>F: Publish to DLQ
F->>DB: Mark COMPLETED ✓
Scenario 2: Non-Retryable Exception
Order processing with non-retryable exception:
sequenceDiagram
participant S as Scheduler
participant P as Primary Processor
participant R as Retry Processor
participant F as Fallback Processor
participant DB as Database
S->>P: Process Record
P->>P: IllegalArgumentException
P->>R: Handle Exception
R->>R: Non-Retryable Exception
R->>F: Invoke Fallback
F->>F: Publish to DLQ
F->>DB: Mark COMPLETED ✓
Scenario 3: No Fallback Handler
Order processing without fallback handler:
sequenceDiagram
participant S as Scheduler
participant P as Primary Processor
participant R as Retry Processor
participant F as Fallback Processor
participant PF as Permanent Failure Processor
participant DB as Database
Note over S,DB: Attempts 1-3: Retries
loop 3 times
S->>P: Process Record
P->>P: SocketTimeoutException
P->>R: Handle Exception
R->>R: Can Retry? ✓
R->>DB: Schedule Retry (1s→2s→4s)
end
Note over S,DB: Attempt 4: Permanent Failure
S->>P: Process Record
P->>P: SocketTimeoutException
P->>R: Handle Exception
R->>R: Retries Exhausted
R->>F: Check Fallback
F->>F: No Fallback Found
F->>PF: Handle Permanent Failure
PF->>DB: Mark FAILED ✗
Handlers
Handler Types & Interfaces
The library provides two complementary handler interfaces for different use cases:
Typed Handlers (Type-Safe)
Process specific payload types with full type safety. Recommended for most cases as handlers are type-checked at compile time.
@Component
class OrderCreatedHandler : OutboxTypedHandler<OrderCreatedRecord> {
override fun handle(payload: OrderCreatedRecord) {
println("Processing order: ${payload.orderId}")
eventPublisher.publish(payload)
}
}
@Component
public class OrderCreatedHandler implements OutboxTypedHandler<OrderCreatedRecord> {
@Override
public void handle(OrderCreatedRecord payload) {
System.out.println("Processing order: " + payload.getOrderId());
eventPublisher.publish(payload);
}
}
Generic Handlers (Multi-Type)
Process any payload type with pattern matching. Use for catch-all or multi-type routing logic.
@Component
class UniversalHandler : OutboxHandler {
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}")
}
}
}
@Component
public class UniversalHandler implements OutboxHandler {
@Override
public void handle(Object payload, OutboxRecordMetadata metadata) {
if (payload instanceof OrderCreatedRecord) {
handleOrder((OrderCreatedRecord) payload);
} else if (payload instanceof PaymentProcessedRecord) {
handlePayment((PaymentProcessedRecord) payload);
} else if (payload instanceof CreateCustomerCommand) {
createCustomer((CreateCustomerCommand) payload);
} else {
logger.warn("Unknown payload: {}", payload.getClass().getSimpleName());
}
}
}
Handler Invocation Order
When multiple handlers are registered:
- All matching typed handlers are invoked first (in registration order)
- 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
fun handleOrderCreated(payload: OrderCreatedRecord) {
// ...
}
@OutboxHandler
fun handlePaymentProcessed(payload: PaymentProcessedRecord) {
// ...
}
@OutboxHandler
fun handleAny(payload: Any, metadata: OutboxRecordMetadata) {
// Generic handler via annotation
}
}
@Component
public class MyHandlers {
@OutboxHandler
public void handleOrderCreated(OrderCreatedRecord payload) {
// ...
}
@OutboxHandler
public void handlePaymentProcessed(PaymentProcessedRecord payload) {
// ...
}
@OutboxHandler
public void handleAny(Object payload, OutboxRecordMetadata metadata) {
// Generic handler via annotation
}
}
Handler Signature Requirements
- Typed handlers can accept 1 or 2 parameters:
fun handle(payload: T)- Payload onlyfun 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
Fallback Handlers
Graceful Degradation (Since 1.0.0)
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)
Interface-Based Fallback Handlers
Implement OutboxFallbackHandler interface for type-safe fallback handling:
@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}"
)
}
}
@Component
public class OrderFallbackHandler implements OutboxFallbackHandler<OrderEvent> {
@Override
public void handle(OrderEvent payload, OutboxFailureContext context) {
logger.error(
"Order {} failed permanently after {} attempts",
payload.getOrderId(),
context.getFailureCount(),
context.getLastException()
);
// Publish to dead letter queue
deadLetterQueue.publish(
payload,
"Max retries exceeded",
context.getLastException(),
context.getContext().get("traceId")
);
// Send alert
alertService.sendAlert(
"Order processing failed permanently: " + payload.getOrderId()
);
}
}
Annotation-Based Fallback Handlers
Use @OutboxFallbackHandler annotation for method-level fallback registration:
@Component
class OrderHandlers {
@OutboxHandler
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)
}
}
@Component
public class OrderHandlers {
@OutboxHandler
public void handleOrder(OrderEvent payload) {
emailService.send(payload.getEmail()); // May fail
}
@OutboxFallbackHandler
public void handleOrderFailure(OrderEvent payload, OutboxFailureContext context) {
logger.error(
"Order {} failed after {} attempts",
payload.getOrderId(),
context.getFailureCount()
);
deadLetterQueue.publish(payload);
}
}
OutboxFailureContext
The OutboxFailureContext provides comprehensive failure information:
interface OutboxFailureContext {
val handlerId: String // Handler that failed
val key: String // Record key
val createdAt: Instant // When record was created
val failureCount: Int // Number of failed attempts
val lastException: Throwable? // Last exception thrown
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
fun handleOrderCreated(payload: OrderEvent) {
orderService.create(payload)
}
@OutboxHandler
fun handleOrderUpdated(payload: OrderEvent) {
orderService.update(payload)
}
@OutboxFallbackHandler
fun handleOrderFailure(payload: OrderEvent, context: OutboxFailureContext) {
// Handles failures from both handleOrderCreated and handleOrderUpdated
deadLetterQueue.publish(payload)
}
}
@Component
public class OrderHandlers {
// Both handlers share the same fallback
@OutboxHandler
public void handleOrderCreated(OrderEvent payload) {
orderService.create(payload);
}
@OutboxHandler
public void handleOrderUpdated(OrderEvent payload) {
orderService.update(payload);
}
@OutboxFallbackHandler
public void handleOrderFailure(OrderEvent payload, OutboxFailureContext context) {
// 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)
Use Cases
Common use cases for fallback handlers:
- Dead Letter Queue: Publish failed records to a DLQ for later analysis
- Alert & Monitoring: Send alerts when records fail permanently
- Compensating Actions: Execute compensating transactions (e.g., refund, rollback)
- Alternative Processing: Route to alternative processing logic
- Audit Logging: Log failure details for compliance and debugging
Context Propagation
Cross-Boundary Context (Since 1.0.0)
Context propagation preserves important metadata (trace IDs, tenant info, correlation IDs, user context) across the async boundary between scheduling and processing.
How It Works
Context is captured when records are scheduled and restored when handlers are invoked:
sequenceDiagram
participant App as Application
participant CP as OutboxContextProvider
participant DB as Outbox Table
participant Scheduler as Scheduler
participant Handler as Handler
App->>CP: Request current context
CP-->>App: {traceId, spanId, tenantId}
App->>DB: Save record + context
Note over DB: Context stored as JSON
Scheduler->>DB: Poll records
DB-->>Scheduler: Record + context
Scheduler->>Handler: Invoke with metadata
Note over Handler: Context available via<br/>metadata.context
OutboxContextProvider Interface
Implement OutboxContextProvider to capture context during record scheduling:
@Component
class TracingContextProvider(
private val tracer: Tracer
) : OutboxContextProvider {
override fun provide(): Map<String, String> {
val currentSpan = tracer.currentSpan() ?: return emptyMap()
return mapOf(
"traceId" to currentSpan.context().traceId(),
"spanId" to currentSpan.context().spanId()
)
}
}
@Component
class TenantContextProvider : OutboxContextProvider {
override fun provide(): Map<String, String> {
val tenantId = SecurityContextHolder.getContext()
.authentication
?.principal
?.let { (it as? TenantAware)?.tenantId }
?: return emptyMap()
return mapOf("tenantId" to tenantId)
}
}
@Component
public class TracingContextProvider implements OutboxContextProvider {
private final Tracer tracer;
public TracingContextProvider(Tracer tracer) {
this.tracer = tracer;
}
@Override
public Map<String, String> provide() {
Span currentSpan = tracer.currentSpan();
if (currentSpan == null) {
return Collections.emptyMap();
}
return Map.of(
"traceId", currentSpan.context().traceId(),
"spanId", currentSpan.context().spanId()
);
}
}
@Component
public class TenantContextProvider implements OutboxContextProvider {
@Override
public Map<String, String> provide() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof TenantAware)) {
return Collections.emptyMap();
}
String tenantId = ((TenantAware) auth.getPrincipal()).getTenantId();
return Map.of("tenantId", tenantId);
}
}
Multiple Providers:
You can register multiple OutboxContextProvider beans. The library automatically merges all context maps. If keys collide, the last provider wins.
Accessing Context in Handlers
Context is available via metadata.context in primary handlers and failureContext.context in fallback handlers:
@Component
class OrderHandler {
@OutboxHandler
fun handle(payload: OrderEvent, metadata: OutboxRecordMetadata) {
// Access context via metadata.context
val traceId = metadata.context["traceId"]
val spanId = metadata.context["spanId"]
val tenantId = metadata.context["tenantId"]
// Restore tracing context for downstream calls
tracer.withSpan(traceId, spanId) {
logger.info("Processing order ${payload.orderId} [trace: $traceId]")
orderService.process(payload)
}
}
@OutboxFallbackHandler
fun handleFailure(payload: OrderEvent, context: OutboxFailureContext) {
// Access context via failureContext.context
val traceId = context.context["traceId"]
logger.error(
"Order ${payload.orderId} failed permanently [trace: $traceId]",
context.lastException
)
deadLetterQueue.publish(
payload = payload,
context = mapOf("traceId" to traceId)
)
}
}
@Component
public class OrderHandler {
@OutboxHandler
public void handle(OrderEvent payload, OutboxRecordMetadata metadata) {
// Access context via metadata.context
String traceId = metadata.getContext().get("traceId");
String spanId = metadata.getContext().get("spanId");
String tenantId = metadata.getContext().get("tenantId");
// Restore tracing context for downstream calls
tracer.withSpan(traceId, spanId, () -> {
logger.info("Processing order {} [trace: {}]", payload.getOrderId(), traceId);
orderService.process(payload);
});
}
@OutboxFallbackHandler
public void handleFailure(OrderEvent payload, OutboxFailureContext context) {
// Access context via failureContext.context
String traceId = context.getContext().get("traceId");
logger.error(
"Order {} failed permanently [trace: {}]",
payload.getOrderId(),
traceId,
context.getLastException()
);
deadLetterQueue.publish(payload, Map.of("traceId", traceId));
}
}
Manual Context Override
You can also pass custom context directly when scheduling records:
@Service
class OrderService(private val outbox: Outbox) {
@Transactional
fun createOrder(command: CreateOrderCommand) {
val order = Order.create(command)
orderRepository.save(order)
// Override or extend context
outbox.schedule(
payload = OrderCreatedEvent(order.id),
key = "order-${order.id}",
context = mapOf(
"correlationId" to command.correlationId,
"userId" to command.userId,
"priority" to "high"
)
)
}
}
@Service
public class OrderService {
private final Outbox outbox;
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
// Override or extend context
outbox.schedule(
new OrderCreatedEvent(order.getId()),
"order-" + order.getId(),
Map.of(
"correlationId", command.getCorrelationId(),
"userId", command.getUserId(),
"priority", "high"
)
);
}
}
Context Merging:
When you provide manual context, it's merged with context from registered OutboxContextProvider beans. Manual context takes precedence for duplicate keys.
Use Cases
Common use cases for context propagation:
- Distributed Tracing: Preserve trace and span IDs across async boundaries
- Multi-Tenancy: Maintain tenant context for data isolation
- Correlation IDs: Track requests across service boundaries
- User Context: Preserve user identity for audit logging
- Feature Flags: Propagate feature flag states for consistent behavior
- Request Metadata: Pass request IDs, client info, API versions
Payload Serialization
Records are serialized before storage and deserialized during processing. The library provides flexible serialization through the OutboxPayloadSerializer interface.
Jackson Module (Default)
The namastack-outbox-jackson module provides default JSON serialization using Jackson 3.x:
dependencies:
implementation("io.namastack:namastack-outbox-starter-jpa") # Includes Jackson by default
Supported Features:
- JSON serialization/deserialization
- Custom Jackson modules and mixins
- Standard Jackson configuration via spring.jackson.* properties
Example Jackson Configuration:
spring:
jackson:
default-property-inclusion: NON_NULL
serialization:
write-dates-as-timestamps: false
indent-output: true
deserialization:
fail-on-unknown-properties: false
OutboxPayloadSerializer Interface
Implement custom serializers for alternative formats (Protobuf, Avro, XML, etc.):
interface OutboxPayloadSerializer {
fun serialize(payload: Any): String
fun <T> deserialize(data: String, targetType: Class<T>): T
}
public interface OutboxPayloadSerializer {
String serialize(Object payload);
<T> T deserialize(String data, Class<T> targetType);
}
Custom Serializer Implementation
@Configuration
class OutboxSerializationConfig {
@Bean
fun protobufSerializer(): OutboxPayloadSerializer {
return object : OutboxPayloadSerializer {
override fun serialize(payload: Any): String {
// Implement Protobuf serialization
require(payload is Message) { "Payload must be Protobuf Message" }
return Base64.encoder.encodeToString(payload.toByteArray())
}
override fun <T> deserialize(data: String, targetType: Class<T>): T {
// Implement Protobuf deserialization
val bytes = Base64.decoder.decode(data)
val method = targetType.getMethod("parseFrom", ByteArray::class.java)
@Suppress("UNCHECKED_CAST")
return method.invoke(null, bytes) as T
}
}
}
}
@Configuration
public class OutboxSerializationConfig {
@Bean
public OutboxPayloadSerializer protobufSerializer() {
return new OutboxPayloadSerializer() {
@Override
public String serialize(Object payload) {
if (!(payload instanceof Message)) {
throw new IllegalArgumentException("Payload must be Protobuf Message");
}
byte[] bytes = ((Message) payload).toByteArray();
return Base64.getEncoder().encodeToString(bytes);
}
@Override
public <T> T deserialize(String data, Class<T> targetType) {
try {
byte[] bytes = Base64.getDecoder().decode(data);
java.lang.reflect.Method method = targetType.getMethod("parseFrom", byte[].class);
return (T) method.invoke(null, bytes);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
};
}
}
Important: When you provide a custom serializer as a Spring bean, it automatically replaces the default Jackson serializer.
Retry Mechanisms
The library provides sophisticated retry strategies to handle transient failures gracefully. You can configure a default retry policy for all handlers and optionally override it per handler.
Default Retry Policy
The default retry policy applies to all handlers unless overridden. Configure it via application.yml or by providing a custom OutboxRetryPolicy bean.
Built-in Retry Policies
Fixed Delay
Retry with a constant delay between attempts:
outbox:
retry:
policy: "fixed"
max-retries: 5
fixed:
delay: 5000 # 5 seconds between retries
Use Case: Simple scenarios with consistent retry intervals
Example Retry Schedule: 0s → 5s → 5s → 5s → 5s → 5s → Failed
Exponential Backoff
Retry with exponentially increasing delays:
outbox:
retry:
policy: "exponential"
max-retries: 3
exponential:
initial-delay: 1000 # Start with 1 second
max-delay: 60000 # Cap at 1 minute
multiplier: 2.0 # Double each time
Use Case: Handles transient failures gracefully without overwhelming downstream services
Retry Schedule: 0s → 1s → 2s → 4s → 8s → 16s → 32s (capped at 60s)
Jittered Retry
Add random jitter to prevent thundering herd problems:
outbox:
retry:
policy: "jittered"
max-retries: 7
exponential:
initial-delay: 2000
max-delay: 60000
multiplier: 2.0
jittered:
base-policy: exponential
jitter: 1000 # Add 0-1000ms random delay
Benefits: Prevents coordinated retry storms when multiple instances retry simultaneously
Exception Filtering
Exception-Based Retry Control (Since 1.0.0)
Control which exceptions trigger retries using include/exclude patterns.
Configure which exceptions should trigger retries:
outbox:
retry:
policy: exponential
max-retries: 3
exponential:
initial-delay: 1000
max-delay: 60000
multiplier: 2.0
# Only retry these exceptions
include-exceptions:
- java.net.SocketTimeoutException
- org.springframework.web.client.ResourceAccessException
- java.io.IOException
# Never retry these exceptions
exclude-exceptions:
- java.lang.IllegalArgumentException
- javax.validation.ValidationException
- com.example.BusinessException
Rules:
- If
include-exceptionsis set, only listed exceptions are retryable - If
exclude-exceptionsis set, listed exceptions are never retried - If both are set,
include-exceptionstakes precedence - If neither is set, all exceptions are retryable (default behavior)
Use Cases:
- include-exceptions: Whitelist transient errors (network, timeout, rate limiting)
- exclude-exceptions: Blacklist permanent errors (validation, business rules, auth failures)
Custom Default Retry Policy
Implement the OutboxRetryPolicy interface and register it as a bean named outboxRetryPolicy:
@Configuration
class OutboxConfig {
@Bean("outboxRetryPolicy")
fun customRetryPolicy(): OutboxRetryPolicy {
return object : OutboxRetryPolicy {
override fun shouldRetry(exception: Throwable): Boolean {
// Don't retry validation errors
if (exception is IllegalArgumentException) return false
// Don't retry permanent failures
if (exception is PaymentDeclinedException) return false
// Retry transient failures
return exception is TimeoutException ||
exception is IOException ||
exception.cause is TimeoutException
}
override fun nextDelay(failureCount: Int): Duration {
// Exponential backoff: 1s → 2s → 4s → 8s (capped at 60s)
val delayMillis = 1000L * (1L shl failureCount)
return Duration.ofMillis(minOf(delayMillis, 60000L))
}
override fun maxRetries(): Int = 5
}
}
}
@Configuration
public class OutboxConfig {
@Bean("outboxRetryPolicy")
public OutboxRetryPolicy customRetryPolicy() {
return new OutboxRetryPolicy() {
@Override
public boolean shouldRetry(Throwable exception) {
// Don't retry validation errors
if (exception instanceof IllegalArgumentException) return false;
// Don't retry permanent failures
if (exception instanceof PaymentDeclinedException) return false;
// Retry transient failures
return exception instanceof TimeoutException ||
exception instanceof IOException ||
(exception.getCause() instanceof TimeoutException);
}
@Override
public Duration nextDelay(int failureCount) {
// Exponential backoff: 1s → 2s → 4s → 8s (capped at 60s)
long delayMillis = 1000L * (1L << failureCount);
return Duration.ofMillis(Math.min(delayMillis, 60000L));
}
@Override
public int maxRetries() {
return 5;
}
};
}
}
Key Methods:
shouldRetry(exception: Throwable): Boolean- Decide if this error should be retriednextDelay(failureCount: Int): Duration- Calculate delay before next retrymaxRetries(): Int- Maximum number of retry attempts
Important: The bean must be named outboxRetryPolicy to override the default policy configured in application.yml.
Handler-Specific Retry Policies
Per-Handler Retry Configuration (Since 1.0.0)
Override the default retry policy for specific handlers using @OutboxRetryable annotation or by implementing the OutboxRetryAware interface.
You can configure retry behavior per handler, allowing different handlers to have different retry strategies.
Interface-Based Approach
Implement the OutboxRetryAware interface to specify a retry policy programmatically:
@Component
class PaymentHandler(
private val aggressiveRetryPolicy: AggressiveRetryPolicy
) : OutboxTypedHandler<PaymentEvent>, OutboxRetryAware {
override fun handle(payload: PaymentEvent, metadata: OutboxRecordMetadata) {
paymentGateway.process(payload)
}
override fun getRetryPolicy(): OutboxRetryPolicy = aggressiveRetryPolicy
}
@Component
class NotificationHandler(
private val conservativeRetryPolicy: ConservativeRetryPolicy
) : OutboxTypedHandler<NotificationEvent>, OutboxRetryAware {
override fun handle(payload: NotificationEvent, metadata: OutboxRecordMetadata) {
emailService.send(payload)
}
override fun getRetryPolicy(): OutboxRetryPolicy = conservativeRetryPolicy
}
@Component
class AggressiveRetryPolicy : OutboxRetryPolicy {
override fun shouldRetry(exception: Throwable) = true
override fun nextDelay(failureCount: Int) = Duration.ofMillis(500)
override fun maxRetries() = 10
}
@Component
class ConservativeRetryPolicy : OutboxRetryPolicy {
override fun shouldRetry(exception: Throwable) =
exception !is IllegalArgumentException
override fun nextDelay(failureCount: Int) = Duration.ofSeconds(10)
override fun maxRetries() = 2
}
@Component
public class PaymentHandler implements OutboxTypedHandler<PaymentEvent>, OutboxRetryAware {
private final AggressiveRetryPolicy aggressiveRetryPolicy;
public PaymentHandler(AggressiveRetryPolicy aggressiveRetryPolicy) {
this.aggressiveRetryPolicy = aggressiveRetryPolicy;
}
@Override
public void handle(PaymentEvent payload, OutboxRecordMetadata metadata) {
paymentGateway.process(payload);
}
@Override
public OutboxRetryPolicy getRetryPolicy() {
return aggressiveRetryPolicy;
}
}
@Component
public class NotificationHandler implements OutboxTypedHandler<NotificationEvent>, OutboxRetryAware {
private final ConservativeRetryPolicy conservativeRetryPolicy;
public NotificationHandler(ConservativeRetryPolicy conservativeRetryPolicy) {
this.conservativeRetryPolicy = conservativeRetryPolicy;
}
@Override
public void handle(NotificationEvent payload, OutboxRecordMetadata metadata) {
emailService.send(payload);
}
@Override
public OutboxRetryPolicy getRetryPolicy() {
return conservativeRetryPolicy;
}
}
@Component
public class AggressiveRetryPolicy implements OutboxRetryPolicy {
@Override
public boolean shouldRetry(Throwable exception) {
return true;
}
@Override
public Duration nextDelay(int failureCount) {
return Duration.ofMillis(500);
}
@Override
public int maxRetries() {
return 10;
}
}
@Component
public class ConservativeRetryPolicy implements OutboxRetryPolicy {
@Override
public boolean shouldRetry(Throwable exception) {
return !(exception instanceof IllegalArgumentException);
}
@Override
public Duration nextDelay(int failureCount) {
return Duration.ofSeconds(10);
}
@Override
public int maxRetries() {
return 2;
}
}
Annotation-Based Approach
Use the @OutboxRetryable annotation for method-level retry policy configuration:
@Component
class PaymentHandler {
// Critical handler - aggressive retries
@OutboxHandler
@OutboxRetryable(AggressiveRetryPolicy::class)
fun handlePayment(payload: PaymentEvent) {
paymentGateway.process(payload)
}
// Less critical handler - conservative retries
@OutboxHandler
@OutboxRetryable(ConservativeRetryPolicy::class)
fun handleNotification(payload: NotificationEvent) {
emailService.send(payload)
}
// Uses default retry policy
@OutboxHandler
fun handleAudit(payload: AuditEvent) {
auditService.log(payload)
}
}
@Component
class AggressiveRetryPolicy : OutboxRetryPolicy {
override fun shouldRetry(exception: Throwable) = true
override fun nextDelay(failureCount: Int) = Duration.ofMillis(500)
override fun maxRetries() = 10
}
@Component
class ConservativeRetryPolicy : OutboxRetryPolicy {
override fun shouldRetry(exception: Throwable) =
exception !is IllegalArgumentException
override fun nextDelay(failureCount: Int) = Duration.ofSeconds(10)
override fun maxRetries() = 2
}
@Component
public class PaymentHandler {
// Critical handler - aggressive retries
@OutboxHandler
@OutboxRetryable(AggressiveRetryPolicy.class)
public void handlePayment(PaymentEvent payload) {
paymentGateway.process(payload);
}
// Less critical handler - conservative retries
@OutboxHandler
@OutboxRetryable(ConservativeRetryPolicy.class)
public void handleNotification(NotificationEvent payload) {
emailService.send(payload);
}
// Uses default retry policy
@OutboxHandler
public void handleAudit(AuditEvent payload) {
auditService.log(payload);
}
}
@Component
public class AggressiveRetryPolicy implements OutboxRetryPolicy {
@Override
public boolean shouldRetry(Throwable exception) {
return true;
}
@Override
public Duration nextDelay(int failureCount) {
return Duration.ofMillis(500);
}
@Override
public int maxRetries() {
return 10;
}
}
@Component
public class ConservativeRetryPolicy implements OutboxRetryPolicy {
@Override
public boolean shouldRetry(Throwable exception) {
return !(exception instanceof IllegalArgumentException);
}
@Override
public Duration nextDelay(int failureCount) {
return Duration.ofSeconds(10);
}
@Override
public int maxRetries() {
return 2;
}
}
Policy Resolution Order:
- Handler-specific policy via interface (
OutboxRetryAware.getRetryPolicy()) - highest priority - Handler-specific policy via annotation (
@OutboxRetryable) - Global custom policy (bean named
outboxRetryPolicy) - Default policy (from
application.yml)
Interface vs Annotation
- Interface (
OutboxRetryAware): Best when handler class is dedicated to single payload type, and you want type safety - Annotation (
@OutboxRetryable): Best for method-level handlers or multiple handlers in one class
Persistence Modules
Namastack Outbox provides two persistence modules to choose from based on your needs.
JPA Module
The JPA module uses Hibernate/JPA for database operations. Best for projects already using Spring Data JPA.
dependencies {
implementation("io.namastack:namastack-outbox-starter-jpa:1.0.0-RC2")
}
<dependency>
<groupId>io.namastack</groupId>
<artifactId>namastack-outbox-starter-jpa</artifactId>
<version>1.0.0-RC2</version>
</dependency>
Schema Management
The JPA module does not support automatic schema creation. You must manage schemas using:
- Flyway/Liquibase (recommended for production) - Use the SQL schema files
- Hibernate DDL Auto (
ddl-auto: create) for development
JDBC Module
New in 1.0.0-RC2
The JDBC module provides a lightweight alternative without JPA/Hibernate dependency.
The JDBC module uses Spring's JdbcClient for database operations. Best for projects that don't use JPA or want lower overhead.
dependencies {
implementation("io.namastack:namastack-outbox-starter-jdbc:1.0.0-RC2")
}
<dependency>
<groupId>io.namastack</groupId>
<artifactId>namastack-outbox-starter-jdbc</artifactId>
<version>1.0.0-RC2</version>
</dependency>
Benefits:
- No Hibernate/JPA dependency required
- Built-in automatic schema initialization
- Support for custom table prefixes and schema names
- Lower memory footprint
Automatic Schema Creation (JDBC Only)
The JDBC module can automatically create outbox tables on startup:
outbox:
jdbc:
schema-initialization:
enabled: true # Auto-create tables on startup
Database Detection
The JDBC module automatically detects your database type and uses the appropriate schema. Supported databases: PostgreSQL, MySQL, MariaDB, H2, SQL Server.
Custom Table Prefix and Schema Name
New in 1.0.0-RC2
Configure custom table prefixes and schema names for the JDBC module.
The JDBC module supports custom table naming for multi-tenant deployments or naming conventions:
outbox:
jdbc:
table-prefix: "myapp_" # Results in: myapp_outbox_record, myapp_outbox_instance, etc.
schema-name: "outbox_schema" # Results in: outbox_schema.myapp_outbox_record
Examples:
| Configuration | Resulting Table Name |
|---|---|
| Default | outbox_record |
table-prefix: "app1_" |
app1_outbox_record |
schema-name: "myschema" |
myschema.outbox_record |
| Both | myschema.app1_outbox_record |
Schema Initialization Limitation
When using custom table prefix or schema name, you must create tables manually. Schema initialization cannot be used with custom naming:
outbox:
jdbc:
table-prefix: "myapp_"
schema-name: "custom_schema"
schema-initialization:
enabled: false # Must be false when using custom naming
Manual Schema Creation:
Use the SQL schema files as templates and adjust table names: 👉 Schema Files on GitHub
Virtual Threads Support
New in 1.0.0-RC2
Full support for Java 21 virtual threads for improved scalability.
When virtual threads are enabled in Spring Boot, Namastack Outbox automatically uses virtual threads for outbox processing, providing better scalability for I/O-bound workloads.
Enabling Virtual Threads
Enable virtual threads in your Spring Boot application:
spring:
threads:
virtual:
enabled: true
Benefits:
- Higher Throughput: Handle more concurrent outbox records with fewer resources
- Lower Memory: Virtual threads have minimal memory overhead
- Better I/O Handling: Ideal for handlers that make external API calls
- Automatic: No code changes required - the library detects and uses virtual threads automatically
Configuration
When virtual threads are enabled, use the concurrency limit instead of pool sizes:
outbox:
processing:
executor-concurrency-limit: -1 # -1 for unlimited, or set a specific limit
Platform Threads
When virtual threads are disabled (default), the library uses traditional thread pools:
outbox:
processing:
executor-core-pool-size: 4
executor-max-pool-size: 8
Database Support
Supported Databases
Any JPA/JDBC-compatible database is supported. Automatic schema creation (JDBC module only) is available for:
- ✅ H2 (development)
- ✅ MySQL / MariaDB
- ✅ PostgreSQL
- ✅ SQL Server
Schema Management
JDBC Module
The JDBC module can automatically create its schema on startup:
outbox:
jdbc:
schema-initialization:
enabled: true # Auto-create tables on startup (default: false)
JPA Module
The JPA module does not support automatic schema creation. Use one of these approaches:
Option 1: Hibernate DDL Auto (Development)
spring:
jpa:
hibernate:
ddl-auto: create # or create-drop, update
Option 2: Flyway/Liquibase (Production)
Create tables manually using migration scripts. Database-specific schemas are available: 👉 Schema Files on GitHub
Then configure Hibernate to validate the schema:
spring:
jpa:
hibernate:
ddl-auto: validate
Monitoring & Observability
Metrics Module
The namastack-outbox-metrics module provides automatic integration with Spring Boot Actuator and Micrometer:
dependencies {
implementation("io.namastack:namastack-outbox-starter-jpa")
implementation("io.namastack:namastack-outbox-metrics")
// For Prometheus endpoint (optional)
implementation("io.micrometer:micrometer-registry-prometheus")
}
<dependency>
<groupId>io.namastack</groupId>
<artifactId>namastack-outbox-metrics</artifactId>
<version>${namastack-outbox.version}</version>
</dependency>
Built-in Metrics
| Metric | Description | Tags |
|---|---|---|
outbox.records.count |
Number of outbox records | status=new\|failed\|completed |
outbox.partitions.assigned.count |
Partitions assigned to this instance | - |
outbox.partitions.pending.records.total |
Total pending records across partitions | - |
outbox.partitions.pending.records.max |
Maximum pending records in any partition | - |
outbox.cluster.instances.total |
Total active instances in cluster | - |
Endpoints:
/actuator/metrics/outbox.records.count/actuator/metrics/outbox.partitions.assigned.count/actuator/prometheus(if Prometheus enabled)
Programmatic Monitoring
@Service
class OutboxMonitoringService(
private val outboxRepository: OutboxRecordRepository,
private val partitionMetricsProvider: OutboxPartitionMetricsProvider
) {
fun getPendingRecordCount(): Long =
outboxRepository.countByStatus(OutboxRecordStatus.NEW)
fun getFailedRecordCount(): Long =
outboxRepository.countByStatus(OutboxRecordStatus.FAILED)
fun getPartitionStats(): PartitionProcessingStats =
partitionMetricsProvider.getProcessingStats()
fun getClusterStats(): PartitionStats =
partitionMetricsProvider.getPartitionStats()
}
@Service
public class OutboxMonitoringService {
private final OutboxRecordRepository outboxRepository;
private final OutboxPartitionMetricsProvider partitionMetricsProvider;
public OutboxMonitoringService(OutboxRecordRepository outboxRepository,
OutboxPartitionMetricsProvider partitionMetricsProvider) {
this.outboxRepository = outboxRepository;
this.partitionMetricsProvider = partitionMetricsProvider;
}
public long getPendingRecordCount() {
return outboxRepository.countByStatus(OutboxRecordStatus.NEW);
}
public long getFailedRecordCount() {
return outboxRepository.countByStatus(OutboxRecordStatus.FAILED);
}
public PartitionProcessingStats getPartitionStats() {
return partitionMetricsProvider.getProcessingStats();
}
public PartitionStats getClusterStats() {
return partitionMetricsProvider.getPartitionStats();
}
}
Configuration Reference
Complete reference of all configuration options:
outbox:
# Enable/Disable Outbox (Since 1.0.0-RC2)
enabled: true # Enable outbox functionality (default: true)
# Polling Configuration
poll-interval: 2000 # Milliseconds between polling cycles (default: 2000)
rebalance-interval: 10000 # Milliseconds between rebalance checks (default: 10000)
batch-size: 10 # Records per poll cycle (default: 10)
# Processing Configuration
processing:
stop-on-first-failure: true # Stop processing on first failure (default: true)
publish-after-save: true # Publish events to listeners after saving (default: true)
delete-completed-records: false # Delete records after completion (default: false)
executor-core-pool-size: 4 # Core threads for processing (default: 4, platform threads)
executor-max-pool-size: 8 # Maximum threads for processing (default: 8, platform threads)
executor-concurrency-limit: -1 # Concurrency limit for virtual threads (default: -1 unlimited)
# Event Multicaster Configuration
multicaster:
enabled: true # Enable @OutboxEvent interception (default: true)
# Instance Coordination Configuration
instance:
graceful-shutdown-timeout-seconds: 15 # Graceful shutdown timeout (default: 15)
stale-instance-timeout-seconds: 30 # When to mark instance as dead (default: 30)
heartbeat-interval-seconds: 5 # Heartbeat interval (default: 5)
# JDBC Module Configuration (Since 1.0.0-RC2)
jdbc:
table-prefix: "" # Prefix for table names (default: empty)
schema-name: null # Database schema name (default: null, uses default schema)
schema-initialization:
enabled: false # Auto-create tables on startup (default: false)
# Retry Configuration
retry:
policy: exponential # Retry policy: fixed|exponential|jittered (default: exponential)
max-retries: 3 # Maximum retry attempts (default: 3)
# Exception Filtering (Since 1.0.0)
include-exceptions: # Only retry these exceptions (optional)
- java.net.SocketTimeoutException
- org.springframework.web.client.ResourceAccessException
exclude-exceptions: # Never retry these exceptions (optional)
- java.lang.IllegalArgumentException
- javax.validation.ValidationException
# Fixed Delay Policy
fixed:
delay: 5000 # Delay in milliseconds (default: 5000)
# Exponential Backoff Policy
exponential:
initial-delay: 1000 # Initial delay in milliseconds (default: 1000)
max-delay: 60000 # Maximum delay cap in milliseconds (default: 60000)
multiplier: 2.0 # Backoff multiplier (default: 2.0)
# Jittered Retry Policy
jittered:
base-policy: exponential # Base policy for jitter: fixed|exponential (default: exponential)
jitter: 500 # Max random jitter in milliseconds (default: 500)
Disabling Outbox
New in 1.0.0-RC2
Outbox is now auto-configured when the library is on the classpath. Use outbox.enabled=false to disable.
To completely disable outbox functionality:
outbox:
enabled: false
This prevents all outbox beans from being created, useful for:
- Running tests without outbox processing
- Temporarily disabling outbox in specific environments
- Conditional feature flags
Reliability Guarantees
What Namastack Outbox for Spring Boot Guarantees
- At-least-once delivery: Records will be processed at least once
- Ordering per key: Records with the same key are processed in order
- Failure recovery: System failures don't result in lost records
- Horizontal scalability: Multiple instances process different partitions
- Consistency: Database transactions ensure data integrity
- Automatic retry: Failed records are automatically retried
- Automatic rebalancing: Partitions redistribute on topology changes
- Linear scaling: Performance scales with instance count
What Namastack Outbox for Spring Boot Does NOT Guarantee
- Exactly-once delivery: Records may be processed multiple times (handlers should be idempotent)
- Global ordering: No ordering guarantee across different keys
- Real-time processing: Records are processed asynchronously with configurable delays
Next Steps
Ready to get started? Check out the Quick Start Guide to integrate Namastack Outbox for Spring Boot into your application.