Manikandan — Manikandan
Microservices

Day 11: Domain Event

ManikandanManikandan
16 min read·Updated Aug 20, 2022

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. ClaimApproved is a term the business uses; UpdateClaimStatusRow is 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 ApproveClaim method, 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. SendEmailToCustomer is a command; ClaimApproved is an event.

How to identify the problem (key signals)

  1. A method like ApproveClaim() calls three to six other services or repositories after saving, and the list keeps growing.
  2. Other teams ask “can you add a call to our service when X happens?” every sprint.
  3. Consumers poll a table or endpoint on a timer (SELECT ... WHERE status = 'APPROVED' AND processed = 0) to discover changes.
  4. Incidents where a downstream outage causes upstream user-facing failures (approval returns 500 because email is down).
  5. Business people describe workflows as “when X happens, then Y and Z” but the code shows no X happened concept anywhere.
  6. Data in reporting or another service is stale for hours because nothing tells it to refresh.
  7. 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, not ClaimStatusChanged or ApproveClaimEvent.
  • 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 fact
public sealed record ClaimApproved(Guid ClaimId, decimal ApprovedAmount, DateTimeOffset OccurredAt);
// 2. The aggregate raises events; it does not publish them
public 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:

  1. The aggregate raises events into an internal list.
  2. SaveChangesAsync in the DbContext collects 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).
  3. 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:

AspectIn-process (same service)Cross-service (broker)
TransportDispatcher call in the same processAzure Service Bus, Kafka, RabbitMQ
Atomicity with the DB writeCan be inside the transaction (before commit) or afterNeeds Transactional Outbox
Failure modeHandler exception can roll back or leave partial workDuplicates, reordering, delays
CouplingSame codebase and deploymentContract-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 SaveChanges only 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 ClaimApproved event should carry a PolicyNumber or 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 EventId as 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:

  1. Naming events after CRUD (ClaimUpdated) so nobody knows what really happened.
  2. Putting the full entity in the event so every schema change breaks all consumers.
  3. Publishing to the broker directly after SaveChanges with no outbox, and losing events on crash.
  4. Using events for request/response (“event that expects a reply”).
  5. Handlers that call back into the publisher synchronously and create hidden circular dependencies.
  6. 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:

OptionHow it worksStrengthsWeaknessesUse when
Direct synchronous callsClaims calls each downstream serviceSimple, immediate resultTight coupling, cascading failure, growing methodTwo services, stable, must have immediate answer
Domain Event (in-process)Aggregate raises; dispatcher calls handlersClean separation, easy testingSame deployment only; lost on crash if after-commit without outboxModular monolith, side effects in same service
Domain Event + Outbox + brokerEvent stored atomically, relayed to brokerReliable cross-service, decoupledMore moving parts, eventual consistencyMicroservices needing reliable notifications
Event SourcingEvents are the source of truthFull history, replaySteep learning curve, versioning costAudit-heavy domains, temporal queries
Polling shared table/APIConsumers query for changesNo broker neededLatency, load, hidden couplingLegacy 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 EventType property) 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):

  1. A Claims API (ASP.NET Core on Azure Container Apps) approves a claim. The aggregate raises ClaimApproved.
  2. In one Azure SQL transaction, the claim row is updated and an outbox row is inserted.
  3. 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.
  4. Subscriptions payments, notifications, and reporting feed consumer services (Functions or Container Apps with KEDA autoscaling).
  5. Each consumer dedups by EventId and writes to its own database. Failures retry, then go to the dead-letter queue, which raises an Azure Monitor alert.
  6. 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:

  1. Approve a claim and confirm both handlers ran.
  2. 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.
  3. Call the handler twice with the same EventId and 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:

  1. 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).
  2. Why is publishing to a broker right after SaveChanges unsafe? The process can crash between the two steps, so the database changes but the event is never published (dual-write). Use a transactional outbox.
  3. 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 ClaimUpdated is 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.

Interactive Architectural Roadmaps

Explore Complete Roadmaps & Pattern Checklists

Track your learning with interactive checklists for all 23 Gang of Four patterns and modern Microservice architecture patterns.

Share:
Back to Blog

Related Posts

View All Posts
Microservices

Day 10: API Composition

API Composition solves the "no more SQL JOIN" problem that appears once each microservice owns its own database.

Manikandan
Manikandan·13 min read
Microservices

Day 9: Event Sourcing

Event Sourcing stores every change to a business object as an immutable event in an append-only log, instead of overwriting the current state in a row.

Manikandan
Manikandan·18 min read
Microservices

Day 8: CQRS

CQRS (Command Query Responsibility Segregation) splits an application's model into two sides.

Manikandan
Manikandan·18 min read
Microservices

Day 7: Saga Pattern

A saga is a way to complete one business process that spans several services, each with its own database, without a distributed lock or a two-phase commit.

Manikandan
Manikandan·20 min read