What Is A Domain Event

Domain Events Fire From Domain Logic. Not Repositories. Not Anywhere Else.

Domain events fire when something meaningful happens in your system. Not when a row gets inserted. When the business cares.

A user didn't get inserted into a table. A user registered. An order wasn't updated with a status field. An order was cancelled. The difference matters.

This is why you never fire events from a repository. A repository doesn't know what happened. It knows data moved. That's not the same thing.

A repository handles persistence. It writes rows, reads rows, maybe does some query optimization if you're lucky. It has no idea why it was asked to save something. It shouldn't. That's not its job.

The domain logic layer knows why. Your service knows a user just completed registration. Your command handler knows an order was cancelled due to fraud. Your listener knows a payment succeeded after three retries. These layers understand intent. They understand what the business would call this moment.

Fire events there.


The Wrong Way

class OrderRepository {
  async save(order: Order) {
    await this.db.orders.insert(order);
    this.eventEmitter.emit('order.created', order);
  }
}

This repository has no idea if this order was placed by a customer, imported from a legacy migration, created as a test fixture, or duplicated due to a bug. It just knows it saved something. Firing an event here is a lie waiting to cause problems.


The Right Way

class PlaceOrderHandler {
  constructor(
    private orderRepository: OrderRepository,
    private eventEmitter: EventEmitter2
  ) {}

  async execute(command: PlaceOrderCommand) {
    const order = Order.create(command.customerId, command.items);
    await this.orderRepository.save(order);

    this.eventEmitter.emit(
      OrderPlacedEvent.eventName,
      new OrderPlacedEvent(order.id, order.customerId, order.total)
    );
  }
}

The command handler knows exactly what happened. A customer placed an order. That's a domain event. Fire it there.


Define Events With Intent

Your event class should describe what happened in business terms. Keep it simple. Keep it immutable.

export class OrderPlacedEvent {
  static readonly eventName = 'order.placed';

  constructor(
    public readonly orderId: string,
    public readonly customerId: string,
    public readonly total: number
  ) {}
}

export class OrderCancelledEvent {
  static readonly eventName = 'order.cancelled';

  constructor(
    public readonly orderId: string,
    public readonly reason: string,
    public readonly cancelledBy: string
  ) {}
}

export class UserRegisteredEvent {
  static readonly eventName = 'user.registered';

  constructor(
    public readonly userId: string,
    public readonly email: string
  ) {}
}

One File. All Events. All Listeners.

This is where modular design earns its keep. One file defines every event and every listener in your module. Want to know what happens when an order is placed? Look here. Want to add a new listener? Add it here.

No grep. No séance. No tracing through decorators scattered across forty files.

import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';

import { OrderPlacedEvent } from './events/order-placed.event';
import { OrderCancelledEvent } from './events/order-cancelled.event';
import { UserRegisteredEvent } from './events/user-registered.event';

import { SendOrderConfirmationListener } from './listeners/send-order-confirmation.listener';
import { UpdateInventoryListener } from './listeners/update-inventory.listener';
import { NotifyWarehouseListener } from './listeners/notify-warehouse.listener';
import { ProcessRefundListener } from './listeners/process-refund.listener';
import { SendWelcomeEmailListener } from './listeners/send-welcome-email.listener';

export const EventListenerMap = {
  [OrderPlacedEvent.eventName]: [
    SendOrderConfirmationListener,
    UpdateInventoryListener,
    NotifyWarehouseListener,
  ],
  [OrderCancelledEvent.eventName]: [
    ProcessRefundListener,
  ],
  [UserRegisteredEvent.eventName]: [
    SendWelcomeEmailListener,
  ],
};

@Module({
  imports: [EventEmitterModule.forRoot()],
  providers: [
    ...Object.values(EventListenerMap).flat(),
  ],
})
export class EventsModule {}

Listeners Do One Thing

Each listener handles one reaction to an event. Keep them focused. Keep them testable.

import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderPlacedEvent } from '../events/order-placed.event';

@Injectable()
export class SendOrderConfirmationListener {
  constructor(private mailer: MailerService) {}

  @OnEvent(OrderPlacedEvent.eventName)
  async handle(event: OrderPlacedEvent) {
    await this.mailer.send({
      to: event.customerId,
      template: 'order-confirmation',
      data: { orderId: event.orderId, total: event.total },
    });
  }
}
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderPlacedEvent } from '../events/order-placed.event';

@Injectable()
export class UpdateInventoryListener {
  constructor(private inventoryService: InventoryService) {}

  @OnEvent(OrderPlacedEvent.eventName)
  async handle(event: OrderPlacedEvent) {
    await this.inventoryService.decrementStock(event.orderId);
  }
}

Events Without Listeners Are Fine

Someone will push back. "Why fire an event if nothing is listening?"

Because the domain event happened. Whether something reacts today is irrelevant. You're not adding bloat. You're adding extension points.

export const EventListenerMap = {
  [OrderPlacedEvent.eventName]: [
    SendOrderConfirmationListener,
    UpdateInventoryListener,
  ],
  [OrderRefundedEvent.eventName]: [
    // Nothing yet. And that's fine.
  ],
};

That empty array isn't a code smell. It's a statement of intent. When finance needs a webhook six months from now, you add one line. The hook is already there. The event already fires. You're not retrofitting. You're extending.


The Payoff

Domain events fired from domain logic. One file mapping events to listeners. Listeners that do one thing each.

You want to know what happens when a user registers? One file. You want to add behavior when an order is cancelled? One file. You want to understand the system six months from now when you've forgotten everything? One file.

That's not chaos. That's a system that respects future maintainers.

Including you.

❤️
Jake