Domain-Driven Design in TypeScriptPart 4
Coordinate long-running work with domain events
Learn how domain events, a transactional outbox, and a process manager let a DDD workflow resume tenant onboarding after failures.
An aggregate protects rules inside one consistency boundary and one transaction. Enterprise onboarding crosses several boundaries and may take days, so no single aggregate can safely own the whole workflow. This is where domain events help the model state what happened and a process manager decides what should happen next.
In our running example, tenant onboarding reserves quota, creates regional data, registers entitlements, and may wait days for an identity administrator to provide production metadata. If that whole process pretends to be one function call, a crash can leave half the work complete with no reliable way to resume. A blind retry may reserve the same quota twice.
Three patterns give the process durable state. A domain event records a business fact that has already happened. A transactional outbox saves the intent to publish that fact in the same commit as the model change. A saga, also called a process manager, remembers what should happen next and how to respond when a later step fails.
These patterns come from related but distinct bodies of work. Domain events grew out of domain modeling; Martin Fowler describes one as the memory of something significant in the domain in his Domain Event write-up. The transactional outbox, documented by Chris Richardson, addresses the database-and-broker dual write. The process manager vocabulary used here comes from Gregor Hohpe and Bobby Woolf’s Enterprise Integration Patterns. Combining them is a design choice for this workflow, not a claim that every DDD model must use messaging or a saga.
We will use all three to make onboarding resumable. You will see the dual-write bug, replace it with an outbox transaction, and build a process manager that can continue after a crash without repeating completed work.
Events record facts while sagas coordinate long-running work
Open in StudioThe dual-write problem
This is the trap:
await tenantRepository.save(configuration);
await messageBus.publish(new TenantRegistrationInitiated(configuration.tenantId));
If the database write succeeds and the broker publish fails, the tenant exists but provisioning never starts. If the broker succeeds and the database rolls back, downstream systems react to a tenant that does not exist.
A dual write will fail eventually. Make the business change and its publish intent one transaction.
Transactional outbox
Save the aggregate and event in the same database transaction:
await db.transaction(async (tx) => {
await tenantConfigurationRepository.save(tx, configuration);
await outbox.save(tx, {
id: eventId(),
type: "tenant.registration_initiated.v1",
aggregateId: configuration.tenantId,
occurredAt: clock().toISOString(),
payload: toIntegrationEvent(new TenantRegistrationInitiated(configuration.tenantId)),
});
});
Then a separate dispatcher publishes unsent outbox rows:
const messages = await outbox.claimBatch({ limit: 100 });
for (const message of messages) {
await messageBus.publish(message.type, message.payload);
await outbox.markPublished(message.id);
}
The request transaction ends after it saves the aggregate and the outbox row; it does not wait for the broker or every downstream consumer. The dispatcher can therefore retry publication after a network failure without repeating the original business change. This separation keeps the request path short while preserving evidence that delivery is still owed.
Example onboarding flow
The happy path is a sequence of small, observable steps:
TenantRegistrationInitiatedSeedTenantDataPlane(us-workspace, us-east)ValidateIdentityMetadata(saml-metadata)RequestSeatAllocation(100 seats)OpenSecurityReviewTicketTenantOnboardingCompleted
The onboarding context does not do all of that work itself. It records the fact that registration started, then the process manager coordinates commands across Platform, IAM, Entitlements, and Security.
Domain events versus integration events
A domain event describes a fact in the model’s own language and may contain rich types that are useful inside the bounded context. An integration event is the stable, serialized contract another context receives. Separating them lets the domain model evolve without silently changing a public message schema. The domain event in this example is:
class TenantRegistrationInitiated {
constructor(
readonly tenantId: TenantId,
readonly organizationId: GlobalOrganizationId,
readonly occurredAt: Date,
) {}
}
The integration event is a versioned contract:
type TenantRegistrationInitiatedV1 = {
type: "tenant.registration_initiated.v1";
tenantId: string;
organizationId: string;
occurredAt: string;
};
Map between them at the boundary. That mapper turns branded identifiers and Date objects into
plain, versioned data and deliberately chooses which fields other contexts may depend on. Publishing
the TypeScript domain object directly would couple consumers to constructors, internal field names,
and changes that were never intended to be public.
Saga / process manager
The saga coordinates work across bounded contexts:
type OnboardingSagaState = {
tenantId: TenantId;
dataPlaneSeeded: boolean;
identityMetadataValidated: boolean;
entitlementsGranted: boolean;
securityReviewTicketId: string | null;
failedStep: string | null;
};
It listens to events and issues commands:
async function onTenantRegistrationInitiated(event: TenantRegistrationInitiatedV1) {
const saga = await sagaRepository.loadOrCreate(event.tenantId);
await commandBus.send({
type: "SeedTenantDataPlane",
tenantId: event.tenantId,
dataRegion: event.dataRegion,
correlationId: saga.correlationId,
});
}
If a step fails, the saga decides the compensating action:
async function onSeatAllocationRejected(event: SeatAllocationRejectedV1) {
const saga = await sagaRepository.load(event.correlationId);
await commandBus.send({
type: "RevokeDataPlaneAccess",
tenantId: saga.tenantId,
reason: "seat allocation rejected",
});
await sagaRepository.save(saga.blocked("entitlements", event.reason));
}
That makes this a process manager rather than an aggregate. An aggregate decides whether one model may change while preserving its own invariants. The process manager remembers progress across several owners and issues the next command, but it does not reimplement their rules. Entitlements still decides whether seats may be allocated, and Platform still decides whether a region can be provisioned.
Error modes and compensating actions
Workflows fail in different ways. Treating every failure as “retry later” is how systems end up with half-provisioned tenants.
| Step | Failure mode | Saga response | Compensation |
|---|---|---|---|
| Seed tenant data plane | regional cluster unavailable | retry with backoff | none until access is granted |
| Seed tenant data plane | requested region not approved | block onboarding for platform review | none |
| Validate federation metadata | signing certificate invalid | block onboarding for security and client IT review | none |
| Request quota allocation | agreement allows fewer seats | block for commercial review or reduce the request | revoke data-plane access if it was already granted |
| Open security ticket | ticketing API unavailable | retry and keep onboarding in review-pending state | none |
| Mark onboarding complete | stale saga state | reload state and re-evaluate completion | do not emit completed event |
Compensation should undo external side effects, not rewrite history. If tenant seed rows were created and no user can access them, the system may keep them for retry. If data-plane access was granted before Entitlements rejected the seat allocation, the saga must revoke access and leave an audit trail.
Test the saga decision
Do not merely test that the handler ran. Test the business decision it made after partial failure.
it("blocks onboarding and revokes partition access when quota is rejected", async () => {
const parsedTenantId = tenantId("ten_ops_us");
const saga = onboardingSaga({
tenantId: parsedTenantId,
dataPlaneSeeded: true,
});
sagaRepository.load.mockResolvedValue(saga);
await onSeatAllocationRejected({
type: "entitlements.seat_allocation_rejected.v1",
tenantId: parsedTenantId,
correlationId: saga.correlationId,
reason: "requested 500 seats but contract allows 100",
});
expect(commandBus.send).toHaveBeenCalledWith({
type: "RevokeDataPlaneAccess",
tenantId: parsedTenantId,
reason: "seat allocation rejected",
});
expect(sagaRepository.save).toHaveBeenCalledWith(expect.objectContaining({ status: "blocked" }));
});
That test gives the team confidence that a partially seeded tenant does not quietly become active after a commercial rejection.
Apply the pattern to one workflow
These patterns cover separate failure windows. The outbox prevents a committed model change from losing its publication intent. The saga persists which cross-context step should happen next after the original request is gone. A compensating action describes how to counter an external effect that already committed when a later step makes the original plan impossible.
If onboarding can die between two side effects, write the fact and the outbox row in the same transaction, resume from saga state rather than from the request, and name the compensating command before you turn retries on.
Start with one workflow and draw every database commit and external side effect in order. Wherever a state change creates follow-up work, put its publish intent in the same transaction. Wherever the process crosses transactions, persist enough saga state to choose the next action after a restart. For each external effect, decide whether retry is safe and what compensation means before enabling automatic retries. That exercise reveals where the design still depends on one uninterrupted request.