Outbox Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
outbox-wiringrecipe 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/outbox—OutboxStore(where rows live),OutboxPublisher(where dispatched messages go),OutboxDispatcher(the worker that drains pending → published),OutboxModule. You injectOutboxStoreto write entries andOutboxDispatcher.dispatchPending(limit?)to drain them.OutboxEntry = { topic, payload }; the store assignsid,createdAt, and tracksattempts/lastError.- Store driver —
outbox-memory(test/dev),outbox-database-typeorm(usesDatabaseTypeormModule; registers anOutboxEntityviaDatabaseEntitiesPool),outbox-database-prisma(usesDatabasePrismaModule). - Publisher driver —
outbox-events(dispatches each row as aDomainEventover theEventBus). One driver bound at a time viapreferences. - Dispatcher trigger —
outbox-schedulerregisters aScheduledJobsPoolentry that callsdispatcher.dispatchPending()on a cron interval. Without it the dispatcher only runs when you call it manually.
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 markDead — unset 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.