@nestjs-transactional/outbox
v2.0.0
Published
Persistent Event Publication Registry for @nestjs-transactional — Spring Modulith equivalent (ORM-agnostic core)
Maintainers
Readme
@nestjs-transactional/outbox
The transactional outbox for NestJS — a persistent Event Publication Registry, modelled on Spring Modulith's.
In-process event handlers have one failure mode you cannot design around: if the process dies between the commit and the handler, the event is gone. This package writes one row per handler per event, in the same transaction as your business data. A worker picks the rows up afterwards, retries what fails, and resumes what a restart interrupted.
Either both happen or neither does. That is the whole point.
This package is storage-agnostic — it ships the SPI, the worker, the
operator APIs and an in-memory implementation for tests, but no
production backend. Add
@nestjs-transactional/outbox-typeorm
for that. It builds on
@nestjs-transactional/core,
integrates with
@nestjs-transactional/cqrs,
and can forward events to a broker through
@nestjs-transactional/outbox-microservices.
Install
pnpm add @nestjs-transactional/outbox @nestjs-transactional/core
pnpm add @nestjs-transactional/outbox-typeorm # a persistence backendModule format
This package ships ESM only, matching NestJS 12, which is ESM-only across its own packages. There is no CommonJS build.
A CommonJS application still works: Node loads ESM from require()
since 22.12.0, which is why engines.node is >=22.13.0. What does not
follow Node here is tooling with its own module loader — Jest above all,
which needs NODE_OPTIONS=--experimental-vm-modules and a few config
settings. The 19 example applications in the repository all run their
suites that way and can be copied from.
Reasoning and measurements: ADR-022.
Quick start
import { Module } from '@nestjs/common';
import { TransactionalModule } from '@nestjs-transactional/core';
import { OutboxModule, OutboxProcessingModule } from '@nestjs-transactional/outbox';
import { typeOrmEventPublicationRepositoryProvider } from '@nestjs-transactional/outbox-typeorm';
@Module({
imports: [
// Must be global: outbox providers resolve TransactionManager
// across module boundaries.
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot(),
OutboxTypeOrmModule.forRoot(),
OutboxModule.forRoot({
// Without this, the in-memory repository stays installed and
// nothing is ever persisted. See the note below.
repository: typeOrmEventPublicationRepositoryProvider(),
republishOnStartup: true,
}),
// Event classes this module owns. Feature modules normally call
// forFeature for their own events.
OutboxModule.forFeature([OrderPlacedEvent]),
// ONLY in worker processes. An API that merely publishes events
// must not import this.
OutboxProcessingModule,
],
})
export class AppModule {}Pass
repository.OutboxModule.forRoot()falls back toInMemoryEventPublicationRepositorywhen the option is missing. Your application starts, publishes, and handles events perfectly — and loses every one of them on restart, with nothing in the database and no error anywhere.
Declare a handler:
@Injectable()
@OutboxEventsHandler(OrderPlacedEvent)
export class ChargeCustomer implements IOutboxEventHandler<OrderPlacedEvent> {
async handle(event: OrderPlacedEvent) {
// Invoked by the worker, in its own transaction. Throwing marks the
// publication FAILED, leaving it for retry or an operator.
await this.payments.charge(event.orderId);
}
}Publish from inside a transaction:
@Transactional()
async placeOrder(dto: PlaceOrderDto) {
const order = await this.orders.save(dto);
await this.publisher.publish(new OrderPlacedEvent(order.id));
return order; // publication rows commit with the order, or not at all
}Lifecycle
A publication moves through five states:
PUBLISHED → PROCESSING → COMPLETED, or FAILED → RESUBMITTED
back to PROCESSING.
The worker polls for ready rows and then claims each one with a
single conditional UPDATE. That claim, not the poll, is what makes
concurrent workers safe: two workers may fetch the same row, but only
one wins the claim and the loser moves on without invoking the handler.
So scaling out costs a wasted read, never a duplicate dispatch
(DD-025).
Completed rows can be kept (UPDATE, the default), deleted, or moved to
an archive table — completionMode. Kept rows need purging eventually;
CompletedEventPublications.purge(olderThan) does it.
Operating it
OutboxModule.forRoot({
repository: typeOrmEventPublicationRepositoryProvider(),
republishOnStartup: true,
processor: {
pollingInterval: 1000, // end-to-end latency is dominated by this
batchSize: 100,
maxConcurrent: 10,
// How long shutdown waits for an in-flight batch. Keep it under
// your platform's grace period; 0 disables the wait.
shutdownTimeout: 10_000,
},
// Flip publications stuck in a non-terminal state to FAILED. 0 = off.
staleness: { processing: 60_000, monitorInterval: 30_000 },
// Automatic retry, off unless you ask for it. maxAttempts counts the
// first delivery, so 3 means the original plus two retries.
retry: { maxAttempts: 3, baseDelay: 1_000, factor: 2, maxDelay: 300_000 },
// Retention for COMPLETED publications, also off unless asked for.
// 0 = off, which leaves purging to you.
cleanup: { interval: 3_600_000, retention: 7 * 24 * 3_600_000, batchSize: 500 },
});Shutdown is drained, not cut off: OutboxProcessingModule awaits the
batch already running before NestJS tears down the DataSource, so a
publication is not stranded mid-transition.
Retry is opt-in because Spring Modulith has none either — recovery is
otherwise an operator action. A publication that exhausts its attempts
stays FAILED; there is no separate dead-letter state, and it remains
visible and resubmittable
(DD-026).
Under the default UPDATE completion mode a delivered publication
stays as an audit row and nothing removes it, so the table grows until
someone purges it. cleanup puts that purge on a timer: each pass takes
up to batchSize publications whose completionDate is older than
retention and deletes them. The bound matters, because the first pass
after enabling this on an existing deployment meets the table at its
largest, and one unbounded DELETE there is a long transaction holding
locks your writers need. Passes drain from the oldest end, so a job that
cannot keep up still shrinks the backlog rather than stranding its tail.
completed.purge(olderThan) is unchanged and remains the right tool for
a deliberate one-shot bulk purge.
Three injectable APIs for operators, matching Spring Modulith's:
await failed.findAll({ minAge: 60_000 }); // triage
await failed.resubmit({ maxAttempts: 5 }); // retry by hand
await incomplete.count(); // anything not COMPLETED
await completed.purge(olderThan); // retentionListener ids
Each publication row is keyed by ${baseId}#${EventName}, where
baseId defaults to the handler's class name. Renaming the class
therefore orphans any stored publication for it. Pass an explicit id
when that is a risk:
@OutboxEventsHandler({ events: [OrderPlacedEvent], id: 'Billing.charge' })Testing
The /testing subpath ships an in-memory repository and fluent
assertions, so outbox behaviour is testable without a database:
import {
AssertablePublishedEvents,
InMemoryEventPublicationRepository,
} from '@nestjs-transactional/outbox/testing';
(await assertable.contains(OrderPlacedEvent))
.matching((e) => e.orderId, 'o-1')
.hasSize(1);
// The negative path matters just as much: after a rollback, the
// in-memory repository has removed the publication.
await assertable.doesNotContain(OrderCancelledEvent);Sending events to a broker
@Externalized marks an event for forwarding after its local handlers
complete. The EVENT_EXTERNALIZER SPI is transport-agnostic;
outbox-microservices implements it over @nestjs/microservices.
A publication is marked COMPLETED when the externalizer resolves,
and what that proves depends on the transport. Kafka and RabbitMQ wait
for a broker acknowledgement, so a broker that is down produces a
FAILED row the retry and resubmit machinery can act on. NATS core and
TCP acknowledge nothing, and gRPC cannot be used for externalization at
all. The per-transport table is in that package's README and in
ADR-021.
Documentation
- Getting started and full docs
- Architecture: the outbox pattern
- Migrating to the outbox
- One
forRootper dataSource (ADR-019) - Runnable examples:
basic-outbox,saga-pattern,audit-logging,graceful-shutdown
License
MIT
