Every time a traveler confirms a booking, your code sends a confirmation email, creates an invoice in accounting, pings the trip organizer on Slack, and tracks an analytics event. Four side effects live in one function.
When product asks for a fifth, you open confirmBooking() and wedge it in between the email and the invoice.
Domain events are the standard fix. The core announces a business fact, BookingConfirmed, and separate handlers react to it.
Every tutorial shows that part with a typed event bus, then stops right before the three things that bite in production: handler errors that disappear, events that die with the process, and latency that never left your request.
Share this post & I’ll send you some rewards for the referrals.
Build for the long run (Partner)
Long-running agents need endurance.
They must endure waiting for users, and weather tool failures. They must take each new jump in model intelligence in stride.
Inngest makes long-running agents durable, observable and improvable over time.
Check out their generous free tier and a sleek local dev server for testing your workflows.
(Thanks to Inngest for partnering on this post.)
Events turn four side effects into one call
Here's the before, which is most Node.js codebases:
async function confirmBooking(bookingId: string): Promise<Booking> {
const booking = await bookings.markConfirmed(bookingId);
await email.sendConfirmation(booking);
await accounting.createInvoice(booking);
await slack.notifyOrganizer(booking);
await analytics.track('booking_confirmed', { bookingId });
return booking;
}confirmBooking knows four services that have nothing to do with confirming a booking. Testing it means mocking all four. A Slack outage fails the confirmation, which is a silly reason to lose a booking.
A domain event is a record of something that already happened, named in the past tense: BookingConfirmed, PaymentFailed, TripCancelled. The core publishes it and moves on. Each side effect becomes a handler in its own file, wired once at startup:
async function confirmBooking(bookingId: string, bus: EventBus): Promise<Booking> {
const booking = await bookings.markConfirmed(bookingId);
await bus.emit({
type: 'BookingConfirmed',
eventId: randomUUID(),
occurredAt: new Date(),
payload: { bookingId: booking.id, travelerEmail: booking.travelerEmail },
});
return booking;
}
// composition-root.ts
bus.on('BookingConfirmed', sendConfirmationEmail(services.email));
bus.on('BookingConfirmed', createInvoice(services.accounting));
bus.on('BookingConfirmed', notifyOrganizer(services.slack));
bus.on('BookingConfirmed', trackConfirmation(services.analytics));The fifth side effect is now one new file and one new line, and testing confirmBooking needs zero service mocks. Two details in the event pay off later. The eventId lets a handler tell a retry from a new event. The payload carries IDs and plain facts instead of the ORM entity, so a handler can't mutate the booking.
Every tutorial covers this much. The first thing they skip hides inside the bus itself.
Skip #1: Promise.allSettled hides failed handlers
Node's built-in EventEmitter looks like enough, and since mid-2024 @types/node even types its event names and payloads. Its emit() is the problem. It's synchronous and never awaits an async handler. A rejected handler becomes an unhandled rejection, and since Node 15 that crashes the process by default. A Slack outage takes your whole API down with it.
You want a bus that awaits its handlers and decides what a failure means. It's about 25 lines:
type DomainEvent = BookingConfirmed | TripCancelled;
type EventOfType<T extends DomainEvent['type']> = Extract<DomainEvent, { type: T }>;
type Handler<E> = (event: E) => Promise<void>;
class EventBus {
private handlers = new Map<string, Handler<any>[]>();
on<T extends DomainEvent['type']>(type: T, handler: Handler<EventOfType<T>>): void {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
}
async emit(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.type) ?? [];
const results = await Promise.allSettled(
handlers.map(async (handle) => handle(event)), // a sync throw becomes a rejection too
);
results.forEach((result, i) => {
if (result.status === 'rejected') {
logger.error(
{ eventType: event.type, eventId: event.eventId, handler: handlers[i].name, err: result.reason },
'event handler failed',
);
}
});
}
}allSettled is the right call, and it's also the trap. One failing handler shouldn't stop the others or fail the booking, so Promise.all is out. But allSettled never throws. A rejected handler vanishes unless you walk the results, with no log line, no alert, and no stack trace. Your invoice handler can fail for a week while confirmBooking returns 200, and finance finds out at month-end close.
The logging loop is the difference between degraded-and-visible and broken-and-silent, so put a metric and an alert on it too. Write handlers as named functions (return async function createInvoice(event) {…}) so the log says which one broke. The async wrapper inside map matters too. Without it, a plain function that throws before returning a promise blows up map, and emit itself rejects. Handlers also finish in any order, so two steps that must run in sequence belong in one handler.
Skip #2: the crash window between commit and handlers
The booking is saved, then the event goes out. Two failures live in that gap.
An event can fire before the data is real. Say confirming also reserves seats on the trip, inside one transaction. The event fires mid-transaction, the trip turns out to be full, and the whole transaction rolls back. Accounting now holds an invoice for a booking your database says was never confirmed.
The fix is to collect events during the transaction and emit them only after it commits:
const pending: DomainEvent[] = [];
await db.transaction(async (trx) => {
const booking = await bookings.markConfirmed(bookingId, trx);
await trips.reserveSeats(booking.tripId, booking.travelerCount, trx); // throws when full
pending.push(bookingConfirmed(booking)); // builds the same event as before
});
// committed, so the facts are real now
for (const event of pending) await bus.emit(event);A rolled-back transaction never reaches the loop, so nothing gets announced. DDD folks know this shape as the aggregate recording events and the unit of work dispatching them after save.
The process can die right after the commit. A deploy or an OOM kill is enough. The booking is confirmed, and the email, the invoice, and the Slack ping never happen. Nothing retries, because nothing knows. No in-process pattern fixes this, since the event lived only in the memory of the process that died. A graceful shutdown that drains in-flight handlers makes the window smaller, but it can't help with a SIGKILL.
A side effect that must survive a crash has to be saved in the same transaction as the data. The transactional outbox does exactly that. You commit an outbox row together with the booking, and a separate process relays it out. The outbox post covers it end to end. A job queue that lives in Postgres gets you close too. graphile-worker's add_job is a SQL call, so a job queued inside the booking's transaction commits or rolls back with it.
Until then, treat in-process handlers as best-effort and keep each one safe to rerun, which is what the eventId is for (the idempotency post covers how).
Skip #3: events decouple your code, and your runtime didn't notice
await bus.emit(...) waits for every handler. A two-second Slack call makes confirming a booking take two seconds, and a hung one hangs the request, so every outbound call in a handler needs a timeout (the resilience patterns post covers those). It's the same process and the same event loop. What you decoupled is where the code lives, who owns it, and how you test it. That's worth a lot, and it's less than most people think they're buying.
void bus.emit(event) stops awaiting and returns the response right away, and the bus's own logging still catches failures. The latency left the request, but the crash window from skip #2 got wider, because every deploy now kills whatever handlers were mid-flight. On serverless it's worse, since the platform can freeze your function once the response is sent and a fire-and-forget handler may never finish.
In-process events give you decoupled code with a shared fate. Independent retries, delivery guarantees, and separate scaling show up only when the event leaves the process through an outbox, a broker, or a durable job queue. That's also the line between a domain event, which stays inside one service, and an integration event, which other services consume and whose shape becomes a contract you version. An in-process bus hasn't crossed that line for you.
Use events when one fact fans out
Reach for domain events when one business fact triggers several independent side effects, the list keeps growing, and a side-effect failure must not fail the operation. Skip them when the side effect is the operation's actual job. Reserving seats is part of confirming a booking, so it belongs inside the transaction. Skip them too when the app is ten endpoints and the indirection costs more than it saves.
Domain events are still my favorite first move when a function starts collecting side effects. Go in knowing what the bus does for you, and exactly where it stops.
📌 TL;DR
Domain events move side effects out of your core function. Each one becomes a handler of
BookingConfirmed.Don't use a plain
EventEmitterfor async handlers. A rejected handler becomes an unhandled rejection, which crashes Node 15+ by default.Skip #1: log what
Promise.allSettledreturns. It never throws, so a failed handler disappears while the request keeps returning 200.Skip #2: emit after commit. That kills phantom events. Surviving a crash after the commit takes a transactional outbox or a job queued in the same transaction.
Skip #3: events decouple your code, not your runtime. Awaited handlers add their latency. Fire-and-forget widens the crash window, and on serverless it may never run.
Use events when one fact fans out into a growing list of side effects. Skip them when the "side effect" is the operation's real job.

Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏

Press the Like button if you found this post helpful




