Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b1699eb
Add notify-spring-boot-outbox module skeleton with status and entry t…
dominik-kovacs Jul 23, 2026
b0c5ffa
Add outbox properties, store SPI, and canonical schema
dominik-kovacs Jul 23, 2026
63fd5b5
Add JdbcOutboxStore with SKIP LOCKED claim, integration-tested on Pos…
dominik-kovacs Jul 23, 2026
c84aeb8
Add OutboxNotifier API and JSON-serializing DefaultOutboxNotifier
dominik-kovacs Jul 23, 2026
c9c07ee
Add OutboxRelay: poll, deliver via Notifier, exponential backoff
dominik-kovacs Jul 23, 2026
3f68234
Add OutboxAutoConfiguration wiring the store, notifier, and scheduled…
dominik-kovacs Jul 27, 2026
cc619d1
Use Jackson 3 JsonMapper in the outbox, not Jackson 2 ObjectMapper
dominik-kovacs Jul 27, 2026
4374903
Fail the entry, not the poll cycle, on an undeserializable outbox pay…
dominik-kovacs Jul 27, 2026
997a73f
Auto-configure the outbox on a DataSource and schedule the relay prog…
dominik-kovacs Jul 27, 2026
9443326
Correct the outbox store SPI docs: a replacement must still join the …
dominik-kovacs Jul 27, 2026
9421d25
Generate outbox config metadata and declare dependencies explicitly
dominik-kovacs Jul 27, 2026
37f3773
Add outbox-email example: demonstrates transactional outbox with Post…
dominik-kovacs Jul 27, 2026
21608d0
Give the outbox its own scheduler so apps need not enable scheduling
dominik-kovacs Jul 27, 2026
b2e930b
Restore outbox test dependencies
dominik-kovacs Jul 27, 2026
8e8d9bc
Test DefaultOutboxNotifier: pending entry, type, payload, id
dominik-kovacs Jul 27, 2026
4a921c9
Test OutboxProperties defaults and overrides
dominik-kovacs Jul 27, 2026
6715e63
Test OutboxRelay: delivery, retry scheduling, backoff, poison pills
dominik-kovacs Jul 27, 2026
05cc13e
Test JdbcOutboxStore against Postgres using the shipped schema
dominik-kovacs Jul 27, 2026
cd0ba96
Test that concurrent relays claim disjoint entries via SKIP LOCKED
dominik-kovacs Jul 27, 2026
11f6aa5
Test outbox auto-configuration, scheduler, properties, and store over…
dominik-kovacs Jul 27, 2026
7ce3a6f
Keep the relay polling through store failures and use RecordingNotifi…
dominik-kovacs Jul 27, 2026
e629b44
Add OutboxTracePropagator SPI with a no-op implementation
dominik-kovacs Jul 27, 2026
14ff329
Share one recording outbox store across the tests
dominik-kovacs Jul 27, 2026
f4a5a8e
Return Optional from OutboxTracePropagator.capture()
dominik-kovacs Jul 27, 2026
edb36a5
Persist a trace_context column on outbox entries
dominik-kovacs Jul 27, 2026
487b210
Add a Micrometer-based outbox trace propagator
dominik-kovacs Jul 27, 2026
14bc56b
Capture the trace context at enqueue and restore it for delivery
dominik-kovacs Jul 27, 2026
da8e0cb
Auto-configure real trace propagation when tracing is present
dominik-kovacs Jul 27, 2026
9234a56
Select the outbox trace propagator independently of bean declaration …
dominik-kovacs Jul 27, 2026
496ff74
Log the outbox lifecycle: relay start/stop, enqueue, and delivery
dominik-kovacs Jul 27, 2026
d167763
Prove outbox trace propagation end to end and document it
dominik-kovacs Jul 27, 2026
ad49908
Prove the restored trace context parents the outbox delivery
dominik-kovacs Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ docs/superpowers/

# private working notes (pitch, plan, design, handoff)
.notes/

