Domain Events vs Integration Events in .NET

Domain Events vs Integration Events in .NET

8 min read··

clean-architectureddddistributed-systemsdotnet

A domain event stays inside the bounded context that raised it and is handled in-process, usually within the same transaction. An integration event crosses service boundaries through a message broker and is consumed later, asynchronously. Blur that line and you get lost events, accidental coupling, and confirmation emails for orders that never committed.

An order gets placed. Inventory has to reserve stock right away, and a separate Notification service has to send a confirmation email eventually. Same trigger, two very different events.

Two Types of Events, Two Different Jobs

Events are central to building loosely coupled systems. But not all events are created equal.

In a well-designed .NET application, you'll typically work with two kinds:

  • Domain events - something happened within a bounded context
  • Integration events - something happened that other systems need to know about

Confusing the two leads to tight coupling, inconsistent data, and architectural headaches.

Let's clear this up.

What Are Domain Events?

A domain event represents something meaningful that happened in your domain. It's raised by an aggregate and handled within the same bounded context.

public interface IDomainEvent : INotification;

public sealed record OrderPlacedDomainEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount) : IDomainEvent;

IDomainEvent extends MediatR's INotification, so we can dispatch events with IMediator and handle them with INotificationHandler<T>. That couples the domain to MediatR's contracts. If you want a fully pure domain, you can adapt events with a generic wrapper instead; I cover that tradeoff in the Clean Architecture solution template.

When an order is placed, the Order aggregate raises this event:

public class Order : AggregateRoot
{
    public static Order Create(Customer customer, List<LineItem> items)
    {
        var order = new Order(customer.Id, items);

        order.RaiseDomainEvent(new OrderPlacedDomainEvent(
            order.Id,
            order.CustomerId,
            order.TotalAmount));

        return order;
    }
}

Domain events are typically:

  • In-process - they execute within the same application, same transaction
  • Synchronous (usually) - handlers run before SaveChanges completes or right after
  • Private - they stay inside the bounded context that raised them
  • Consistent - they maintain strong consistency with the operation that triggered them

What Domain Event Handlers Do

Domain event handlers react to what happened and execute side effects within the same context:

public class OrderPlacedDomainEventHandler : INotificationHandler<OrderPlacedDomainEvent>
{
    private readonly IInventoryService _inventoryService;

    public OrderPlacedDomainEventHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task Handle(
        OrderPlacedDomainEvent notification,
        CancellationToken cancellationToken)
    {
        await _inventoryService.ReserveStockAsync(notification.OrderId);
    }
}

Common uses for domain event handlers:

  • Updating related data within the same aggregate or module
  • Enforcing business rules that span multiple entities
  • Preparing data for projections or read models

For a full implementation guide, see my article on domain events in .NET.

What Are Integration Events?

An integration event represents something that happened that other bounded contexts or external systems need to react to.

public interface IIntegrationEvent;

public sealed record OrderPlacedIntegrationEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount,
    DateTime OccurredAt) : IIntegrationEvent;

Integration events are:

  • Published to a message broker (RabbitMQ, Azure Service Bus, Amazon SQS)
  • Asynchronous - consumers process them at their own pace
  • Public - they cross bounded context boundaries
  • Eventually consistent - there's a delay between publishing and consuming

Publishing Integration Events

You typically publish integration events after the domain transaction succeeds. This is where the Outbox pattern becomes critical - you need to guarantee that the event gets published even if the application crashes after saving.

public class PublishOrderPlacedIntegrationEventHandler
    : INotificationHandler<OrderPlacedDomainEvent>
{
    private readonly IOutboxWriter _outbox;

    public PublishOrderPlacedIntegrationEventHandler(IOutboxWriter outbox)
    {
        _outbox = outbox;
    }

    public async Task Handle(
        OrderPlacedDomainEvent notification,
        CancellationToken cancellationToken)
    {
        // Convert domain event to integration event
        var integrationEvent = new OrderPlacedIntegrationEvent(
            notification.OrderId,
            notification.CustomerId,
            notification.TotalAmount,
            DateTime.UtcNow);

        // Write to outbox (same transaction as domain changes)
        await _outbox.WriteAsync(integrationEvent, cancellationToken);
    }
}

MediatR runs every handler registered for a notification, so this handler executes alongside the inventory handler from earlier. One reacts inside the bounded context, the other hands the event off to the outside world.

A background process picks up outbox messages and publishes them to the message broker.

Key Differences

Here's how the two compare, aspect by aspect:

Domain eventsIntegration events
ScopeStay within a bounded contextCross bounded context boundaries
TransportIn-memory (MediatR or a custom dispatcher)Message broker (RabbitMQ, Azure Service Bus, SQS)
ConsistencyCan participate in the same transactionEventually consistent
Failure handlingSide effects roll back with the transactionNeed retries and idempotent consumers
SchemaInternal, can change freelyPublic contract that needs versioning
TimingHandled immediatelyProcessed with a delay, at the consumer's pace

The Flow: Domain Event → Integration Event

In a well-architected system, the flow looks like this:

  1. An aggregate performs a business operation
  2. The aggregate raises a domain event
  3. A domain event handler processes the event within the same transaction
  4. If other bounded contexts need to know, the handler writes an integration event to the outbox
  5. A background worker publishes the integration event to the message broker
  6. Consumers in other bounded contexts receive and process the event
The flow from a domain event to an integration event: an aggregate raises a domain event handled in-process within the same transaction, that handler writes an integration event to the outbox, a background worker publishes it to the message broker, and a consumer in another bounded context processes it

This two-step approach gives you the best of both worlds:

  • Strong consistency for domain-level side effects
  • Reliable asynchronous delivery for cross-boundary communication

