> Reliably publish domain events using the transactional outbox and CDC polling approach.
Scanned 9/11/2026
Install to Claude Code
npx -y skills add Intense-Visions/harness-engineering --skill events-outbox-pattern --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Events Outbox Pattern?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/intense-visions-events-outbox-pattern-4b8a1a4b)More formats (shields.io, HTML) on the badges page.
# Events: Outbox Pattern
> Reliably publish domain events using the transactional outbox and CDC polling approach.
## When to Use
- You need to guarantee that domain events are published whenever a database transaction commits
- You can't afford to lose events (payment processed, order created, user registered)
- You want to avoid the dual-write problem: "write to DB and publish to queue atomically"
- You're building event-sourced systems or microservices with eventual consistency
## Instructions
**The problem the outbox solves:**
```
BAD:
1. Write to DB ✓
2. Publish to Kafka ✗ ← crash here → event lost, DB and queue out of sync
GOOD (outbox):
1. Write to DB + write to outbox table (one transaction) ✓
2. Separate poller reads outbox, publishes to Kafka ✓
3. Mark outbox records as published ✓
```
**Outbox table schema (SQL):**
```sql
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
aggregate_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
publish_attempts INT NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX outbox_unpublished ON outbox (created_at)
WHERE published_at IS NULL;
```
**Write to DB + outbox in one transaction (Prisma example):**
```typescript
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function createOrder(data: CreateOrderInput): Promise<Order> {
return prisma.$transaction(async (tx) => {
// Primary write
const order = await tx.order.create({
data: {
userId: data.userId,
items: data.items,
status: 'PENDING',
total: calculateTotal(data.items),
},
});
// Outbox write — same transaction
await tx.outbox.create({
data: {
eventType: 'order.created',
aggregateId: order.id,
payload: {
orderId: order.id,
userId: order.userId,
total: order.total,
items: order.items,
occurredAt: new Date().toISOString(),
},
},
});
return order;
});
// If transaction commits: both writes succeed atomically
// If transaction rolls back: neither write happens
}
```
**Polling publisher (separate process):**
```typescript
class OutboxPublisher {
private running = false;
constructor(
private readonly db: PrismaClient,
private readonly producer: KafkaProducer,
private readonly pollIntervalMs = 1_000,
private readonly batchSize = 100
) {}
start(): void {
this.running = true;
this.poll();
}
stop(): void {
this.running = false;
}
private async poll(): Promise<void> {
while (this.running) {
try {
await this.publishBatch();
} catch (err) {
console.error('Outbox poll error:', err);
}
await new Promise((r) => setTimeout(r, this.pollIntervalMs));
}
}
private async publishBatch(): Promise<void> {
// Fetch unpublished events — oldest first, batch
const events = await this.db.outbox.findMany({
where: { publishedAt: null, publishAttempts: { lt: 5 } },
orderBy: { createdAt: 'asc' },
take: this.batchSize,
});
if (events.length === 0) return;
for (const event of events) {
try {
// Publish to Kafka with event ID as key for deduplication
await this.producer.send({
topic: event.eventType,
messages: [
{
key: event.aggregateId,
value: JSON.stringify(event.payload),
headers: { 'outbox-id': event.id },
},
],
});
// Mark as published
await this.db.outbox.update({
where: { id: event.id },
data: { publishedAt: new Date() },
});
} catch (err) {
await this.db.outbox.update({
where: { id: event.id },
data: {
publishAttempts: { increment: 1 },
error: (err as Error).message,
},
});
}
}
}
}
```
**CDC alternative (Debezium):** Instead of polling, use Change Data Capture to stream the outbox table changes directly to Kafka. No polling process needed — lower latency, zero overhead on DB.
```yaml
# Debezium connector config (Kafka Connect)
connector.class: io.debezium.connector.postgresql.PostgresConnector
database.hostname: postgres
database.dbname: myapp
table.include.list: public.outbox
transforms: outbox
transforms.outbox.type: io.debezium.transforms.outbox.EventRouter
```
## Details
**Polling interval vs. latency trade-off:** Polling every 100ms gives ~100ms event latency. Polling every 5s gives 5s latency. CDC gives near-zero latency but adds infrastructure complexity.
**Exactly-once delivery:** The outbox gives at-least-once. To achieve exactly-once, consumers must deduplicate using the outbox record ID (stored in message headers). See `events-idempotency` skill.
**Anti-patterns:**
- Publishing inside the application after the transaction (dual-write) — defeats the purpose
- Outbox table that grows unboundedly — prune published events after a retention period
- Missing index on `WHERE published_at IS NULL` — polling becomes a full table scan under load
**Retention cleanup:**
```sql
DELETE FROM outbox
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '7 days';
```
## Source
microservices.io/patterns/data/transactional-outbox.html
## Process
1. Read the instructions and examples in this document.
2. Apply the patterns to your implementation, adapting to your specific context.
3. Verify your implementation against the details and edge cases listed above.
## Harness Integration
- **Type:** knowledge — this skill is a reference document, not a procedural workflow.
- **No tools or state** — consumed as context by other skills and agents.
## Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!