07

Events, Outbox, and Idempotent Consumers

The enterprise problem and today’s slice

Enterprise problem: ParcelFlow customers expect fulfillment and notification after an accepted order, but saving the order and publishing an event as separate writes can lose work, invent phantom work, or repeat side effects.

Whole-course context: The incoming evidence is a validated synchronous Order→Inventory contract with additive evolution, bounded deadlines, and fake Payment; today carries the accepted outcome into asynchronous work.

Today’s slice: We commit an event to an Order-owned transactional outbox, relay through a broker-neutral port, and deduplicate versioned events in fulfillment and notification consumers.

End-of-day evidence: Runnable crash, duplicate, and out-of-order tests show one durable order, one fulfillment start, one notification, and replayable failure records.

Still unsolved: Timeout budgets, retry backoff, circuit breaking, bulkheads, and customer-safe compensation across partial failures remain deferred.

Customer use cases

An accepted order that never reaches delivery is a broken promise, so asynchronous processing must expose durable progress and recoverable failure rather than imply instant completion.

Use case IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D07-UC-01Tenant order operatorPlace an order and observe fulfillment start plus one confirmationAccepted order ord-701 reaches fulfillment_started and produces one notification despite duplicate deliveryA crash after commit leaves an unpublished outbox row that a relay later sends; duplicate delivery changes no outcome
D07-UC-02Operations responderDiagnose and replay a failed event without crossing tenant or version boundariesDead-letter record is corrected or routed to a compatible consumer, replayed, and linked to new evidenceUnknown event version or sequence gap is quarantined; another tenant's healthy stream continues unaffected

Actor-centred user stories

Message acknowledgement can occur at a different time from business completion, so stories require evidence from the consumer's authoritative state, not only broker delivery.

Story IDUse case IDsUser storyObservable acceptance conditions
D07-US-01D07-UC-01As a tenant order operator, I want accepted orders to start fulfillment and notify once, so that a process crash does not silently abandon or duplicate my deliveryOrder and outbox commit together; relaying after a crash produces one fulfillment job and one notification; duplicate event IDs are acknowledged without repeating side effects
D07-US-02D07-UC-02As an operations responder, I want incompatible or exhausted events quarantined and replayable, so that recovery is controlled and auditableDead-letter evidence includes tenant, event, version, attempts, cause, payload digest, trace, and replay ID; unaffected tenant events continue processing

End-to-end product flows

Publishing before or after a separate database commit creates a dual-write gap, so the customer flow makes the outbox row part of the same local transaction as the accepted order.

Flow IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D07-FLOW-01D07-UC-01, D07-UC-02HappyOperator selects Place order and receives accepted1. Order validates tenant, reserves Inventory, and records fake authorization.<br>2. One Order transaction inserts the accepted state and OrderAccepted.v1 outbox row.<br>3. Relay reads committed unpublished rows and calls EventBus.publish.<br>4. Each consumer locks its durable tenant-plus-aggregate stream position, then advances position, inserts the inbox key, and applies its local effect in one transaction.<br>5. Product reads fulfillment status and shows one confirmation.Actor, tenant, order, expected and observed states, environment, commit timestamp, event ID, aggregate sequence, trace ID, outbox publication evidence, inbox IDs, stream position, one fulfillment ID, one notification ID
D07-FLOW-02D07-UC-01, D07-UC-02RecoveryResponder selects Replay quarantined event after an unsupported version or consumer crash1. Consumer rejects an unknown major version or rolls back local state before acknowledgement.<br>2. Delivery is retried under policy; exhausted work enters a dead-letter record.<br>3. Responder inspects payload digest and tenant scope, then deploys compatible handling or corrects routing.<br>4. Replay creates a new attempt linked to the original event without changing its ID.<br>5. Inbox dedup allows one effect and product displays recovered progress.Original event ID, dead-letter ID, cause, sequence, version, actor, scope, environment, timestamps, replay ID, one resulting effect, and unaffected-tenant positive control

System design derived from the flows

