https://github.com/leonlee/outbox
Outbox pattern in microservice.
https://github.com/leonlee/outbox
domain-events event-driven microservice outbox-pattern
Last synced: 12 days ago
JSON representation
Outbox pattern in microservice.
- Host: GitHub
- URL: https://github.com/leonlee/outbox
- Owner: leonlee
- License: mit
- Created: 2026-02-04T05:49:03.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-02-18T09:25:03.000Z (24 days ago)
- Last Synced: 2026-02-18T10:30:59.966Z (23 days ago)
- Topics: domain-events, event-driven, microservice, outbox-pattern
- Language: Java
- Homepage: https://leonlee.github.io/outbox/
- Size: 529 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Agents: AGENTS.md
Awesome Lists containing this project
README
[](https://github.com/leonlee/outbox/actions/workflows/ci.yml)
[](https://central.sonatype.com/namespace/io.github.leonlee)
[](LICENSE)
[](https://openjdk.org/projects/jdk/17/)
[](https://leonlee.github.io/outbox/)
# outbox-java
Minimal, Spring-free outbox framework with JDBC persistence, optional hot-path enqueue, and poller/CDC fallback.
## Installation
Artifacts are published to [Maven Central](https://central.sonatype.com/namespace/io.github.leonlee). Current release: *
*0.8.3**.
```xml
io.github.leonlee
outbox-core
0.8.3
io.github.leonlee
outbox-jdbc
0.8.3
io.github.leonlee
outbox-spring-boot-starter
0.8.3
io.github.leonlee
outbox-spring-adapter
0.8.3
io.github.leonlee
outbox-micrometer
0.8.3
```
## Modules
- `outbox-core`: core APIs, hooks, dispatcher, poller, and registries.
- `outbox-jdbc`: JDBC outbox store and transaction helpers.
- `outbox-spring-boot-starter`: Spring Boot auto-configuration with `@OutboxListener` annotation.
- `outbox-spring-adapter`: optional `TxContext` implementation for Spring (without Boot).
- `outbox-micrometer`: Micrometer metrics bridge for Prometheus/Grafana.
- `samples/outbox-demo`: minimal, non-Spring demo (H2).
- `samples/outbox-spring-demo`: Spring demo app (manual wiring).
- `samples/outbox-spring-boot-starter-demo`: Spring Boot Starter demo (zero-config auto-configuration).
- `samples/outbox-multi-ds-demo`: multi-datasource demo (two H2 databases).
## Architecture
```text
+----------------------------------------------------------------+
| Transaction Scope |
| |
| +------------------------+ write() +------------------+ |
| | Application / Domain | --------> | OutboxWriter | |
| +------------------------+ +--------+---------+ |
| | insert |
| v |
| +------------------+ |
| | OutboxStore | |
| +--------+---------+ |
| | persist |
| v |
| +------------------+ |
| | Outbox Table | |
| +------------------+ |
+------------+-------------------------------+--------------------+
| |
afterCommit hook poll pending
| |
v v
+-----------+ +--------------+
| Hot Queue | | OutboxPoller |
+-----+-----+ +------+-------+
| |
v enqueue cold |
+------------------+ |
| OutboxDispatcher | <-------------------+
+--------+---------+
|
v
+------------------+ handleEvent() +--------------+
| ListenerRegistry | ------------> | Listener A |
+--------+---------+ +--------------+
| +--------------+
+-----------------------> | Listener B |
+--------------+
OutboxDispatcher --- mark DONE/DEFERRED/RETRY/DEAD ---> OutboxStore
```
Hot path is optional: supply an `WriterHook` (for example, `DispatcherWriterHook`) or omit it for CDC-only consumption.
## How It Works
- **Write inside TX**: `OutboxWriter.write(...)` inserts into `outbox_event` using the same JDBC connection as your
business work.
- **After-commit hook**: `DispatcherWriterHook` enqueues the event into the hot queue after commit. Delayed events
(with `availableAt` or `deliverAfter`) are skipped — the poller delivers them at the scheduled time. If the queue is
full, the event stays in the DB.
- **Dispatch**: `OutboxDispatcher` drains hot/cold queues, routes to a single listener via `handleEvent()`, and updates
status based on `DispatchResult` (`DONE`, `DEFERRED`, `RETRY`, or `DEAD`).
- **Fallback**: `OutboxPoller` periodically scans/claims pending rows and enqueues them into the cold queue.
- **Delayed delivery**: Use `EventEnvelope.builder(...).deliverAfter(Duration.ofMinutes(30))` or
`.availableAt(futureInstant)` to schedule future delivery. The poller picks them up when `available_at` arrives.
- **At-least-once**: listeners may see duplicates. Always dedupe downstream by `eventId`.
- **Purge**: `OutboxPurgeScheduler` periodically deletes terminal events (DONE/DEAD) older than a configurable retention
period to prevent table bloat.
## Operating Modes
- **Hot + Poller (default)**: Use `DispatcherWriterHook` and start `OutboxPoller` for low latency plus durable fallback.
- **Poller-only**: Omit the hook, start `OutboxPoller`. Simpler wiring, higher latency.
- **CDC-only**: Omit both hook and poller. CDC reads the table; you manage dedupe and retention.
## Tuning and Backpressure
- `workerCount` controls max concurrent listener executions.
- `hotQueueCapacity`/`coldQueueCapacity` bound in-memory buffering.
- `skipRecent` avoids racing very recent inserts with the hot path.
- `claimLocking(ownerId, lockTimeout)` enables multi-instance poller locking.
- `maxAttempts` and `RetryPolicy` control retries and backoff. Handlers can override retry timing via
`DispatchResult.retryAfter()` or `RetryAfterException`.
## Event Retention / Purge
The outbox table is a transient buffer, not an outbox store. Terminal events (DONE, DEAD) should be purged after a
retention period to prevent table bloat:
```java
OutboxPurgeScheduler purgeScheduler = OutboxPurgeScheduler.builder()
.connectionProvider(connectionProvider)
.purger(new H2EventPurger()) // or MySqlEventPurger, PostgresEventPurger
.retention(Duration.ofDays(7)) // default: 7 days
.batchSize(500) // default: 500
.intervalSeconds(3600) // default: 1 hour
.build();
purgeScheduler.
start();
```
If clients need to archive events for audit, they should do so in their `EventListener` before events are purged.
## Failure and Delivery Semantics
- Delivery is **at-least-once**. Downstream must dedupe by `eventId`.
- Listener exceptions trigger `RETRY` with backoff; after `maxAttempts`, events go `DEAD`.
- Handlers can return `DispatchResult.retryAfter(delay)` for deferred re-delivery without counting against
`maxAttempts`, or throw `RetryAfterException` for handler-controlled retry timing that does count.
- If status updates fail, the event remains in DB and may be retried later.
## Composite Builder
The `Outbox` class provides scenario-specific builders that wire dispatcher, poller,
and writer into a single `AutoCloseable` unit:
```java
// Single-node: hot path + poller fallback
try(Outbox outbox = Outbox.singleNode()
.connectionProvider(connProvider).txContext(txContext)
.outboxStore(store).listenerRegistry(registry)
.build()){
OutboxWriter writer = outbox.writer();
// write events inside transactions...
}
// Multi-node: hot path + claim-based locking
try(
Outbox outbox = Outbox.multiNode()
.connectionProvider(connProvider).txContext(txContext)
.outboxStore(store).listenerRegistry(registry)
.claimLocking(Duration.ofMinutes(5))
.build()){...}
// Ordered delivery: poller-only, single worker, no retry
try(
Outbox outbox = Outbox.ordered()
.connectionProvider(connProvider).txContext(txContext)
.outboxStore(store).listenerRegistry(registry)
.intervalMs(1000)
.build()){...}
// Writer-only (CDC mode): no dispatcher or poller
try(
Outbox outbox = Outbox.writerOnly()
.txContext(txContext).outboxStore(store)
.build()){
OutboxWriter writer = outbox.writer();
// Events consumed externally (e.g. Debezium)
}
// Writer-only with age-based purge
try(
Outbox outbox = Outbox.writerOnly()
.txContext(txContext).outboxStore(store)
.connectionProvider(connProvider)
.purger(new H2AgeBasedPurger())
.purgeRetention(Duration.ofHours(24))
.build()){...}
```
For advanced wiring (custom `InFlightTracker`, per-component lifecycle, etc.), use
`OutboxDispatcher.builder()` and `OutboxPoller.builder()` directly.
## Ordered Delivery
For use cases requiring per-aggregate event ordering (e.g., `OrderCreated` before
`OrderShipped`), use `Outbox.ordered()` or the manual **poller-only** mode with a
single dispatch worker:
1. **Omit `WriterHook`** — do not configure `DispatcherWriterHook` (disables the hot path).
2. **Single-node `OutboxPoller`** — one poller instance reads events in DB insertion order.
3. **`workerCount(1)`** — one dispatch worker processes events sequentially.
The poller reads pending events `ORDER BY created_at`, and the single worker delivers
them to listeners in that order. Since DB I/O (polling) is the throughput bottleneck —
not in-memory dispatch — a single worker easily keeps up.
**Caveat — retries break ordering.** If event A fails and is retried with backoff,
event B (same aggregate, inserted later) can be polled and delivered while A waits.
To preserve strict ordering, set `maxAttempts(1)` so failed events go straight to
DEAD without retry. Use [Dead Event Management](TUTORIAL.md#12-dead-event-management)
to inspect and replay them manually.
Trade-off: higher latency (poll interval vs. immediate hot-path delivery). For
unordered events, use the default hot + poller mode for lowest latency.
## Spring Boot Starter
For Spring Boot applications, just add the starter dependency — no manual `@Configuration` needed:
```xml
io.github.leonlee
outbox-spring-boot-starter
0.8.3
```
Annotate your listeners with `@OutboxListener`:
```java
@Component
@OutboxListener(eventType = "OrderPlaced", aggregateType = "Order")
public class OrderListener implements EventListener {
public void onEvent(EventEnvelope event) {
// publish to MQ, update cache, etc.
}
}
```
Configure via `application.properties`:
```properties
outbox.mode=single-node
outbox.dispatcher.worker-count=4
outbox.poller.interval-ms=5000
```
The starter auto-detects your database (H2, MySQL, PostgreSQL), wires `SpringTxContext`, and creates an `Outbox`
composite with graceful shutdown. Micrometer metrics are enabled automatically when `spring-boot-starter-actuator` is on
the classpath.
See [TUTORIAL.md](TUTORIAL.md#5-spring-boot-starter) for the full guide with all configuration properties.
## Requirements
- Java 17 or later
## Documentation
- [**OBJECTIVE.md**](OBJECTIVE.md) -- Project goals, constraints, non-goals, and acceptance criteria
- [**SPEC.md**](SPEC.md) -- Technical specification: API contracts, data model, behavioral rules, configuration,
observability
- [**TUTORIAL.md**](TUTORIAL.md) -- Step-by-step guides with runnable code examples
## Notes
- Delivery is **at-least-once**. Use `eventId` for downstream dedupe.
- Hot queue drops (DispatcherWriterHook) do not throw; the poller (if enabled) or CDC should pick up the event.