Skip to main content

What are Domain Events?

Domain Events represent something significant that happened in your domain. They enable loose coupling between aggregates and support event-driven architectures.
import { DomainEvent, Id } from "@woltz/rich-domain";

class OrderConfirmedEvent extends DomainEvent {
  constructor(
    aggregateId: Id,
    public readonly customerId: string,
    public readonly total: number
  ) {
    super(aggregateId);
  }

  protected getPayload() {
    return {
      customerId: this.customerId,
      total: this.total,
    };
  }
}

Creating Events

Basic Event

class UserCreatedEvent extends DomainEvent {
  constructor(
    aggregateId: Id,
    public readonly email: string,
    public readonly name: string
  ) {
    super(aggregateId);
  }

  protected getPayload() {
    return {
      email: this.email,
      name: this.name,
    };
  }
}

Event Properties

Every domain event has:
PropertyTypeDescription
eventIdstringUnique identifier for this event occurrence
eventNamestringName of the event (class name by default)
aggregateIdstringID of the aggregate that raised the event
occurredOnDateWhen the event occurred
const event = new UserCreatedEvent(
  Id.from("user-123"),
  "john@example.com",
  "John Doe"
);

console.log(event.eventId); // "1699876543210-abc123def"
console.log(event.eventName); // "UserCreatedEvent"
console.log(event.aggregateId); // "user-123"
console.log(event.occurredOn); // 2024-01-15T10:30:00.000Z

Raising Events from Aggregates

Use addDomainEvent() inside aggregate methods:
class User extends Aggregate<UserProps> {
  static create(props: Omit<UserProps, "id">): User {
    const user = new User(props);

    user.addDomainEvent(new UserCreatedEvent(user.id, user.email, user.name));

    return user;
  }

  activate() {
    if (this.props.status === "active") return;

    this.props.status = "active";

    this.addDomainEvent(new UserActivatedEvent(this.id));
  }

  changeEmail(newEmail: string) {
    const oldEmail = this.props.email;
    this.props.email = newEmail;

    this.addDomainEvent(new UserEmailChangedEvent(this.id, oldEmail, newEmail));
  }
}

Event Bus

The DomainEventBus is a singleton that manages event subscriptions and publishing:
import { DomainEventBus } from "@woltz/rich-domain";

const eventBus = DomainEventBus.getInstance();

Subscribing to Events

Function Handler:
eventBus.subscribe({
  event: UserCreatedEvent,
  handler: async (event) => {
    console.log(`User created: ${event.email}`);
    await sendWelcomeEmail(event.email, event.name);
  },
});
Class Handler:
import { IDomainEventHandler } from "@woltz/rich-domain";

class SendWelcomeEmailHandler implements IDomainEventHandler<UserCreatedEvent> {
  async handle(event: UserCreatedEvent): Promise<void> {
    await emailService.sendWelcome(event.email, event.name);
  }
}

eventBus.subscribe({
  event: UserCreatedEvent,
  handler: new SendWelcomeEmailHandler(),
});
Subscribe by Event Name:
eventBus.subscribe({
  event: "UserCreatedEvent", // string instead of class
  handler: (event) => {
    console.log("User created:", event.aggregateId);
  },
});
Subscribe to All Events:
eventBus.subscribeAll((event) => {
  console.log(`Event occurred: ${event.eventName}`);
  // Useful for logging, analytics, etc.
});

Publishing Events

Dispatch from Aggregate:
const user = User.create({
  name: "John Doe",
  email: "john@example.com",
  status: "inactive",
});

// Save to database first
await userRepository.save(user);

// Then dispatch events
await user.dispatchAll(eventBus);

// Clear events after dispatching
user.clearEvents();
Publish Directly:
const event = new UserCreatedEvent(
  Id.from("user-123"),
  "john@example.com",
  "John Doe"
);

await eventBus.publish(event);
Publish Multiple Events:
const events = [
  new UserCreatedEvent(userId, email, name),
  new UserActivatedEvent(userId),
];

await eventBus.publishAll(events);

Event Management

Check for Uncommitted Events

const user = User.create({ ... });

console.log(user.hasUncommittedEvents()); // true

user.clearEvents();

console.log(user.hasUncommittedEvents()); // false