Broker-specific calls inside domain code make reliability policy hard to test and may assume unsupported runtime clients, so ParcelFlow depends on a small EventBus port and treats every broker library as an adapter that must pass a compatibility spike on Bun.

Use case IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D07-UC-01POST /v1/orders and Order outbox relayOrder service, outbox repository, EventBus adapter, Fulfillment consumer, Notification consumerOrder database for order/outbox; each consumer database for tenant-plus-aggregate positions, inbox keys, and effectsUnpublished outbox age, publish attempt, consumer transaction rollback, duplicate acknowledgement, trace ID
D07-UC-02Operations dead-letter and replay actionConsumer retry policy, stream-position repository, dead-letter handler, replay controller, tenant authorizationDead-letter registry and each consumer-owned durable ordering/inbox/effect storeUnsupported version, scoped sequence gap, attempts exhausted, replay link, unaffected tenant-aggregate stream evidence

Data model and ownership

A shared event table would let consumers mutate producer history or each other's dedup state, so every durable record stays with the boundary whose invariant it protects.

Generated-application database: Required in this slice — Order, Fulfillment, and Notification each own tenant-scoped application data; Order owns outbox truth while every consumer owns its inbox and side effects.

Record or entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
OrderOrder database, Order serviceorder_idreservation_id opaque Inventory referencetenant_id(tenant_id, idempotency_key) unique; accepted state and outbox insert share one transactionCreated, completed/cancelled, retained for support, tenant-erased after policyD07-UC-01
OutboxEventOrder database, Order serviceevent_idaggregate_id=order_id local FKtenant_id(tenant_id, aggregate_id, aggregate_sequence) unique; payload immutable; only committed orders publishInserted with order; marked published after broker acceptance; retained for replay window then archivedD07-UC-01, D07-UC-02
FulfillmentStreamPositionFulfillment database, Fulfillment service(consumer_name, tenant_id, aggregate_id)aggregate_id opaque producer referencetenant_idlast_applied_sequence advances by exactly one while the scoped row is locked; never global or process-localCreated on first aggregate event; advanced atomically with Fulfillment inbox/job; retained through replay horizon then expired after aggregate closureD07-UC-01, D07-UC-02
FulfillmentInboxFulfillment database, Fulfillment service(consumer_name, tenant_id, event_id)event_id opaque producer reference; stream-position composite local FKtenant_idUnique key makes duplicate handling idempotent; version and scoped sequence checked before effectInserted atomically with stream advance and job; retained through replay horizon then expiredD07-UC-01, D07-UC-02
FulfillmentJobFulfillment database, Fulfillment servicefulfillment_idorder_id opaque Order referencetenant_id(tenant_id, order_id) unique; state transitions monotonicCreated on accepted event; completed/cancelled; retained through delivery support then deletedD07-UC-01, D07-UC-02
NotificationStreamPositionNotification database, Notification service(consumer_name, tenant_id, aggregate_id)aggregate_id opaque producer referencetenant_idlast_applied_sequence advances by exactly one while the scoped row is locked; independent of Fulfillment positionCreated on first aggregate event; advanced atomically with Notification inbox/send; retained through replay horizon then expired after aggregate closureD07-UC-01, D07-UC-02
NotificationInboxNotification database, Notification service(consumer_name, tenant_id, event_id)event_id opaque producer reference; scoped stream-position referencetenant_idUnique event key, monotonic tenant-plus-aggregate position, and (tenant_id, order_id, template) send uniquenessInserted with stream advance and send record; retained through replay horizon then expiredD07-UC-01
DeadLetterDead-letter registry, operations platformdead_letter_idevent_id opaque Order reference; replay_id optional local referencetenant_idPayload digest, cause, attempts, version, and authorization required before replayCreated after policy exhaustion; resolved or waived; sensitive payload expires, audit summary retainedD07-UC-02

The outbox closes the dual-write gap

Writing business state and publishing independently can leave one without the other, so the transactional outbox turns the operation into one local atomic commit followed by a retryable relay. AWS Prescriptive Guidance documents the same failure: state may commit while notification fails, or notification may escape while state rolls back; it also warns that duplicate delivery requires idempotent consumers. AWS transactional outbox pattern.