# local-only Maven settings (mirror workaround); never committed — breaks release deploy
.mvn/settings.xml
.mvn/maven.config
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ documents the request fields.
dead-lettering, or retry. → [Observability & events](docs/observability-and-events.md#events)
- **Interceptors** — wrap every send (or one channel) for logging, rate limiting, retry, kill
switches. → [Interceptors](docs/interceptors.md)
- **Transactional outbox** — persist a send in your business transaction; a relay delivers it
durably afterwards, safe across instances. → [Transactional outbox](docs/outbox.md)
- **Test double** — `RecordingNotifier` asserts sends without a real provider.
→ [Testing](docs/testing.md)

Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ notifier.notify(SmsRequest.builder()
- [Providers](providers.md) — the provider matrix and per-provider configuration.
- [Interceptors](interceptors.md) — wrap every send for logging, rate limiting, retry, kill switches.
- [Observability & events](observability-and-events.md) — Micrometer observation, lifecycle events, interceptor ordering.
- [Transactional outbox](outbox.md) — durable sends that commit with your business transaction.
- [Testing](testing.md) — asserting notifications with `RecordingNotifier`.

> Latest release: `1.0.0` (on Maven Central) · Java 25 · Spring Boot 4.1.
Expand Down
188 changes: 188 additions & 0 deletions docs/outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Transactional outbox

The `notify-spring-boot-outbox` module makes a send **durable and transactionally consistent**:
you persist the notification in the same database transaction as your business change, and a
background relay delivers it afterwards.

```xml
<dependency>
<groupId>sk.solodev</groupId>
<artifactId>notify-spring-boot-outbox</artifactId>
</dependency>
```

## Why

`notifier.notify(request)` calls the provider inline. That leaves two gaps:

- **Lost notification** — the business transaction commits, then the process dies before the
provider call completes. The customer never hears about their order.
- **Phantom notification** — you send first, then the surrounding transaction rolls back. The
customer is told about an order that does not exist.

The outbox closes both. If the transaction commits, the notification *will* be delivered; if it
rolls back, nothing is sent.

## Use

Inject `OutboxNotifier` instead of `Notifier` and call `enqueue` inside a transactional method:

```java
@Service
class OrderService {

private final OrderRepository orders;
private final OutboxNotifier outbox;

OrderService(OrderRepository orders, OutboxNotifier outbox) {
this.orders = orders;
this.outbox = outbox;
}

@Transactional
public void placeOrder(Order order) {
orders.save(order);
// Written in the same transaction as the save: roll back and nothing is sent.
outbox.enqueue(EmailRequest.builder()
.to(order.customerEmail())
.from("shop@example.com")
.subject("Order received")
.body("Thanks for your order!")
.build());
}
}
```

`enqueue` returns the **outbox entry id**, not a provider message id — at that point nothing has
been sent yet. Use the id to correlate with the `NotificationSent` / `NotificationFailed` event
published when the relay actually delivers.

Which bean you inject *is* the durability choice: `Notifier` sends inline, `OutboxNotifier`
persists for durable delivery. Both can be used in the same application.

## The database table

The outbox needs one table. The module ships the canonical DDL as
`schema-notification-outbox.sql` (on the classpath), but **does not run it** — your application
owns its schema:

- **Production** — copy it into your Flyway/Liquibase migrations.
- **Dev/test** — point `spring.sql.init.schema-locations` at
`classpath:schema-notification-outbox.sql`.

The DDL is written for PostgreSQL. For MySQL 8+, use `CHAR(36)` for `id` and `DATETIME(6)` for the
timestamps. The relay's claim query uses `FOR UPDATE SKIP LOCKED`, which requires **PostgreSQL or
MySQL 8.0+**.

The table includes:
- `id` — outbox entry id (UUID)
- `request_type` / `payload` — the serialized notification request
- `status` / `attempts` / `max_attempts` — relay state
- `message_id` / `last_error` — delivery outcome
- `created_at` / `next_attempt_at` / `sent_at` — timing
- `trace_context` — W3C trace parent (VARCHAR(512), nullable)

Override the table name with `spring.notify.outbox.table-name` if `notification_outbox` clashes.

**Existing deployments:** if you are adding the outbox module to a running application, migrate the
schema:

```sql
ALTER TABLE notification_outbox ADD COLUMN trace_context VARCHAR(512);
```

## How delivery works

```
@Transactional method
orders.save(order)
outbox.enqueue(request) ──► INSERT ... status=PENDING (same transaction)
commit ──► row is durable
OutboxRelay (every poll-interval, on every instance)
SELECT ... WHERE status='PENDING' AND next_attempt_at <= now
FOR UPDATE SKIP LOCKED LIMIT batch-size
per row: deserialize ──► notifier.notify(request) ──► status=SENT, message_id
on failure ──► attempts++, next_attempt_at = now + backoff (or FAILED)
```

The relay delivers through the **normal `Notifier` pipeline**, so your interceptors, events, and
observations all apply at real delivery time — the outbox composes on top of them rather than
bypassing them.

**Multi-instance safe.** `SKIP LOCKED` means concurrent relays on different instances claim
disjoint rows, so you can run the outbox on every instance without double-sending. No leader
election required.

## Observability

The enqueue and the eventual delivery are linked into one trace. When you call
`outbox.enqueue(request)` inside a transactional method, the outbox captures the ambient W3C trace
context and stores it in the `trace_context` column. Later, when the `OutboxRelay` delivers the
entry, it restores that context around the delivery so the delivery span descends from the request
that enqueued it — the full notification flow appears as a single, connected trace despite the
durable boundary.

This requires **micrometer-tracing** and a bridge (e.g.
`io.micrometer:micrometer-tracing-bridge-brave`) on the classpath. Without tracing, the outbox
works exactly as before: the `trace_context` column stays null, and each delivery starts an
unparented span.

Each retry is its own child span under the original enqueue trace. If a stored context cannot be
restored — the serialization format changed between enqueue and delivery — the delivery degrades to
an unparented span rather than failing.

## Retries and failure

A failed delivery increments `attempts` and schedules the next try with exponential backoff
(doubling from `initial-backoff`, capped at `max-backoff`). Once `attempts` reaches
`max-attempts`, the entry becomes `FAILED` and is no longer retried; `last_error` holds the final
message.

A payload that cannot be deserialized at all — the request class was renamed or removed, or the
stored JSON no longer matches it — is marked `FAILED` immediately rather than retried, since it can
never succeed. This keeps one bad row from blocking every other entry in the batch.

Retries are durable and cross-poll: `attempts` and `next_attempt_at` live in the table, so a pending
retry survives restarts and redeploys. If you also write a retrying
[interceptor](interceptors.md), note that its attempts sit *inside* one relay attempt, so the two
counts multiply.

**Delivery is at-least-once.** If the process dies after the provider accepts the message but
before the status update commits, the relay will deliver that entry again on a later poll. Design
consumers accordingly, or make the sends idempotent at the provider level.

## Configuration

| Key | Default | Meaning |
|----------------------------------------|-----------------------|--------------------------------------------------|
| `spring.notify.outbox.poll-interval` | `PT5S` | how often the relay polls |
| `spring.notify.outbox.batch-size` | `100` | rows claimed per poll |
| `spring.notify.outbox.max-attempts` | `5` | attempts before an entry is `FAILED` |
| `spring.notify.outbox.initial-backoff` | `PT10S` | delay before the first retry |
| `spring.notify.outbox.max-backoff` | `PT10M` | cap on the exponential backoff |
| `spring.notify.outbox.table-name` | `notification_outbox` | outbox table name |

```yaml
spring:
notify:
outbox:
poll-interval: 2s
max-attempts: 8
```

Adding the module is the opt-in — every key above is optional. The outbox auto-configures as soon as
a `DataSource` bean is present.

## Supplying your own store

Persistence sits behind the `OutboxStore` SPI (`insert`, `claimBatch`, `markSent`, `markForRetry`,
`markFailed`). The JDBC implementation is registered `@ConditionalOnMissingBean`, so defining your
own `OutboxStore` bean replaces it — useful for a different claim strategy, a dialect the shipped SQL
does not cover, a table that does not match the canonical layout, or wrapping the store to add
metrics.

Whatever you implement, `insert` must enlist in the caller's transaction — that is what makes the
notification commit atomically with the business change. In practice this means a store backed by the
same transactional datasource as the application; a store that commits independently gives up the
guarantee the outbox exists to provide.
89 changes: 89 additions & 0 deletions examples/outbox-email/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>

<groupId>sk.solodev.examples</groupId>
<artifactId>outbox-email</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>spring-notify :: examples :: outbox-email</name>
<description>Transactional outbox pattern: enqueues notifications in a business transaction, delivers via relay. Integration-tested with PostgreSQL + Mailpit to prove commit/rollback guarantees.</description>

<properties>
<java.version>25</java.version>
<spring-notify.version>1.0.1-SNAPSHOT</spring-notify.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>sk.solodev</groupId>
<artifactId>notify-bom</artifactId>
<version>${spring-notify.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>sk.solodev</groupId>
<artifactId>notify-spring-boot-outbox</artifactId>
</dependency>
<dependency>
<groupId>sk.solodev</groupId>
<artifactId>notify-spring-boot-starter-email-smtp</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.outbox;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.outbox;

import java.math.BigDecimal;
import java.util.UUID;

record Order(UUID id, String customerEmail, String productName, BigDecimal amount) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.outbox;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.UUID;

@RestController
class OrderController {

private final OrderService orderService;

OrderController(OrderService orderService) {
this.orderService = orderService;
}

@PostMapping("/orders")
Map<String, UUID> createOrder(@RequestBody Order order,
@RequestParam(defaultValue = "false") boolean fail) {
var orderId = orderService.placeOrder(order, fail);
return Map.of("orderId", orderId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.example.outbox;

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.UUID;

@Repository
class OrderRepository {

private final JdbcClient jdbc;

OrderRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}

void insert(Order order) {
jdbc.sql("""
INSERT INTO orders (id, customer_email, product_name, amount)
VALUES (:id, :customerEmail, :productName, :amount)
""")
.param("id", order.id())
.param("customerEmail", order.customerEmail())
.param("productName", order.productName())
.param("amount", order.amount())
.update();
}

Optional<Order> findById(UUID id) {
return jdbc.sql("SELECT id, customer_email, product_name, amount FROM orders WHERE id = :id")
.param("id", id)
.query((rs, rowNum) -> new Order(
(UUID) rs.getObject("id"),
rs.getString("customer_email"),
rs.getString("product_name"),
rs.getBigDecimal("amount")
))
.optional();
}
}
Loading
Loading