{"id":43647165,"url":"https://github.com/leonlee/outbox","last_synced_at":"2026-03-02T07:08:42.907Z","repository":{"id":336587454,"uuid":"1149444854","full_name":"leonlee/outbox","owner":"leonlee","description":"Outbox pattern in microservice.","archived":false,"fork":false,"pushed_at":"2026-02-18T09:25:03.000Z","size":542,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-18T10:30:59.966Z","etag":null,"topics":["domain-events","event-driven","microservice","outbox-pattern"],"latest_commit_sha":null,"homepage":"https://leonlee.github.io/outbox/","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/leonlee.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-04T05:49:03.000Z","updated_at":"2026-02-18T09:25:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/leonlee/outbox","commit_stats":null,"previous_names":["leonlee/outbox"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/leonlee/outbox","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leonlee%2Foutbox","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leonlee%2Foutbox/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leonlee%2Foutbox/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leonlee%2Foutbox/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/leonlee","download_url":"https://codeload.github.com/leonlee/outbox/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/leonlee%2Foutbox/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29609524,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-19T06:47:36.664Z","status":"ssl_error","status_checked_at":"2026-02-19T06:45:47.551Z","response_time":117,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["domain-events","event-driven","microservice","outbox-pattern"],"created_at":"2026-02-04T19:02:13.262Z","updated_at":"2026-02-27T08:44:32.259Z","avatar_url":"https://github.com/leonlee.png","language":"Java","readme":"[![CI](https://github.com/leonlee/outbox/actions/workflows/ci.yml/badge.svg)](https://github.com/leonlee/outbox/actions/workflows/ci.yml)\n[![Maven Central](https://img.shields.io/maven-central/v/io.github.leonlee/outbox-core)](https://central.sonatype.com/namespace/io.github.leonlee)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n[![Java 17+](https://img.shields.io/badge/Java-17%2B-blue)](https://openjdk.org/projects/jdk/17/)\n[![Javadoc](https://img.shields.io/badge/Javadoc-latest-green)](https://leonlee.github.io/outbox/)\n\n# outbox-java\n\nMinimal, Spring-free outbox framework with JDBC persistence, optional hot-path enqueue, and poller/CDC fallback.\n\n## Installation\n\nArtifacts are published to [Maven Central](https://central.sonatype.com/namespace/io.github.leonlee). Current release: *\n*0.8.3**.\n\n```xml\n\u003c!-- Core APIs, dispatcher, poller, registries (required) --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n    \u003cartifactId\u003eoutbox-core\u003c/artifactId\u003e\n    \u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n\n        \u003c!-- JDBC outbox store and transaction helpers (required for persistence) --\u003e\n\u003cdependency\u003e\n\u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n\u003cartifactId\u003eoutbox-jdbc\u003c/artifactId\u003e\n\u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n\n        \u003c!-- Spring Boot Starter — auto-configures everything (recommended for Spring Boot) --\u003e\n\u003cdependency\u003e\n\u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n\u003cartifactId\u003eoutbox-spring-boot-starter\u003c/artifactId\u003e\n\u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n\n        \u003c!-- Spring transaction integration (optional, only if using Spring without Boot) --\u003e\n\u003cdependency\u003e\n\u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n\u003cartifactId\u003eoutbox-spring-adapter\u003c/artifactId\u003e\n\u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n\n        \u003c!-- Micrometer metrics bridge for Prometheus/Grafana (optional) --\u003e\n\u003cdependency\u003e\n\u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n\u003cartifactId\u003eoutbox-micrometer\u003c/artifactId\u003e\n\u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n## Modules\n\n- `outbox-core`: core APIs, hooks, dispatcher, poller, and registries.\n- `outbox-jdbc`: JDBC outbox store and transaction helpers.\n- `outbox-spring-boot-starter`: Spring Boot auto-configuration with `@OutboxListener` annotation.\n- `outbox-spring-adapter`: optional `TxContext` implementation for Spring (without Boot).\n- `outbox-micrometer`: Micrometer metrics bridge for Prometheus/Grafana.\n- `samples/outbox-demo`: minimal, non-Spring demo (H2).\n- `samples/outbox-spring-demo`: Spring demo app (manual wiring).\n- `samples/outbox-spring-boot-starter-demo`: Spring Boot Starter demo (zero-config auto-configuration).\n- `samples/outbox-multi-ds-demo`: multi-datasource demo (two H2 databases).\n\n## Architecture\n\n```text\n+----------------------------------------------------------------+\n|                      Transaction Scope                          |\n|                                                                 |\n|  +------------------------+  write()  +------------------+      |\n|  |  Application / Domain  | --------\u003e |   OutboxWriter   |      |\n|  +------------------------+           +--------+---------+      |\n|                                                | insert         |\n|                                                v                |\n|                                       +------------------+      |\n|                                       |    OutboxStore   |      |\n|                                       +--------+---------+      |\n|                                                | persist        |\n|                                                v                |\n|                                       +------------------+      |\n|                                       |   Outbox Table   |      |\n|                                       +------------------+      |\n+------------+-------------------------------+--------------------+\n             |                               |\n    afterCommit hook                    poll pending\n             |                               |\n             v                               v\n       +-----------+                  +--------------+\n       | Hot Queue |                  | OutboxPoller |\n       +-----+-----+                 +------+-------+\n             |                               |\n             v            enqueue cold       |\n    +------------------+                     |\n    | OutboxDispatcher | \u003c-------------------+\n    +--------+---------+\n             |\n             v\n    +------------------+ handleEvent() +--------------+\n    | ListenerRegistry | ------------\u003e |  Listener A  |\n    +--------+---------+               +--------------+\n             |                         +--------------+\n             +-----------------------\u003e |  Listener B  |\n                                       +--------------+\n\n    OutboxDispatcher --- mark DONE/DEFERRED/RETRY/DEAD ---\u003e OutboxStore\n```\n\nHot path is optional: supply an `WriterHook` (for example, `DispatcherWriterHook`) or omit it for CDC-only consumption.\n\n## How It Works\n\n- **Write inside TX**: `OutboxWriter.write(...)` inserts into `outbox_event` using the same JDBC connection as your\n  business work.\n- **After-commit hook**: `DispatcherWriterHook` enqueues the event into the hot queue after commit. Delayed events\n  (with `availableAt` or `deliverAfter`) are skipped — the poller delivers them at the scheduled time. If the queue is\n  full, the event stays in the DB.\n- **Dispatch**: `OutboxDispatcher` drains hot/cold queues, routes to a single listener via `handleEvent()`, and updates\n  status based on `DispatchResult` (`DONE`, `DEFERRED`, `RETRY`, or `DEAD`).\n- **Fallback**: `OutboxPoller` periodically scans/claims pending rows and enqueues them into the cold queue.\n- **Delayed delivery**: Use `EventEnvelope.builder(...).deliverAfter(Duration.ofMinutes(30))` or\n  `.availableAt(futureInstant)` to schedule future delivery. The poller picks them up when `available_at` arrives.\n- **At-least-once**: listeners may see duplicates. Always dedupe downstream by `eventId`.\n- **Purge**: `OutboxPurgeScheduler` periodically deletes terminal events (DONE/DEAD) older than a configurable retention\n  period to prevent table bloat.\n\n## Operating Modes\n\n- **Hot + Poller (default)**: Use `DispatcherWriterHook` and start `OutboxPoller` for low latency plus durable fallback.\n- **Poller-only**: Omit the hook, start `OutboxPoller`. Simpler wiring, higher latency.\n- **CDC-only**: Omit both hook and poller. CDC reads the table; you manage dedupe and retention.\n\n## Tuning and Backpressure\n\n- `workerCount` controls max concurrent listener executions.\n- `hotQueueCapacity`/`coldQueueCapacity` bound in-memory buffering.\n- `skipRecent` avoids racing very recent inserts with the hot path.\n- `claimLocking(ownerId, lockTimeout)` enables multi-instance poller locking.\n- `maxAttempts` and `RetryPolicy` control retries and backoff. Handlers can override retry timing via\n  `DispatchResult.retryAfter()` or `RetryAfterException`.\n\n## Event Retention / Purge\n\nThe outbox table is a transient buffer, not an outbox store. Terminal events (DONE, DEAD) should be purged after a\nretention period to prevent table bloat:\n\n```java\nOutboxPurgeScheduler purgeScheduler = OutboxPurgeScheduler.builder()\n        .connectionProvider(connectionProvider)\n        .purger(new H2EventPurger())       // or MySqlEventPurger, PostgresEventPurger\n        .retention(Duration.ofDays(7))     // default: 7 days\n        .batchSize(500)                    // default: 500\n        .intervalSeconds(3600)             // default: 1 hour\n        .build();\npurgeScheduler.\n\nstart();\n```\n\nIf clients need to archive events for audit, they should do so in their `EventListener` before events are purged.\n\n## Failure and Delivery Semantics\n\n- Delivery is **at-least-once**. Downstream must dedupe by `eventId`.\n- Listener exceptions trigger `RETRY` with backoff; after `maxAttempts`, events go `DEAD`.\n- Handlers can return `DispatchResult.retryAfter(delay)` for deferred re-delivery without counting against\n  `maxAttempts`, or throw `RetryAfterException` for handler-controlled retry timing that does count.\n- If status updates fail, the event remains in DB and may be retried later.\n\n## Composite Builder\n\nThe `Outbox` class provides scenario-specific builders that wire dispatcher, poller,\nand writer into a single `AutoCloseable` unit:\n\n```java\n// Single-node: hot path + poller fallback\ntry(Outbox outbox = Outbox.singleNode()\n        .connectionProvider(connProvider).txContext(txContext)\n        .outboxStore(store).listenerRegistry(registry)\n        .build()){\nOutboxWriter writer = outbox.writer();\n// write events inside transactions...\n}\n\n// Multi-node: hot path + claim-based locking\n        try(\nOutbox outbox = Outbox.multiNode()\n        .connectionProvider(connProvider).txContext(txContext)\n        .outboxStore(store).listenerRegistry(registry)\n        .claimLocking(Duration.ofMinutes(5))\n        .build()){...}\n\n// Ordered delivery: poller-only, single worker, no retry\n        try(\nOutbox outbox = Outbox.ordered()\n        .connectionProvider(connProvider).txContext(txContext)\n        .outboxStore(store).listenerRegistry(registry)\n        .intervalMs(1000)\n        .build()){...}\n\n// Writer-only (CDC mode): no dispatcher or poller\n        try(\nOutbox outbox = Outbox.writerOnly()\n        .txContext(txContext).outboxStore(store)\n        .build()){\nOutboxWriter writer = outbox.writer();\n// Events consumed externally (e.g. Debezium)\n}\n\n// Writer-only with age-based purge\n        try(\nOutbox outbox = Outbox.writerOnly()\n        .txContext(txContext).outboxStore(store)\n        .connectionProvider(connProvider)\n        .purger(new H2AgeBasedPurger())\n        .purgeRetention(Duration.ofHours(24))\n        .build()){...}\n```\n\nFor advanced wiring (custom `InFlightTracker`, per-component lifecycle, etc.), use\n`OutboxDispatcher.builder()` and `OutboxPoller.builder()` directly.\n\n## Ordered Delivery\n\nFor use cases requiring per-aggregate event ordering (e.g., `OrderCreated` before\n`OrderShipped`), use `Outbox.ordered()` or the manual **poller-only** mode with a\nsingle dispatch worker:\n\n1. **Omit `WriterHook`** — do not configure `DispatcherWriterHook` (disables the hot path).\n2. **Single-node `OutboxPoller`** — one poller instance reads events in DB insertion order.\n3. **`workerCount(1)`** — one dispatch worker processes events sequentially.\n\nThe poller reads pending events `ORDER BY created_at`, and the single worker delivers\nthem to listeners in that order. Since DB I/O (polling) is the throughput bottleneck —\nnot in-memory dispatch — a single worker easily keeps up.\n\n**Caveat — retries break ordering.** If event A fails and is retried with backoff,\nevent B (same aggregate, inserted later) can be polled and delivered while A waits.\nTo preserve strict ordering, set `maxAttempts(1)` so failed events go straight to\nDEAD without retry. Use [Dead Event Management](TUTORIAL.md#12-dead-event-management)\nto inspect and replay them manually.\n\nTrade-off: higher latency (poll interval vs. immediate hot-path delivery). For\nunordered events, use the default hot + poller mode for lowest latency.\n\n## Spring Boot Starter\n\nFor Spring Boot applications, just add the starter dependency — no manual `@Configuration` needed:\n\n```xml\n\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.leonlee\u003c/groupId\u003e\n    \u003cartifactId\u003eoutbox-spring-boot-starter\u003c/artifactId\u003e\n    \u003cversion\u003e0.8.3\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nAnnotate your listeners with `@OutboxListener`:\n\n```java\n\n@Component\n@OutboxListener(eventType = \"OrderPlaced\", aggregateType = \"Order\")\npublic class OrderListener implements EventListener {\n    public void onEvent(EventEnvelope event) {\n        // publish to MQ, update cache, etc.\n    }\n}\n```\n\nConfigure via `application.properties`:\n\n```properties\noutbox.mode=single-node\noutbox.dispatcher.worker-count=4\noutbox.poller.interval-ms=5000\n```\n\nThe starter auto-detects your database (H2, MySQL, PostgreSQL), wires `SpringTxContext`, and creates an `Outbox`\ncomposite with graceful shutdown. Micrometer metrics are enabled automatically when `spring-boot-starter-actuator` is on\nthe classpath.\n\nSee [TUTORIAL.md](TUTORIAL.md#5-spring-boot-starter) for the full guide with all configuration properties.\n\n## Requirements\n\n- Java 17 or later\n\n## Documentation\n\n- [**OBJECTIVE.md**](OBJECTIVE.md) -- Project goals, constraints, non-goals, and acceptance criteria\n- [**SPEC.md**](SPEC.md) -- Technical specification: API contracts, data model, behavioral rules, configuration,\n  observability\n- [**TUTORIAL.md**](TUTORIAL.md) -- Step-by-step guides with runnable code examples\n\n## Notes\n\n- Delivery is **at-least-once**. Use `eventId` for downstream dedupe.\n- Hot queue drops (DispatcherWriterHook) do not throw; the poller (if enabled) or CDC should pick up the event.\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fleonlee%2Foutbox","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fleonlee%2Foutbox","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fleonlee%2Foutbox/lists"}