A Domain Event is an immutable record that something meaningful happened in the business domain, named in the past tense (for example ClaimApproved).
Intro
A Domain Event is an immutable record that something meaningful happened in the business domain, named in the past tense (for example ClaimApproved). An aggregate raises it when a state-changing transaction completes, and other parts of the system react to it without the originating code knowing who they are. It replaces “call every interested service directly” with “announce the fact and let interested parties subscribe”.
Running example: an insurance claims system with Claims, Payments, Fraud, Notifications, and Reporting services.
Why we need this
- Business reason: In insurance, one fact (“claim approved”) matters to many departments: payments must schedule a payout, notifications must email the policyholder, reporting must update reserves, fraud analytics must record the outcome. These teams change at different speeds.
- Technical reason: Without events, the Claims service must know about and call every downstream consumer. Each new requirement (for example, “also notify the broker”) forces a change and redeploy of Claims.
- Decoupling in time and knowledge: The publisher knows only the fact it announces. Consumers decide what to do with it.
- DDD alignment: Events make the ubiquitous language explicit.
ClaimApprovedis a term the business uses;UpdateClaimStatusRowis not. - Foundation for other patterns: Saga, CQRS read-model projection, Event Sourcing, and Transactional Outbox all build on domain events.
What problem it solves
Problem: Other bounded contexts are unaware of critical state changes.
Without domain events:
- Claims finishes approving a claim and nobody else knows. Payments finds out only by polling the Claims database or API every few minutes.
- Or Claims calls Payments, Notifications, and Reporting synchronously. If Notifications is down, the approval either fails or is silently half-done.
- Business rules get scattered: the “after approval, do X” logic ends up inside the
ApproveClaimmethod, which grows into a god method that touches five concerns. - Auditors ask “when did we learn the claim was approved, and who reacted?” and there is no trace.
With domain events, the aggregate states the fact once and the reactions live where they belong.
When it is needed (and when it is NOT)
Fits when:
- A state change in one bounded context has consequences in others (approval triggers payment, notification, reserve update).
- You expect new reactions to be added over time without touching the originating service.
- You need a business-meaningful audit trail of what happened.
- You are moving toward eventual consistency between services (see Saga, CQRS).
Overkill or wrong when:
- A simple CRUD service with no interested consumers. Events with zero subscribers are noise.
- The caller needs an immediate answer or a guaranteed result (for example “is this policy valid right now?”). That is a query or a synchronous call, not an event.
- Strict same-transaction consistency is a hard requirement and both parts live in one database. A plain transaction is simpler.
- Small teams with a single deployable where a direct method call is clear and testable. Use in-process events only when they earn their keep.
- Do not use events as disguised commands.
SendEmailToCustomeris a command;ClaimApprovedis an event.
How to identify the problem (key signals)
- A method like
ApproveClaim()calls three to six other services or repositories after saving, and the list keeps growing. - Other teams ask “can you add a call to our service when X happens?” every sprint.
- Consumers poll a table or endpoint on a timer (
SELECT ... WHERE status = 'APPROVED' AND processed = 0) to discover changes. - Incidents where a downstream outage causes upstream user-facing failures (approval returns 500 because email is down).
- Business people describe workflows as “when X happens, then Y and Z” but the code shows no
X happenedconcept anywhere. - Data in reporting or another service is stale for hours because nothing tells it to refresh.
- Audit questions (“who reacted to this approval?”) cannot be answered from logs.
Flow Diagram
An aggregate raises a domain event; in-process handlers and other contexts react independently.
flowchart LR API["Approve claim endpoint"] --> AGG["Claim.Approve()"] AGG -- "Raise ClaimApproved" --> SAVE["SaveChanges"] SAVE --> IP["In-process handlers e.g. ReserveUpdater"] SAVE --> OB[("Outbox row")] OB --> T{{"Topic: claims-events"}} T --> PAY["Payments"] T --> NOT["Notifications"] T --> REP["Reporting"]Level 1: Beginner
Analogy: A school bell. The bell does not know who is in which classroom or what they will do. It announces “class is over”, and students, teachers, and the canteen each react in their own way. Adding a new listener (a cleaner) needs no change to the bell.
Rules of thumb:
- Name it in the past tense with business language:
ClaimApproved, notClaimStatusChangedorApproveClaimEvent. - It is immutable: once raised, it never changes.
- It carries enough data to be useful (IDs, amounts, timestamps) but not a full copy of the aggregate.
Minimal working example (.NET 10, C# 14, console app):
using System;using System.Collections.Generic;
// 1. The event: an immutable factpublic sealed record ClaimApproved(Guid ClaimId, decimal ApprovedAmount, DateTimeOffset OccurredAt);
// 2. The aggregate raises events; it does not publish thempublic class Claim{ private readonly List<object> _events = new(); public Guid Id { get; } = Guid.NewGuid(); public string Status { get; private set; } = "Submitted"; public IReadOnlyList<object> DomainEvents => _events;
public void Approve(decimal amount) { if (Status != "Submitted") throw new InvalidOperationException("Only submitted claims can be approved."); if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
Status = "Approved"; _events.Add(new ClaimApproved(Id, amount, DateTimeOffset.UtcNow)); }
public void ClearEvents() => _events.Clear();}
public static class Program{ public static void Main() { var claim = new Claim(); claim.Approve(1200m);
// 3. Something outside the aggregate dispatches events to handlers foreach (var e in claim.DomainEvents) Console.WriteLine($"Raised: {e}"); claim.ClearEvents(); }}The key idea: the aggregate records what happened; a separate piece of infrastructure decides who hears about it.
Level 2: Intermediate
In a real ASP.NET Core + EF Core + Angular application, the common shape is:
- The aggregate raises events into an internal list.
SaveChangesAsyncin theDbContextcollects events from tracked aggregates and dispatches them after the save succeeds (for in-process handlers), or writes them to an outbox in the same transaction (for cross-service delivery; see Day 12).- Handlers react (update a read model, start a saga step, send a notification command).
Domain layer:
public interface IDomainEvent { Guid EventId { get; } DateTimeOffset OccurredAt { get; } }
public abstract class AggregateRoot{ private readonly List<IDomainEvent> _events = new(); public IReadOnlyCollection<IDomainEvent> DomainEvents => _events.AsReadOnly(); protected void Raise(IDomainEvent e) => _events.Add(e); public void ClearDomainEvents() => _events.Clear();}
public sealed record ClaimApproved(Guid ClaimId, decimal ApprovedAmount, string PolicyNumber) : IDomainEvent{ public Guid EventId { get; } = Guid.NewGuid(); public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;}
public class Claim : AggregateRoot{ public Guid Id { get; private set; } = Guid.NewGuid(); public string PolicyNumber { get; private set; } = ""; public ClaimStatus Status { get; private set; }
public void Approve(decimal amount) { if (Status != ClaimStatus.UnderReview) throw new InvalidOperationException("Claim must be under review."); Status = ClaimStatus.Approved; Raise(new ClaimApproved(Id, amount, PolicyNumber)); }}
public enum ClaimStatus { Submitted, UnderReview, Approved, Rejected }Infrastructure: dispatch after commit (EF Core):
using Microsoft.EntityFrameworkCore;
public class ClaimsDbContext(DbContextOptions<ClaimsDbContext> options, IDomainEventDispatcher dispatcher) : DbContext(options){ public DbSet<Claim> Claims => Set<Claim>();
public override async Task<int> SaveChangesAsync(CancellationToken ct = default) { var aggregates = ChangeTracker.Entries<AggregateRoot>() .Select(e => e.Entity) .Where(a => a.DomainEvents.Count > 0) .ToList(); var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
var result = await base.SaveChangesAsync(ct); // state is committed first
aggregates.ForEach(a => a.ClearDomainEvents()); await dispatcher.DispatchAsync(events, ct); // then handlers run return result; }}
public interface IDomainEventDispatcher{ Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct);}
public interface IDomainEventHandler<in T> where T : IDomainEvent{ Task HandleAsync(T e, CancellationToken ct);}
public class DomainEventDispatcher(IServiceProvider sp) : IDomainEventDispatcher{ public async Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct) { foreach (var e in events) { var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(e.GetType()); foreach (dynamic h in sp.GetServices(handlerType)) await h.HandleAsync((dynamic)e, ct); } }}This hand-rolled dispatcher avoids a dependency. MediatR’s INotification is a popular alternative, but it moved to a commercial license from version 13, so check the license terms before adopting it for a team project.
Handler and API:
public class ReserveUpdater(ReportingDbContext db) : IDomainEventHandler<ClaimApproved>{ public async Task HandleAsync(ClaimApproved e, CancellationToken ct) { // Idempotent: upsert by ClaimId, so replay is harmless var row = await db.ClaimReserves.FindAsync([e.ClaimId], ct); if (row is null) db.ClaimReserves.Add(new ClaimReserve(e.ClaimId, e.ApprovedAmount)); else row.Amount = e.ApprovedAmount; await db.SaveChangesAsync(ct); }}
app.MapPost("/claims/{id:guid}/approve", async (Guid id, ApproveRequest req, ClaimsDbContext db, CancellationToken ct) =>{ var claim = await db.Claims.FindAsync([id], ct); if (claim is null) return Results.NotFound(); claim.Approve(req.Amount); await db.SaveChangesAsync(ct); return Results.Accepted();});Angular (v21-era, standalone components + signals): the UI does not receive domain events directly. It calls the API and, for long reactions, reads the result via polling or SignalR.
import { Component, inject, signal } from '@angular/core';import { HttpClient } from '@angular/common/http';
@Component({ selector: 'app-approve-claim', template: ` <button (click)="approve()" [disabled]="busy()">Approve</button> @if (message()) { <p>{{ message() }}</p> } `,})export class ApproveClaimComponent { private http = inject(HttpClient); busy = signal(false); message = signal('');
approve() { this.busy.set(true); this.http.post('/api/claims/123/approve', { amount: 1200 }).subscribe({ next: () => this.message.set('Approved. Payment and notifications are being processed.'), error: () => this.message.set('Approval failed.'), complete: () => this.busy.set(false), }); }}The message wording matters: reactions are asynchronous, so the UI must not promise that the payment already exists.
Database: for cross-service reliability, persist the event in an OutboxMessages table in the same transaction as the aggregate change (see Day 12). SQL Server and PostgreSQL both work; store the payload as nvarchar(max) / jsonb.
Level 3: Advanced
Two kinds of dispatch, two different guarantees:
| Aspect | In-process (same service) | Cross-service (broker) |
|---|---|---|
| Transport | Dispatcher call in the same process | Azure Service Bus, Kafka, RabbitMQ |
| Atomicity with the DB write | Can be inside the transaction (before commit) or after | Needs Transactional Outbox |
| Failure mode | Handler exception can roll back or leave partial work | Duplicates, reordering, delays |
| Coupling | Same codebase and deployment | Contract-based, versioned |
Dispatch before vs after commit:
- Before commit (inside the transaction): handlers see uncommitted state and their DB changes commit atomically with the aggregate. Good for same-database side effects. A slow or failing handler blocks or fails the original request.
- After commit: the original change is safe, but if the process crashes between commit and dispatch, the event is lost. This is the dual-write problem. The fix is the outbox.
Performance and scalability:
- Keep events small. Send IDs and the facts that changed; consumers fetch more if they need it.
- Avoid loading big object graphs inside handlers on every event.
- Batch dispatches inside
SaveChangesonly for in-process handlers; do not fan out slow HTTP calls there.
Security:
- Do not put secrets or unnecessary PII in events. Events are copied to brokers, logs, and read models. A
ClaimApprovedevent should carry aPolicyNumberor claim ID, not a full medical history. - Authorise producers and consumers on the broker (Azure RBAC, Managed Identity).
- Treat consumed events as untrusted input: validate schema and version.
Failure modes and handling:
- Duplicates: brokers deliver at least once. Handlers must be idempotent (Day 17). Use
EventIdas a dedup key. - Ordering: two events for the same claim can arrive out of order. Include a version or sequence number per aggregate and ignore stale ones.
- Poison messages: a handler that always throws must not block the queue forever. Use retry with backoff, then a dead-letter queue.
- Schema evolution: add fields only (backward compatible), never rename or remove without versioning (
ClaimApproved.v2).
Common mistakes:
- Naming events after CRUD (
ClaimUpdated) so nobody knows what really happened. - Putting the full entity in the event so every schema change breaks all consumers.
- Publishing to the broker directly after
SaveChangeswith no outbox, and losing events on crash. - Using events for request/response (“event that expects a reply”).
- Handlers that call back into the publisher synchronously and create hidden circular dependencies.
- Confusing domain events (internal, rich, may change) with integration events (public contract, stable, versioned). Publish integration events from domain events through a translation step.
Level 4: Expert and Architect view
Alternatives compared:
| Option | How it works | Strengths | Weaknesses | Use when |
|---|---|---|---|---|
| Direct synchronous calls | Claims calls each downstream service | Simple, immediate result | Tight coupling, cascading failure, growing method | Two services, stable, must have immediate answer |
| Domain Event (in-process) | Aggregate raises; dispatcher calls handlers | Clean separation, easy testing | Same deployment only; lost on crash if after-commit without outbox | Modular monolith, side effects in same service |
| Domain Event + Outbox + broker | Event stored atomically, relayed to broker | Reliable cross-service, decoupled | More moving parts, eventual consistency | Microservices needing reliable notifications |
| Event Sourcing | Events are the source of truth | Full history, replay | Steep learning curve, versioning cost | Audit-heavy domains, temporal queries |
| Polling shared table/API | Consumers query for changes | No broker needed | Latency, load, hidden coupling | Legacy or interim integration |
Combines with: Transactional Outbox (Day 12), Transaction Log Tailing (Day 13), Polling Publisher (Day 14), Saga (Day 7), CQRS (Day 8), Event Sourcing (Day 9), Idempotent Consumer (Day 17), Messaging (Day 16).
ADR (architecture review format):
- Title: ADR-011 Use domain events, published through a transactional outbox, for cross-context notification in Claims
- Status: Proposed
- Context: Claims approval must trigger payment scheduling, policyholder notification, reserve update, and fraud analytics, owned by four teams. Direct calls have caused approval failures when Notifications was unavailable, and each new consumer requires a Claims release.
- Decision: Aggregates raise domain events named in business language. Events are dispatched in-process for same-service reactions. For other services, a translation step maps the domain event to a versioned integration event written to an outbox table in the same transaction and relayed to Azure Service Bus topics. Consumers are idempotent and use the event ID for dedup.
- Consequences: (+) Claims no longer depends on downstream availability; new consumers need no Claims change; audit of business facts improves. (−) Eventual consistency, so the UI must not assume downstream effects are complete; need operational tooling for dead-letter queues and event schema governance.
- Alternatives rejected: Direct synchronous calls (coupling, availability), polling shared database (latency, hidden coupling), full Event Sourcing (cost not justified for this bounded context).
Azure implementation
Services:
- Azure Service Bus (topics and subscriptions): the default broker for business events with ordering (sessions), dead-lettering, duplicate detection, and transactions.
- Azure Event Grid: lightweight reactive events, good for Azure resource events and fan-out to Functions or webhooks. Not the first choice for durable business workflows requiring sessions or strict ordering.
- Azure Event Hubs: high-volume telemetry/streaming; use for analytics feeds, not for command-like business events.
- Azure Functions or Azure Container Apps with Service Bus triggers/KEDA scaling to host consumers.
- Azure SQL Database / Azure Database for PostgreSQL Flexible Server for the outbox and read models.
- Azure Monitor + Application Insights for tracing event flow; Key Vault and Managed Identity for secrets and auth.
Configuration (essentials):
- Create a Service Bus namespace, a topic
claims-events, and one subscription per consumer (payments,notifications,reporting). - Set on each subscription: max delivery count (for example 10), dead-lettering on message expiration, and lock duration matching handler time.
- Enable duplicate detection on the topic with a suitable window, using
MessageId = EventId. - Use SQL filters or correlation filters on subscriptions (for example on a
EventTypeproperty) so consumers receive only relevant events. - Authenticate with Managed Identity and the built-in roles Azure Service Bus Data Sender (producer) and Azure Service Bus Data Receiver (consumers). Avoid connection strings in configuration.
Publisher snippet (Azure.Messaging.ServiceBus):
using Azure.Identity;using Azure.Messaging.ServiceBus;using System.Text.Json;
await using var client = new ServiceBusClient("<namespace>.servicebus.windows.net", new DefaultAzureCredential());await using var sender = client.CreateSender("claims-events");
var integrationEvent = new { EventId = Guid.NewGuid(), ClaimId = Guid.NewGuid(), Amount = 1200m, Version = 1 };var message = new ServiceBusMessage(JsonSerializer.Serialize(integrationEvent)){ MessageId = integrationEvent.EventId.ToString(), Subject = "ClaimApproved", ContentType = "application/json"};message.ApplicationProperties["EventType"] = "ClaimApproved";await sender.SendMessageAsync(message);Pricing and tier considerations (verify current prices on the Azure pricing page before committing):
- Service Bus Basic has queues only, no topics. Domain-event fan-out needs topics, so use Standard or Premium.
- Standard is pay-as-you-go (a base charge plus per-operation charges) and is suitable for most dev, test, and moderate production workloads. Throughput can vary because it is a shared multi-tenant service.
- Premium provides dedicated messaging units, predictable latency, larger message sizes, VNet/private endpoint support, and geo-disaster recovery. Choose it when you need network isolation or steady high throughput.
- Event Grid and Functions Consumption bill per operation/execution, which is cheap at low volume.
Reference architecture (text):
- A Claims API (ASP.NET Core on Azure Container Apps) approves a claim. The aggregate raises
ClaimApproved. - In one Azure SQL transaction, the claim row is updated and an outbox row is inserted.
- A relay (polling publisher in a background service, or CDC-based) reads pending outbox rows and sends integration events to the Service Bus topic
claims-events. - Subscriptions
payments,notifications, andreportingfeed consumer services (Functions or Container Apps with KEDA autoscaling). - Each consumer dedups by
EventIdand writes to its own database. Failures retry, then go to the dead-letter queue, which raises an Azure Monitor alert. - Application Insights carries the W3C trace context in message properties so one trace links the API call to all consumers. Angular runs on Azure Static Web Apps behind the API.
Teaching guide for my team
Explain to a beginner in 2 minutes: “When something important happens in the business, like a claim being approved, the code writes down a fact: ClaimApproved. That fact is like a notice pinned on a board. Anyone who cares, such as payments or email, reads the board and does their own job. The claim code does not call them one by one. Name the notice in the past tense, keep it small, and never change it once written.”
Explain to an intermediate developer in 5 minutes: Cover: (1) aggregates raise events, infrastructure dispatches them; (2) in-process versus cross-service dispatch; (3) the dual-write problem and why the outbox solves it; (4) at-least-once delivery, so handlers must be idempotent; (5) domain events versus integration events, and why the public contract should be versioned and small; (6) eventual consistency and what it means for the Angular UI wording and refresh behavior.
Hands-on exercise: Build the Level 2 sample. Add a ClaimApproved event and two handlers: one that writes a row into a ClaimReserves table and one that logs a “notification queued” message. Then:
- Approve a claim and confirm both handlers ran.
- Make the second handler throw. Observe what happens to the approval (before-commit dispatch versus after-commit dispatch) and discuss which you prefer and why.
- Call the handler twice with the same
EventIdand make the reserve update idempotent.
Expected outcome: Both handlers run on approval. With after-commit dispatch, a handler failure does not undo the approval but the reaction is lost unless retried, which motivates the outbox. The idempotent handler produces a single reserve row after duplicate delivery.
Interview-style questions:
- What is the difference between a domain event and a command? A command asks for something to happen and can be rejected (
ApproveClaim); a domain event states something that already happened and cannot be rejected (ClaimApproved). - Why is publishing to a broker right after
SaveChangesunsafe? The process can crash between the two steps, so the database changes but the event is never published (dual-write). Use a transactional outbox. - How do you handle duplicate event delivery? Make consumers idempotent using the event ID or a natural key, for example an upsert or a processed-messages table with a unique constraint.
Mastery checklist
- I can name three events in the claims domain in correct past-tense business language and explain why
ClaimUpdatedis a poor name. - I can implement an aggregate that raises events and a dispatcher that runs handlers after a successful save.
- I can explain the dual-write problem and how an outbox removes it.
- I can distinguish domain events from integration events and design the translation between them.
- I can write an idempotent handler and justify the dedup key.
- I can design event versioning that lets old and new consumers coexist.
- I can configure an Azure Service Bus topic with subscriptions, dead-lettering, and duplicate detection.
- I can describe how the UI should behave when downstream effects are eventually consistent.
Key takeaway
A domain event is an immutable, past-tense business fact raised by an aggregate so other contexts can react without the source knowing them. Publish it reliably (outbox), consume it idempotently, and keep its public contract small and versioned.
