Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
dotnetmentor avatar

Domain Events

  • 1 installs
  • Updated August 3, 2026
  • dotnetmentor/glenn-code-factory

Adds domain events, rich entity methods, and event-store traceability to a .NET backend for state transitions, invariants, and side effects.

About

Guides implementing domain events, event-aware entities via IHasDomainEvents, and a StoredDomainEvents audit trail in a .NET backend. A developer uses it when adding business logic, invariants, or reactive side effects to domain entities.

  • Rich entity methods and IHasDomainEvents patterns
  • StoredDomainEvents audit-trail traceability

Domain Events by the numbers

  • 1 all-time installs (skills.sh)
  • Ranked #121 of 153 .NET & C# skills by installs in the Skillselion catalog
  • Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dotnetmentor/glenn-code-factory --skill domain-events

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs1
Last updatedAugust 3, 2026
Repositorydotnetmentor/glenn-code-factory

What it does

Adds domain events, rich entity methods, and event-store traceability to a .NET backend for state transitions, invariants, and side effects.

Files

SKILL.mdMarkdownGitHub ↗

Domain Events & Rich Entities

How It Works

1. Entity method mutates state + calls RaiseDomainEvent() 2. Handler calls entity method, then SaveChangesAsync() 3. DomainEventInterceptor (before commit) persists events to StoredDomainEvents table (same transaction) 4. DomainEventInterceptor (after commit) dispatches events via MediatR for side effects

Never manually call `_mediator.Publish()` for domain events. The interceptor handles it.

---

When to Use Rich Entity Methods

ScenarioApproach
State transition with invariants (cancel, approve, complete)Rich method on entity
Simple field update, mostly CRUDDirect mutation in handler
Logic that needs async DB lookups (uniqueness checks)Keep in handler, call entity method for the state change part
Side effects (email, notifications, audit)Event handler reacting to domain event

---

Key Files

FilePurpose
Source/Shared/Events/IHasDomainEvents.csInterface — what the interceptor scans for
Source/Shared/Events/Entity.csBase class for non-Identity entities
Source/Shared/Events/IDomainEvent.csEvent marker interface (requires OccurredAt)
Source/Shared/Events/IEntityDomainEvent.csOptional — adds EntityId/EntityType for indexed querying
Source/Shared/Events/StoredDomainEvent.csPersisted audit record (JSONB payload)
Source/Infrastructure/Interceptors/DomainEventInterceptor.csTwo-phase persist + dispatch

---

Adding a Rich Method to an Existing Entity

Entities inheriting from Entity base class

public class Booking : Entity
{
    public BookingStatus Status { get; private set; }

    public Result Cancel(string? reason)
    {
        if (Status != BookingStatus.Confirmed)
            return Result.Failure("Only confirmed bookings can be cancelled");

        Status = BookingStatus.Cancelled;
        UpdatedAt = DateTime.UtcNow;
        RaiseDomainEvent(new BookingCancelled(Id, ResourceId));
        return Result.Success();
    }
}

User entity (inherits IdentityUser, implements IHasDomainEvents directly)

User can't use Entity base class. It implements IHasDomainEvents directly with the same 5-line plumbing. See Source/Features/Users/Models/User.cs for the pattern.

---

Creating a New Domain Event

Place in the feature's Events/ folder. Name in past tense.

Use IEntityDomainEvent when the event relates to a specific entity (enables indexed querying). Use IDomainEvent for system-wide events not tied to one entity.

using Source.Shared.Events;

namespace Source.Features.Bookings.Events;

public record BookingCancelled(
    Guid BookingId,
    Guid ResourceId,
    DateTime OccurredAt
) : IEntityDomainEvent
{
    public BookingCancelled(Guid bookingId, Guid resourceId)
        : this(bookingId, resourceId, DateTime.UtcNow) { }

    string IEntityDomainEvent.EntityId => BookingId.ToString();
    string IEntityDomainEvent.EntityType => "Booking";
}

