Domain events and integration events aren't the same thing

ยท 2 min read

Both describe something that happened, but one stays inside your service and the other is a public contract. Mixing them up couples services you meant to keep apart.

"Event" gets used for two quite different things, and treating them as one causes trouble later.

Domain events: inside the boundary

A domain event records something that happened in your domain model, for other parts of the same service or module to react to:

public record OrderShipped(Guid OrderId, DateTimeOffset ShippedAt) : IDomainEvent;

public class Order
{
    private readonly List<IDomainEvent> _events = [];
    public IReadOnlyList<IDomainEvent> Events => _events;

    public void Ship(DateTimeOffset now)
    {
        if (Status != OrderStatus.Paid)
            throw new InvalidOperationException("Only paid orders can be shipped.");

        Status = OrderStatus.Shipped;
        _events.Add(new OrderShipped(Id, now));
    }
}

Handlers run in-process, often just before or after SaveChanges, for example from an EF Core interceptor that dispatches each entity's events. They can update another aggregate, write an audit entry or schedule work. They're free to use internal types, because no one outside the service sees them, and you can change them whenever you like.

Integration events: across boundaries

An integration event tells other services something happened. It travels through a message broker such as Service Bus, and it's a published contract:

// In a shared contracts package
public record OrderShippedV1(Guid OrderId, string CustomerId, string Carrier, DateTimeOffset ShippedAt);

Different rules apply:

  • It's a public API. Other teams depend on its shape. Changing it needs versioning and a migration plan.
  • It contains only what consumers need, using simple types, not your internal entities or value objects.
  • It must be published reliably, typically through an outbox, so it's never lost if the publish fails after the database commit.
  • Consumers receive it at least once, later, and possibly out of order.

How they connect

The common pattern: a domain event handler translates the domain event into an integration event and writes it to the outbox, in the same transaction.

public class PublishOrderShipped(OrdersDb db) : IDomainEventHandler<OrderShipped>
{
    public Task HandleAsync(OrderShipped e, CancellationToken ct)
    {
        var order = db.Orders.Local.Single(o => o.Id == e.OrderId);
        db.Outbox.Add(OutboxMessage.From(
            new OrderShippedV1(order.Id, order.CustomerId, order.Carrier, e.ShippedAt)));
        return Task.CompletedTask;
    }
}

Not every domain event becomes an integration event. Most stay internal. Publishing externally is a deliberate decision about what other services should know.

The mistake to avoid

Publishing domain events directly to the broker feels efficient: one event type, no translation. But now other services depend on your domain model's internal structure. Renaming a property in your entity becomes a breaking change for three other teams, and the boundary you designed exists only on paper.

Takeaway

Use domain events to react to changes inside a service, and change them freely. Use integration events, published reliably through an outbox and versioned like an API, to tell other services what happened. Translate between the two on purpose.