Skip to content

Outbox Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the outbox-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

The outbox pattern stages messages in the same transaction as your domain write, then dispatches them asynchronously. Reliable "at-least-once" delivery without distributed transactions.

Three pieces compose, all required:

  • @modularityjs/outboxOutboxStore (where rows live), OutboxPublisher (where dispatched messages go), OutboxDispatcher (the worker that drains pending → published), OutboxModule. You inject OutboxStore to write entries and OutboxDispatcher.dispatchPending(limit?) to drain them. OutboxEntry = { topic, payload }; the store assigns id, createdAt, and tracks attempts/lastError.
  • Store driveroutbox-memory (test/dev), outbox-database-typeorm (uses DatabaseTypeormModule; registers an OutboxEntity via DatabaseEntitiesPool), outbox-database-prisma (uses DatabasePrismaModule).
  • Publisher driveroutbox-events (dispatches each row as a DomainEvent over the EventBus). One driver bound at a time via preferences.
  • Dispatcher triggeroutbox-scheduler registers a ScheduledJobsPool entry that calls dispatcher.dispatchPending() on a cron interval. Without it the dispatcher only runs when you call it manually.
typescript
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import { OutboxModule, OutboxStore } from '@modularityjs/outbox';
import { OutboxEventsModule } from '@modularityjs/outbox-events';
import { OutboxMemoryModule } from '@modularityjs/outbox-memory';
import { OutboxSchedulerModule } from '@modularityjs/outbox-scheduler';
import { EventsModule } from '@modularityjs/events';
import { EventsMemoryModule } from '@modularityjs/events-memory';
import { SchedulerModule } from '@modularityjs/scheduler';
import { SchedulerCronerModule } from '@modularityjs/scheduler-croner';

@Injectable()
class OrderService {
  constructor(@Inject(OutboxStore) private readonly outbox: OutboxStore) {}

  async place(orderId: string, customerId: string): Promise<void> {
    // In a real app this is the same transaction as the order INSERT —
    // typeorm/prisma stores join the existing transaction so the row
    // and the outbox entry commit atomically.
    await this.outbox.enqueue([
      { topic: 'order.placed', payload: { orderId, customerId } },
    ]);
  }
}

@Module({
  name: 'orders',
  imports: [OutboxModule],
  providers: [OrderService],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [
    ModularityModule,
    EventsModule,
    EventsMemoryModule,
    SchedulerModule,
    SchedulerCronerModule,
    OutboxModule,
    OutboxMemoryModule, // store
    OutboxEventsModule, // publisher → EventBus
    OutboxSchedulerModule.forRoot({ schedule: '* * * * *' }), // every minute (5-field cron; the default)
    AppModule,
  ],
});
await app.start();

Pending row lifecycle: enqueue writes pending → scheduler tick → dispatcher.dispatchPending(batchSize) fetches a batch → for each row: publisher.publish(row) → on success, markDispatched(ids); on failure, markFailed(id, error) (retried on next tick) or markDead(id, error) after exceeding the retry limit. OutboxConfig.maxAttempts is the threshold between markFailed and markDeadunset by default, meaning rows retry forever and are never dead-lettered until you configure it.

Why the indirection. Publishing directly from the request handler couples the response to the broker — broker down ≠ orders rejected. With outbox, the write commits with the domain row in one transaction; the publisher retries until it succeeds. Idempotent consumers handle the at-least-once duplicates this introduces.

Choosing a publisher. outbox-events reroutes outbox rows back through the in-process EventBus — useful for keeping cross-aggregate workflows on the same DI surface. For external systems (Kafka, RabbitMQ, SNS), write your own OutboxPublisher driver: extend OutboxPublisher, implement publish(row): Promise<void> (throw on failure → the dispatcher records attempts++), bind via preferences: [{ provide: OutboxPublisher, useClass: ... }] in your driver module.

Dispatcher cron interval is a knob with consequences. Too slow = latency tail. Too fast on a typeorm/prisma store = each tick does SELECT ... FOR UPDATE SKIP LOCKED against the outbox table. 5-15 seconds is a reasonable range; outbox-scheduler ships no default so you must pick one.