Domain events vs integration events
When you're building a microservice that handles order processing, you might emit an event when a payment succeeds—but should that event stay inside your service or broadcast to the entire system? The distinction between domain events and integration events is one of the most practical architectural decisions you'll make in NestJS, because getting it wrong leads to tight coupling, event storms, or silent failures that only surface in production.
Why this distinction matters
Early in my career, I treated all events the same way: anything important got thrown onto a message broker. That seemed clean—every service subscribed to what it needed. But it created a hidden dependency graph that nobody fully understood, made debugging nightmare-level difficult, and meant that a single domain rule change had ripple effects across three services and two teams.
The real insight is that events live at two different layers of your application, and conflating them creates friction. A domain event models something that happened inside your business logic—it's part of your ubiquitous language and only meaningful to your service. An integration event is the published contract that other services depend on. Keeping them separate gives you flexibility, testability, and explicit control over what leaks out of your service boundary.
Domain events: inside a service, in-process
A domain event captures a meaningful occurrence within a single service's domain. It's raised when an entity changes state in a way that matters to your business rules. These events stay in-process—they live and die within your NestJS application, dispatched through a simple event emitter or event bus.
In an order service, when an order is confirmed, you might raise OrderConfirmedDomainEvent. This event triggers internal handlers: update inventory, calculate shipping, send a confirmation email. All of that happens synchronously or through a local message queue, never touching an external broker.
export class OrderConfirmedDomainEvent implements DomainEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly occurredAt: Date = new Date(),
) {}
}
@Injectable()
export class ConfirmOrderService {
constructor(
private readonly eventPublisher: EventPublisher,
private readonly orderRepository: OrderRepository,
) {}
async execute(command: ConfirmOrderCommand): Promise<void> {
const order = await this.orderRepository.findById(command.orderId);
order.confirm();
// Domain event raised, stays in-process
this.eventPublisher.publish(
new OrderConfirmedDomainEvent(
order.id,
order.customerId,
order.totalAmount,
),
);
await this.orderRepository.save(order);
}
}
@Injectable()
export class SendOrderConfirmationEmailHandler implements EventHandler {
@EventPattern(OrderConfirmedDomainEvent)
handle(event: OrderConfirmedDomainEvent): Promise<void> {
// Triggered immediately, in-process
return this.emailService.sendOrderConfirmation(event.customerId);
}
}
The key characteristics of domain events: they're synchronous by default, they're tightly coupled to your domain model, you can test them without any infrastructure, and they're never versioned because they're internal implementation details.
Integration events: across services, via a broker
An integration event is published when you need another service to know about something that happened. It crosses service boundaries and travels over a message broker—Kafka, RabbitMQ, AWS SNS/SQS, whatever you've chosen. This is your contract with the outside world.
The critical difference: not every domain event becomes an integration event. You decide which events matter externally. In our order service, maybe OrderConfirmedDomainEvent is internal, but you publish OrderCreatedIntegrationEvent to let the fulfillment service and analytics pipeline know about new orders. The fulfillment service doesn't care about inventory updates or email sends—those are your concern.
export class OrderCreatedIntegrationEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: Array<{ sku: string; quantity: number }>,
public readonly shippingAddress: Address,
public readonly version: number = 1,
public readonly occurredAt: Date = new Date(),
) {}
}
@Injectable()
export class CreateOrderService {
constructor(
private readonly eventBus: EventBus,
private readonly orderRepository: OrderRepository,
) {}
async execute(command: CreateOrderCommand): Promise<string> {
const order = new Order(command);
await this.orderRepository.save(order);
// Integration event published to broker
this.eventBus.publish(
new OrderCreatedIntegrationEvent(
order.id,
order.customerId,
order.items,
order.shippingAddress,
),
);
return order.id;
}
}
// In NestJS with Kafka
@Injectable()
export class OrderIntegrationEventPublisher {
constructor(private readonly kafkaClient: ClientKafka) {}
async publish(event: OrderCreatedIntegrationEvent): Promise<void> {
await this.kafkaClient.emit('orders.created', {
orderId: event.orderId,
customerId: event.customerId,
items: event.items,
shippingAddress: event.shippingAddress,
version: event.version,
occurredAt: event.occurredAt.toISOString(),
});
}
}
Integration events are the ones you monitor, version, document, and commit to. Other teams build logic on top of them. They go through your event broker and might take minutes to propagate. They're asynchronous by nature.
Translate domain events into integration events
Here's where the architecture gets interesting: the translation layer. You raise domain events for internal consistency and clarity. Then, deliberately and explicitly, you translate some of them into integration events that leave your service.
This translation step is your firewall. It lets you decouple your internal rules from your public API. If you refactor your order confirmation logic and decide to raise five domain events instead of one, external services don't break. If another team wants to subscribe to order status changes in a new way, you add a new integration event without changing your domain.
@Injectable()
export class OrderDomainEventTranslator {
constructor(private readonly eventBus: EventBus) {}
@EventPattern(OrderConfirmedDomainEvent)
async onOrderConfirmed(event: OrderConfirmedDomainEvent): Promise<void> {
// Translate internal domain event to external integration event
const integrationEvent = new OrderConfirmedIntegrationEvent(
event.orderId,
event.customerId,
event.totalAmount,
1,
event.occurredAt,
);
// Publish to broker; other services will eventually see it
await this.eventBus.publish(integrationEvent);
}
}
export class OrderConfirmedIntegrationEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly version: number = 1,
public readonly occurredAt: Date = new Date(),
) {}
}
The translator sits in the middle: it listens for domain events (which it receives fast, in-process) and publishes corresponding integration events to the broker. This pattern gives you a single place to think about what your service promises to the outside world.
Version integration events like public APIs
Integration events are public contracts. If you change them without thinking about backward compatibility, you'll break subscribers. The safest approach is to treat them exactly like REST API versions.
When you need to change an integration event—add a field, rename something, change a type—you publish a new version and support the old one for a deprecation period. Subscribers gradually migrate. This is friction, yes, but it's better than surprise failures in production.
// Version 1: original structure
export class OrderShippedIntegrationEventV1 {
constructor(
public readonly orderId: string,
public readonly trackingNumber: string,
public readonly version: number = 1,
public readonly occurredAt: Date = new Date(),
) {}
}
// Version 2: added carrier information
export class OrderShippedIntegrationEventV2 {
constructor(
public readonly orderId: string,
public readonly trackingNumber: string,
public readonly carrier: string, // new field
public readonly version: number = 2,
public readonly occurredAt: Date = new Date(),
) {}
}
@Injectable()
export class PublishOrderShippedEventService {
constructor(private readonly eventBus: EventBus) {}
async execute(order: Order): Promise<void> {
// Publish both versions during transition period
await this.eventBus.publish(
new OrderShippedIntegrationEventV1(order.id, order.trackingNumber),
);
await this.eventBus.publish(
new OrderShippedIntegrationEventV2(
order.id,
order.trackingNumber,
order.carrier,
),
);
}
}
// Subscriber handles both versions
@Controller()
export class FulfillmentNotificationController {
@MessagePattern('orders.shipped')
async handleOrderShipped(
event: OrderShippedIntegrationEventV1 | OrderShippedIntegrationEventV2,
): Promise<void> {
if (event.version === 1) {
// Handle V1 without carrier
await this.notifyFulfillmentCenter(event.orderId);
} else {
// Handle V2 with carrier
await this.notifyFulfillmentCenterWithCarrier(event.orderId, event.carrier);
}
}
}
Versioning at the message level—including it in the event class—makes routing and handling explicit. Subscribers can reject versions they don't understand, and you can monitor adoption as you deprecate old versions.
Common mistakes
- Publishing every domain event as an integration event. This floods your message broker, makes contracts unclear, and couples internal implementation to external services. Be selective. Ask: does another service absolutely need to know about this?
- Skipping the translation layer. If you publish domain events directly to the broker, your internal refactoring breaks external subscribers. Always translate explicitly.
- Changing integration events without versioning. Your subscribers aren't in the same deployment cycle as you. Assume they can't update instantly. Version early, deprecate gradually.
- Treating domain events as asynchronous. They should be in-process and synchronous by default. If you need async, you're probably looking at an integration event.
- Not documenting which integration events exist. After six months, you'll have no idea which services depend on which events. Keep a living document or schema registry.
Key takeaways
- Domain events model state changes inside your service; integration events are the public contracts you publish to other services.
- Domain events stay in-process and synchronous; integration events travel via a message broker asynchronously.
- Use a translator to deliberately decide which domain events become integration events—this is your firewall between internal and external.
- Version integration events like REST API endpoints: add fields, support multiple versions during transitions, deprecate old versions explicitly.
- Over-publishing domain events creates tight coupling and operational noise; be selective about what leaves your service.






Comments
Loading comments…