Publishing Domain Events With EF Core

A common pattern is to dispatch domain events when SaveChanges is called. You can use an EF Core interceptor for this:

public class PublishDomainEventsInterceptor : SaveChangesInterceptor
{
    private readonly IMediator _mediator;

    public PublishDomainEventsInterceptor(IMediator mediator)
    {
        _mediator = mediator;
    }

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var dbContext = eventData.Context;

        if (dbContext is null)
        {
            return result;
        }

        var aggregates = dbContext.ChangeTracker
            .Entries<AggregateRoot>()
            .Select(entry => entry.Entity)
            .Where(aggregate => aggregate.DomainEvents.Count > 0)
            .ToList();

        var domainEvents = aggregates
            .SelectMany(aggregate => aggregate.DomainEvents)
            .ToList();

        foreach (var aggregate in aggregates)
        {
            aggregate.ClearDomainEvents();
        }

        foreach (var domainEvent in domainEvents)
        {
            await _mediator.Publish(domainEvent, cancellationToken);
        }

        return result;
    }
}

One important subtlety: this interceptor uses SavingChangesAsync, which runs before the save completes. Handlers execute inside the same transaction, so anything they add through the change tracker (like an outbox message) commits atomically with the domain changes. That's exactly what the outbox handler above needs. If your handlers only have side effects independent of the transaction, you can dispatch from SavedChangesAsync after the commit instead. Just don't write to the outbox from there; dispatching after the commit reintroduces the exact dual-write problem the outbox is supposed to solve.

I wrote a dedicated guide on building a custom domain events dispatcher if you want the full implementation.

Integration Event Contracts

Since integration events cross boundaries, their schema is a public contract. Treat them like an API:

  • Version them - don't break consumers when you change the event
  • Keep them minimal - only include what consumers need
  • Use primitive types - avoid domain-specific types that consumers would need to reference
  • Document them - consumers need to know what to expect
// ✅ Good: minimal, self-contained, uses primitives
public sealed record OrderPlacedIntegrationEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount,
    DateTime OccurredAt);

// ❌ Bad: leaks domain details, forces consumers to reference your domain assembly
public sealed record OrderPlacedIntegrationEvent(
    Order Order,
    Customer Customer,
    List<LineItem> LineItems);

Consuming Integration Events

Consumers in other bounded contexts subscribe to integration events through the message broker. Two critical patterns apply:

  1. Idempotent Consumer - handle the same message multiple times safely
  2. Inbox pattern - deduplicate incoming messages before processing

Here's what that looks like in a MassTransit consumer:

public class OrderPlacedIntegrationEventConsumer
    : IConsumer<OrderPlacedIntegrationEvent>
{
    private readonly IInboxStore _inbox;
    private readonly IEmailService _emailService;

    public OrderPlacedIntegrationEventConsumer(
        IInboxStore inbox,
        IEmailService emailService)
    {
        _inbox = inbox;
        _emailService = emailService;
    }

    public async Task Consume(ConsumeContext<OrderPlacedIntegrationEvent> context)
    {
        if (await _inbox.HasBeenProcessedAsync(context.MessageId))
        {
            return; // Already processed - skip
        }

        var message = context.Message;

        // Handle the event
        await _emailService.SendOrderConfirmationAsync(
            message.CustomerId,
            message.OrderId);

        await _inbox.MarkAsProcessedAsync(context.MessageId);
    }
}

When to Use Domain Events

Use domain events when:

  • Side effects must be consistent with the main operation
  • The handler lives in the same bounded context
  • You need immediate execution (same request lifecycle)
  • Example: reserving inventory when an order is placed

When to Use Integration Events

Use integration events when:

  • Another bounded context or service needs to react
  • Eventual consistency is acceptable
  • You need reliable delivery across process boundaries
  • Example: sending an email confirmation from the Notification service

Common Mistakes

1. Publishing integration events synchronously. Integration events should go through a message broker, not through direct HTTP calls. Direct calls create temporal coupling and fragile systems.

2. Putting too much data in integration events. Only include what consumers need. If a consumer needs more details, it can query the originating service.

3. Using domain events across bounded contexts. Domain events are internal. If another context needs the information, create an integration event with a separate schema.

4. Skipping the Outbox pattern. Without the Outbox, you risk losing events if the app crashes between saving to the database and publishing to the broker.

Summary

Domain events keep your bounded context consistent. Integration events keep your system loosely coupled.

The golden rule: domain events stay inside, integration events go outside.

Use domain events for immediate side effects within the same transaction. Use integration events for asynchronous communication across boundaries. And always use the Outbox pattern to guarantee delivery.

Thanks for reading, and stay awesome!


Frequently Asked Questions

What is the difference between a domain event and an integration event?

A domain event represents something that happened inside a bounded context and is handled in-process, usually within the same transaction. An integration event is published to a message broker so other bounded contexts or services can react to it asynchronously.

Can a domain event trigger an integration event?

Yes, that is the recommended flow. A domain event handler converts the domain event into an integration event and writes it to an outbox table in the same transaction. A background worker then publishes it to the message broker.

Why not publish integration events directly to the message broker?

Because the database save and the broker publish are two separate operations. If the application crashes between them, you lose the event. The outbox pattern stores the event in the database within the same transaction, guaranteeing it will eventually be published.

Should integration events contain full entity data?

No. Integration events are public contracts, so keep them minimal and use primitive types. Include only what consumers need; they can query the originating service for additional details.

Are domain events synchronous or asynchronous?

Typically synchronous and in-process. They are dispatched around the SaveChanges call, either just before saving (so handlers join the same transaction) or right after. Integration events are the asynchronous ones.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.