Where do domain events live in Clean Architecture and how do you publish them?
Domain events live in the Domain layer as POCOs. Publishing them happens in the Application layer at commit time. Delivery to external systems happens in Infrastructure via the outbox pattern.
Step 1 — Define the event in Domain
// Domain/Events/OrderPlacedEvent.cs
public record OrderPlacedEvent(Guid OrderId, Guid CustomerId, decimal Total, DateTimeOffset At);
// Domain/Events/IDomainEvent.cs (marker interface)
public interface IDomainEvent { }
public record OrderPlacedEvent(...) : IDomainEvent;
public record OrderCancelledEvent(Guid OrderId, string Reason, DateTimeOffset At) : IDomainEvent;
The event is a fact about the business — past tense, immutable. Pure data, no behavior.
Step 2 — Raise it from the aggregate
The Domain entity collects events that have occurred during its lifetime:
// Domain/Entities/Order.cs
public class Order
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _events;
public void ClearEvents() => _events.Clear();
public static Order Place(Guid customerId, IEnumerable<OrderItem> items, DateTimeOffset now)
{
var order = new Order { ... };
order._events.Add(new OrderPlacedEvent(order.Id, customerId, order.Total.Amount, now));
return order;
}
public void Cancel(string reason, DateTimeOffset now)
{
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel shipped");
Status = OrderStatus.Cancelled;
_events.Add(new OrderCancelledEvent(Id, reason, now));
}
}
The entity ENFORCES invariants AND records what happened. Critical: the event is part of the same in-memory state as the entity.
Step 3 — Dispatch on save (in Application / Infrastructure)
In the use case handler, after the business operation succeeds:
// Application/UseCases/PlaceOrder/PlaceOrderHandler.cs
public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Place(cmd.CustomerId, items, _clock.UtcNow);
await _orders.AddAsync(order, ct); // persists order + dispatches events
return order.Id;
}
The repository persists the entity AND dispatches its events:
// Infrastructure/Persistence/EfOrderRepository.cs
public async Task AddAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
// Persist domain events into the outbox table in the SAME transaction
foreach (var ev in order.DomainEvents)
{
_db.OutboxEvents.Add(new OutboxEvent
{
Topic = ev.GetType().Name,
Payload = JsonSerializer.Serialize(ev),
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync(ct); // ONE atomic transaction
order.ClearEvents();
}
This uses the outbox pattern — the event is written to a DB table in the SAME transaction as the business write. A background relay drains the outbox and publishes to the message bus (Kafka / RabbitMQ / Service Bus). The event is never lost, even if the service crashes between commits.
Step 4 — Bus relay publishes to other services (Infrastructure)
// Infrastructure/Outbox/OutboxRelay.cs (BackgroundService)
public class OutboxRelay : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
var batch = await _db.OutboxEvents
.Where(e => e.SentAt == null)
.Take(100)
.ToListAsync(stop);
foreach (var ev in batch)
{
await _bus.PublishAsync(ev.Topic, ev.Payload, stop);
ev.SentAt = DateTimeOffset.UtcNow;
}
await _db.SaveChangesAsync(stop);
await Task.Delay(250, stop);
}
}
}
Two kinds of consumers — local and external
Local handlers (within the same process) — use MediatR's INotification:
public class WhenOrderPlaced_SendEmail : INotificationHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent ev, CancellationToken ct) { ... }
}
Useful when you want to fan out to multiple in-process listeners (email, audit log, analytics) within the same transaction.
External handlers (other services) — listen to the message bus the outbox publishes to.
The Dependency Rule still holds
- Events are defined in Domain (innermost layer, no dependencies)
- Events are raised by Domain entities
- Events are dispatched by Application + Infrastructure
- The bus publish happens in Infrastructure (it knows about Kafka / RabbitMQ)
Domain layer remains pure. It just records facts. Other layers figure out what to do with those facts.
Common mistakes
Mistake 1 — events in the Application layer
Putting OrderPlacedEvent in Application/Events/ instead of Domain/Events/. Domain entities then can't raise events without referencing Application — violates the Dependency Rule.
Mistake 2 — inline event publish
// ❌ Inline publish — events lost if process crashes between SaveChanges and PublishAsync
await _db.SaveChangesAsync();
await _bus.PublishAsync(ev);
Always use the outbox pattern. Two writes (DB + bus) can't be atomic; the outbox makes them one DB transaction.
Mistake 3 — confusing domain events with integration events
| Domain event | Integration event | |
|---|---|---|
| Audience | Within the same bounded context | Across bounded contexts / services |
| Payload | Rich domain types | Stable, versioned schema (JSON, protobuf) |
| Example | OrderPlacedEvent (full Order details) | order.placed.v1 (flat fields, no internal IDs) |
Most "domain events" people publish to Kafka are actually integration events. Be clear about which you have.
Mistake 4 — entities calling event handlers directly
The entity should RAISE events into its DomainEvents collection. It should NEVER call email senders, bus clients, or other services. Those are infrastructure concerns. Keep the entity pure.
Interview-grade summary
"Domain events live in the Domain layer as records — they represent business facts in past tense. The aggregate raises them in a
DomainEventscollection. The repository persists them to an outbox table in the same transaction as the entity. A relay publishes them to the message bus. Local listeners use MediatR notifications; cross-service consumers listen to Kafka/RabbitMQ. The Dependency Rule holds because Infrastructure does the bus publish, not Domain."