General rule: make the business change and durable publication intent atomic in one owner store. Simple example: insert an order row and an outbox row in one transaction. ParcelFlow example: OrderAccepted.v1 exists if and only if ord-701 commits as accepted. Failure mode: the process crashes after commit but before publish. Decision rule: a separate relay must be able to rediscover every committed unpublished row.

Bun's SQL client supports transactions where thrown errors roll back the callback, which is sufficient for this local atomic boundary; it is not a distributed transaction across services. Bun SQL transactions.

CREATE TABLE order_outbox (
  event_id text PRIMARY KEY,
  tenant_id text NOT NULL,
  aggregate_id text NOT NULL REFERENCES orders(order_id),
  aggregate_sequence bigint NOT NULL,
  event_type text NOT NULL,
  event_version integer NOT NULL,
  payload_json jsonb NOT NULL,
  occurred_at timestamptz NOT NULL,
  published_at timestamptz,
  UNIQUE (aggregate_id, aggregate_sequence)
);
await sql.begin(async (tx) => {
  await tx`INSERT INTO orders ${tx(orderRow)}`;
  await tx`INSERT INTO order_outbox ${tx(eventRow)}`;
});

Event envelope and broker-neutral relay

Payload-only events cannot be routed, ordered, traced, or evolved safely, so a stable envelope carries identity and metadata while versioned data carries domain facts.

export type DomainEvent<T> = {
  eventId: string;
  eventType: "OrderAccepted";
  eventVersion: 1;
  tenantId: string;
  aggregateId: string;
  aggregateSequence: number;
  occurredAt: string;
  traceId: string;
  data: T;
};

export interface EventBus {
  publish(topic: string, event: DomainEvent<unknown>): Promise<{ brokerMessageId: string }>;
}

General rule: domain code depends on delivery semantics, not a vendor API. Simple example: an in-memory bus runs contract tests. ParcelFlow example: a production adapter maps the envelope to the chosen broker and returns acceptance evidence. Failure mode: assuming a Node-oriented broker client works under Bun because its package installs. Decision rule: treat every client as an unproven adapter until a spike proves connect, publish, consume, reconnect, shutdown, authentication, and load behaviour on the pinned Bun version.

event_bus_adapter_acceptance:
  runtime: bun-pinned-by-lockfile
  required: [connect, publish, consume, reconnect, graceful-shutdown, auth, load]
  delivery_assumption: at-least-once
  forbidden_claim: exactly-once-business-effect-from-broker-alone

The relay marks published_at only after adapter acceptance. A crash after publish but before marking causes a duplicate publish, which is why at-least-once delivery is the honest baseline.

Inbox, ordering, versions, dead letters, and replay

Redelivery and reordering are normal failure shapes in asynchronous systems, so consumers must make duplicate, stale, future, and poisoned events explicit.

General rule: keep ordering position durable per consumer, tenant, and aggregate, then advance it with the inbox key and local business effect in one transaction. Simple example: the second delivery of evt-701 finds the inbox key and returns acknowledgement without sending again. ParcelFlow example: Fulfillment can accept tn-a/order-7 sequence 8 after 7 while independently accepting tn-b/order-9 sequence 1; it quarantines sequence 10 only for the first stream while 9 is missing. Failure mode: one volatile global counter makes unrelated tenants block each other and forgets progress on restart. Decision rule: lock the scoped durable position, require exactly last_applied_sequence + 1, commit position, inbox, and effect together, then acknowledge.

BEGIN;
INSERT INTO consumer_stream_positions
  (consumer_name, tenant_id, aggregate_id, last_applied_sequence)
VALUES ('fulfillment-v1', $1, $2, 0)
ON CONFLICT DO NOTHING;