Always provide a convenience constructor that auto-fills OccurredAt.

---

Creating an Event Handler (Side Effects)

Place in the feature's EventHandlers/ folder. One handler per side effect.

using Source.Shared.Events;

namespace Source.Features.Bookings.EventHandlers;

public class NotifyOnBookingCancelled : IEventHandler<BookingCancelled>
{
    private readonly IEmailService _emailService;

    public NotifyOnBookingCancelled(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public async Task Handle(BookingCancelled notification, CancellationToken ct)
    {
        await _emailService.SendEmailAsync(...);
    }
}

Adding a new side effect = add a new handler. Zero changes to existing code.

---

Making a New Entity Event-Aware

Standard entity (not IdentityUser)

Inherit from Entity:

using Source.Shared.Events;

public class Booking : Entity
{
    public Guid Id { get; private set; }
    // ... properties

    private Booking() { } // EF Core

    public static Booking Create(Guid resourceId, ...)
    {
        var booking = new Booking { Id = Guid.NewGuid(), ... };
        booking.RaiseDomainEvent(new BookingCreated(booking.Id));
        return booking;
    }
}

Entity inheriting from another base (like IdentityUser)

Implement IHasDomainEvents directly:

using System.ComponentModel.DataAnnotations.Schema;
using Source.Shared.Events;

public class User : IdentityUser, IHasDomainEvents
{
    private readonly List<IDomainEvent> _domainEvents = new();

    [NotMapped]
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
    public void ClearDomainEvents() => _domainEvents.Clear();

    // ... rest of entity
}

---

Handler Pattern with Rich Entity

public async Task<Result<Response>> Handle(CancelBookingCommand request, CancellationToken ct)
{
    // 1. Load entity
    var booking = await _context.Bookings.FindAsync(request.BookingId);
    if (booking is null) return Result.Failure<Response>("Not found");

    // 2. Call entity method (validates + mutates + raises event)
    var result = booking.Cancel(request.Reason);
    if (result.IsFailure) return Result.Failure<Response>(result.Error!);

    // 3. Save — interceptor persists events + dispatches them
    await _context.SaveChangesAsync(ct);

    return Result.Success(new Response { ... });
}

For UserManager-based entities, _userManager.UpdateAsync(user) calls SaveChanges internally — the interceptor still fires.

---

StoredDomainEvents Table

All events are automatically persisted with:

  • EventType — event class name (e.g. "BookingCancelled")
  • Payload — full JSON serialization (JSONB column)
  • EntityId / EntityType — auto-populated when event implements IEntityDomainEvent
  • UserId — auto-captured from HttpContext
  • OccurredAt — from the event's OccurredAt property

Indexed on: EventType, OccurredAt, (EntityType, EntityId).

---

Automatic Audit Fields

ApplicationDbContext.SaveChangesAsync auto-sets timestamps. Never set these manually.

InterfaceFieldsWhen
IAuditableCreatedAt, UpdatedAtAdded → both set. Modified → UpdatedAt set.
ISoftDeleteDeletedAt, DeletedByWhen IsDeleted flipped to true. DeletedBy = current user from HttpContext.

To make a new entity auditable:

public class Booking : Entity, IAuditable, ISoftDelete
{
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public string? DeletedBy { get; set; }
}

Soft delete in handler — just flip the flag:

booking.IsDeleted = true;
await _context.SaveChangesAsync(ct); // DeletedAt, DeletedBy, UpdatedAt all set automatically

---

What NOT to Do

  • Don't call `_mediator.Publish()` in handlers for domain events — the interceptor does this
  • Don't inject services into entities — entities are pure, no DbContext, no external calls
  • Don't make every entity rich — CRUD-only entities stay anemic (no Entity base needed)
  • Don't put async logic in entity methods — async DB lookups stay in the handler
  • Don't set `CreatedAt`/`UpdatedAt`/`DeletedAt`/`DeletedBy` manually — the DbContext handles it

Related skills

.NET & C#backend

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.