Get Uncommitted Events

const user = User.create({ ... });
user.activate();
user.changeEmail("new@example.com");

const events = user.getUncommittedEvents();
console.log(events.length); // 3
// [UserCreatedEvent, UserActivatedEvent, UserEmailChangedEvent]

Unsubscribe

const handler = (event: UserCreatedEvent) => {
  console.log(event);
};

eventBus.subscribe({ event: UserCreatedEvent, handler });

// Later...
eventBus.unsubscribe(UserCreatedEvent, handler);

Clear All Handlers (Testing)

beforeEach(() => {
  eventBus.clearAllHandlers();
});

Serialization

Events can be serialized to JSON for storage or messaging:
const event = new OrderConfirmedEvent(
  Id.from("order-123"),
  "customer-456",
  299.99
);

const json = event.toJSON();
// {
//   eventId: "1699876543210-abc123def",
//   eventName: "OrderConfirmedEvent",
//   aggregateId: "order-123",
//   occurredOn: "2024-01-15T10:30:00.000Z",
//   customerId: "customer-456",
//   total: 299.99
// }

Error Handling

Event handlers that throw errors don’t affect other handlers:
eventBus.subscribe({
  event: UserCreatedEvent,
  handler: () => {
    throw new Error("Handler 1 failed");
  },
});

eventBus.subscribe({
  event: UserCreatedEvent,
  handler: (event) => {
    // This still runs even if handler 1 fails
    console.log("Handler 2 executed");
  },
});

await eventBus.publish(event); // Both handlers are called
Errors are logged to console but don’t break the event flow. Consider adding proper error monitoring in production.

Complete Example

// Events
class OrderPlacedEvent extends DomainEvent {
  constructor(
    aggregateId: Id,
    public readonly customerId: string,
    public readonly items: Array<{ productId: string; quantity: number }>,
    public readonly total: number
  ) {
    super(aggregateId);
  }

  protected getPayload() {
    return {
      customerId: this.customerId,
      items: this.items,
      total: this.total,
    };
  }
}

class OrderShippedEvent extends DomainEvent {
  constructor(aggregateId: Id, public readonly trackingNumber: string) {
    super(aggregateId);
  }

  protected getPayload() {
    return { trackingNumber: this.trackingNumber };
  }
}

// Aggregate
class Order extends Aggregate<OrderProps> {
  static place(customerId: string, items: CartItem[]): Order {
    const order = new Order({
      customerId,
      status: "placed",
      items: items.map(
        (item) =>
          new OrderItem({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })
      ),
      createdAt: new Date(),
    });

    order.addDomainEvent(
      new OrderPlacedEvent(
        order.id,
        customerId,
        order.items.map((i) => ({
          productId: i.productId,
          quantity: i.quantity,
        })),
        order.total
      )
    );

    return order;
  }

  ship(trackingNumber: string) {
    this.props.status = "shipped";
    this.props.trackingNumber = trackingNumber;

    this.addDomainEvent(new OrderShippedEvent(this.id, trackingNumber));
  }
}

// Handlers
class NotifyCustomerHandler implements IDomainEventHandler<OrderPlacedEvent> {
  constructor(private emailService: EmailService) {}

  async handle(event: OrderPlacedEvent): Promise<void> {
    await this.emailService.sendOrderConfirmation(
      event.customerId,
      event.aggregateId,
      event.total
    );
  }
}

class UpdateInventoryHandler implements IDomainEventHandler<OrderPlacedEvent> {
  constructor(private inventoryService: InventoryService) {}

  async handle(event: OrderPlacedEvent): Promise<void> {
    for (const item of event.items) {
      await this.inventoryService.reserve(item.productId, item.quantity);
    }
  }
}

// Setup
const eventBus = DomainEventBus.getInstance();

eventBus.subscribe({
  event: OrderPlacedEvent,
  handler: new NotifyCustomerHandler(emailService),
});

eventBus.subscribe({
  event: OrderPlacedEvent,
  handler: new UpdateInventoryHandler(inventoryService),
});

// Usage
const order = Order.place("customer-123", cartItems);
await orderRepository.save(order);
await order.dispatchAll(eventBus);
order.clearEvents();