WITH current_position AS (
  SELECT last_applied_sequence
  FROM consumer_stream_positions
  WHERE consumer_name = 'fulfillment-v1' AND tenant_id = $1 AND aggregate_id = $2
  FOR UPDATE
), accepted AS (
  INSERT INTO fulfillment_inbox
    (consumer_name, tenant_id, event_id, aggregate_id, aggregate_sequence, received_at)
  SELECT 'fulfillment-v1', $1, $3, $2, $4, now()
  FROM current_position
  WHERE last_applied_sequence + 1 = $4
  ON CONFLICT DO NOTHING
  RETURNING tenant_id, aggregate_id
), advanced AS (
  UPDATE consumer_stream_positions AS position
  SET last_applied_sequence = $4
  FROM accepted
  WHERE position.consumer_name = 'fulfillment-v1'
    AND position.tenant_id = accepted.tenant_id
    AND position.aggregate_id = accepted.aggregate_id
  RETURNING position.tenant_id, position.aggregate_id
)
INSERT INTO fulfillment_jobs (fulfillment_id, tenant_id, order_id, state)
SELECT $5, tenant_id, aggregate_id, 'started' FROM advanced;
COMMIT;

A zero-row application result is classified against the locked durable state: a matching inbox key is an acknowledged duplicate, a higher sequence is a scoped gap, and a different event at an already-used sequence is a fork that must be quarantined. A dead-letter queue (DLQ) is quarantine evidence, not a rubbish bin. It retains the failed envelope or protected payload reference, cause, attempt history, tenant-plus-aggregate scope, expected and observed sequences, and policy decision. Replay preserves the original event ID so inbox dedup remains effective; a changed business fact requires a new event ID and sequence.

Runnable crash, duplicate, and ordering tests

Happy-path unit tests cannot establish recovery semantics, so inject failures at commit and acknowledgement boundaries and assert authoritative state. Bun ships a TypeScript-capable built-in test runner and documents mocks for controlled dependencies. Bun test runner and Bun mocks.

import { expect, test } from "bun:test";

type TestEvent = { eventId: string; tenantId: string; aggregateId: string; aggregateSequence: number };
const event = (override: Partial<TestEvent> = {}): TestEvent => ({
  eventId: "evt-701",
  tenantId: "tn-a",
  aggregateId: "ord-701",
  aggregateSequence: 1,
  ...override,
});

class MemoryOrderStore {
  orders = new Map<string, { state: string }>();
  outbox: Array<{ eventId: string; published: boolean }> = [];
  async acceptOrderThenCrash(orderId: string, eventId: string) {
    this.orders.set(orderId, { state: "accepted" });
    this.outbox.push({ eventId, published: false });
    throw new Error("crash-after-commit");
  }
  order(id: string) { return this.orders.get(id); }
  unpublished() { return this.outbox.filter((row) => !row.published); }
}

class MemoryConsumerStore {
  inbox = new Set<string>();
  positions = new Map<string, number>();
  jobs: Array<{ tenantId: string; orderId: string }> = [];
  quarantined: Array<{ eventId: string; tenantId: string; aggregateId: string; cause: string }> = [];
}

class MemoryFulfillmentConsumer {
  constructor(private store: MemoryConsumerStore) {}
  async handle(input: TestEvent) {
    const consumer = "fulfillment-v1";
    const inboxKey = `${consumer}:${input.tenantId}:${input.eventId}`;
    const streamKey = `${consumer}:${input.tenantId}:${input.aggregateId}`;
    if (this.store.inbox.has(inboxKey)) return;
    const expected = (this.store.positions.get(streamKey) ?? 0) + 1;
    if (input.aggregateSequence !== expected) {
      this.store.quarantined.push({
        eventId: input.eventId,
        tenantId: input.tenantId,
        aggregateId: input.aggregateId,
        cause: "SEQUENCE_GAP",
      });
      return;
    }
    // These three mutations model one consumer-store transaction.
    this.store.inbox.add(inboxKey);
    this.store.positions.set(streamKey, input.aggregateSequence);
    this.store.jobs.push({ tenantId: input.tenantId, orderId: input.aggregateId });
  }
  jobsFor(tenantId: string, orderId: string) {
    return this.store.jobs.filter((job) => job.tenantId === tenantId && job.orderId === orderId);
  }
  position(tenantId: string, aggregateId: string) {
    return this.store.positions.get(`fulfillment-v1:${tenantId}:${aggregateId}`) ?? 0;
  }
  deadLetters() { return this.store.quarantined; }
}

