Alvyn logoAlvyn

Transactional Outbox

Atomically publish domain events to external message brokers (Kafka, NATS, RabbitMQ) with at-least-once delivery guarantees.

Transactional Outbox

The Transactional Outbox pattern guarantees at-least-once delivery of events to external message brokers (e.g. NATS, Apache Kafka, RabbitMQ, AWS SQS) without distributed transactions.

Outbox entries are inserted in the exact same PostgreSQL transaction as the event store append. If the transaction commits, both the event and its corresponding outbox rows are guaranteed to exist.

1. Client Appends Event


┌────────────────────────────────────────┐
│ PostgreSQL ACID Transaction            │
│ ├─ INSERT INTO events (...)            │
│ └─ INSERT INTO outbox (...)            │
└────────────────────────────────────────┘

   ▼ (committed)
┌────────────────────────────────────────┐
│ Relay Worker Fleet                     │
│ ├─ SELECT ... FOR UPDATE SKIP LOCKED   │
│ ├─ Dispatch to Message Broker (NATS)   │
│ └─ UPDATE outbox SET processed_at=now()│
└────────────────────────────────────────┘

Solving the Dual-Write Problem

A common architectural pitfall is trying to write to PostgreSQL and publish to a message broker in separate steps:

// ❌ DANGEROUS: Dual-write hazard
await eventStore.append({ streamId, events: [orderPlaced] });
await natsBroker.publish("orders", orderPlaced); // If this fails or worker crashes, event is lost to broker!

If your node process crashes or the network blips between those two lines, your message broker will never receive the event.

With Alvyn's Transactional Outbox, publishing is atomic and durable:

// ✅ SAFE: Single ACID transaction
await eventStore.append({
  streamId: "Order-123",
  expectedVersion: 0,
  events: [{ type: "OrderPlaced", data: { total: 99.99 } }],
  outboxTopics: ["orders", "analytics.orders"],
});

Publishing to the Outbox

Direct Append

Specify outboxTopics array on eventStore.append():

await eventStore.append({
  streamId: "Order-123",
  expectedVersion: 0,
  events: [
    {
      type: "OrderPlaced",
      data: { total: 99.99, customerId: "cust_482" },
      extensions: { correlationid: "req_8819" },
    },
  ],
  outboxTopics: ["orders", "notifications"],
});

With Typed Aggregates

When using defineAggregate, pass outboxTopics directly to the aggregate append handle:

await Order.append(eventStore, {
  entityId: "order-123",
  expectedVersion: order.version,
  events: [
    {
      type: "OrderShipped",
      data: { trackingNumber: "TRACK-99128" },
    },
  ],
  outboxTopics: ["shipping-events"],
});

Outbox Payload Format (CloudEvents v1.0.2)

Every outbox record generates an industry-standard CloudEvents v1.0.2 compliant JSON envelope:

{
  "specversion": "1.0",
  "id": "Order-123/1",
  "type": "OrderPlaced",
  "source": "urn:my-app:event-store",
  "subject": "Order-123",
  "time": "2026-08-24T10:00:00.000Z",
  "datacontenttype": "application/json",
  "correlationid": "req_8819",
  "actorid": "usr_9921",
  "schemaversion": 1,
  "data": {
    "total": 99.99,
    "customerId": "cust_482"
  }
}

Worker Relay with processOutbox()

Build a relay worker that claims pending outbox entries and dispatches them to your external message broker:

import { EventStore } from "@lox-solutions/alvyn";
import { connect } from "nats";

const nc = await connect({ servers: process.env.NATS_URL });
const jsm = await nc.jetstream();

async function runOutboxRelay() {
  try {
    // Process up to 100 pending messages per batch
    const processedCount = await eventStore.processOutbox(
      async (entries, client) => {
        for (const entry of entries) {
          // entry.topic matches the string from outboxTopics
          // entry.payload contains the CloudEvent JSON object
          await jsm.publish(entry.topic, JSON.stringify(entry.payload));
        }
      },
      100,
    );

    if (processedCount > 0) {
      console.log(`Successfully dispatched ${processedCount} outbox events.`);
    }
  } catch (err) {
    console.error("Outbox relay error:", err);
  }
}

// Continuously poll for pending outbox items
setInterval(runOutboxRelay, 1000);

Replica Safety (FOR UPDATE SKIP LOCKED)

processOutbox() uses PostgreSQL's row-level locking with FOR UPDATE SKIP LOCKED.

  • Multiple background workers or server replicas can execute runOutboxRelay() concurrently without collision.
  • Each pending row is locked and claimed by exactly one worker.
  • Other workers skip already-locked rows and immediately process the next available records.
  • If a worker crashes before finishing, PostgreSQL releases the row lock, allowing other workers to pick it up on the next cycle.

Retention and Cleanup

Once an outbox entry is processed, its processed_at column is set to the current timestamp. Over time, these processed entries accumulate.

Use cleanupOutbox() in a scheduled maintenance cron job to delete old processed records:

// Delete processed entries older than 7 days (default) in batches of 500
const deletedCount = await eventStore.cleanupOutbox(
  7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
  500, // Batch size
);

console.log(`Cleaned up ${deletedCount} archived outbox records.`);

Outbox vs. Subscriptions

FeatureTransactional Outbox (processOutbox())Subscriptions (subscribe())
ModelCompeting Consumers (Distributed Workers)Fan-Out (Every Subscriber)
TargetExternal Brokers (Kafka, NATS, RabbitMQ, SQS)In-process consumers, GraphQL, WebSockets, SSE
ConcurrencyExactly 1 worker publishes each eventEvery replica/subscriber receives all events
LockingPostgreSQL FOR UPDATE SKIP LOCKEDLock-free streaming via LISTEN/NOTIFY + Polling
StorageDedicated outbox table (cleaned up periodically)Read directly from events table

For building query-optimized read models inside the same PostgreSQL database, see Projections. For architectural guidance on choosing between direct streaming and broker relays, see the Event Streaming & Resilient Consumer Playbook.

On this page