test("crash after commit leaves relayable outbox intent", async () => {
  const db = new MemoryOrderStore();
  await expect(db.acceptOrderThenCrash("ord-701", "evt-701")).rejects.toThrow("crash-after-commit");
  expect(db.order("ord-701")?.state).toBe("accepted");
  expect(db.unpublished().map((row) => row.eventId)).toEqual(["evt-701"]);
});

test("duplicate delivery creates one effect", async () => {
  const store = new MemoryConsumerStore();
  const consumer = new MemoryFulfillmentConsumer(store);
  await consumer.handle(event({ eventId: "evt-701", aggregateSequence: 1 }));
  await consumer.handle(event({ eventId: "evt-701", aggregateSequence: 1 }));
  expect(consumer.jobsFor("tn-a", "ord-701")).toHaveLength(1);
});

test("ordering survives restart and remains tenant-plus-aggregate scoped", async () => {
  const store = new MemoryConsumerStore();
  await new MemoryFulfillmentConsumer(store).handle(event({ eventId: "evt-701", aggregateSequence: 1 }));
  const restarted = new MemoryFulfillmentConsumer(store);
  await restarted.handle(event({ eventId: "evt-702", aggregateSequence: 2 }));
  await restarted.handle(event({ eventId: "evt-b01", tenantId: "tn-b", aggregateId: "ord-901", aggregateSequence: 1 }));
  expect(restarted.position("tn-a", "ord-701")).toBe(2);
  expect(restarted.position("tn-b", "ord-901")).toBe(1);
});

test("out-of-order event quarantines only its scoped stream", async () => {
  const store = new MemoryConsumerStore();
  const consumer = new MemoryFulfillmentConsumer(store);
  await consumer.handle(event({ eventId: "evt-701", aggregateSequence: 1 }));
  await consumer.handle(event({ eventId: "evt-703", aggregateSequence: 3 }));
  await consumer.handle(event({ eventId: "evt-b01", tenantId: "tn-b", aggregateId: "ord-901", aggregateSequence: 1 }));
  expect(consumer.deadLetters()).toMatchObject([{ eventId: "evt-703", cause: "SEQUENCE_GAP" }]);
  expect(consumer.jobsFor("tn-a", "ord-701")).toHaveLength(1);
  expect(consumer.jobsFor("tn-b", "ord-901")).toHaveLength(1);
});
bun test tests/events/crash.test.ts tests/events/duplicate.test.ts tests/events/ordering.test.ts
# expected terminal evidence: 4 pass, 0 fail, run_id=evt-run-0707, durable scoped positions, one duplicate-safe job, one quarantined gap

Key takeaways

Asynchronous delivery separates acceptance from completion, so durability, deduplication, and recovery evidence must replace assumptions about a single successful send.

  • Commit order state and outbox intent atomically in the Order store.
  • Expect at-least-once publication and delivery; enforce one business effect with consumer-owned inbox keys and a durable tenant-plus-aggregate position.
  • Carry event identity, tenant, aggregate sequence, version, time, and trace in a stable envelope; never use one global ordering counter.
  • Preserve producer and consumer ownership; no consumer mutates Order outbox rows.
  • Treat broker packages as adapters requiring a Bun compatibility spike, not guaranteed compatibility.
  • Quarantine unknown versions and sequence gaps, then replay under explicit authority.

Checklist

Event pipelines can appear healthy while losing or duplicating business work, so verify state around every crash window.

  • [ ] Order and outbox rows commit or roll back together.
  • [ ] Relay rediscovers committed unpublished events after restart.
  • [ ] Duplicate delivery produces one fulfillment job and one notification.
  • [ ] Durable tenant-plus-aggregate position, consumer inbox, and effect commit in one local transaction.
  • [ ] Consumer restart preserves each scoped stream position without blocking another tenant or aggregate.
  • [ ] Unknown version and sequence gap enter durable quarantine.
  • [ ] Replay keeps original event ID and records a new replay attempt ID.
  • [ ] Another tenant's stream remains a positive control during recovery.