Blog

Auditing and domain events with EF Core in the DbContext

DbContext
EF
C#
.NET
Azure

Override SaveChangesAsync in EF Core to fill in audit fields, publish domain events after the save commits, and store every DateTime as UTC.

Auditing and domain events with EF Core in the DbContext

Almost every business application I work on ends up needing two things: knowing who created or changed each record and when, and letting the domain announce what happened without wiring every use case to everything else. Both can live in the same place, the Entity Framework Core DbContext, with no repeated code in each service.

Automatic entity auditing

The idea is to override SaveChangesAsync and, before saving, walk through the entities the ChangeTracker is holding:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedBy = _currentUserService.UserId;
entry.Entity.Created = _dateTime.Now;
break;
case EntityState.Modified:
entry.Entity.LastModifiedBy = _currentUserService.UserId;
entry.Entity.LastModified = _dateTime.Now;
break;
}
}
// Rest of the implementation...
}

New entities get their author and creation date; modified ones get the last-modified fields. Nobody has to remember to set them by hand in each command, and every change that goes through the context is recorded the same way.

All the logic in one place

The only thing asked of the entities is that they inherit from AuditableEntity (or implement an equivalent interface) with these properties:

  • CreatedBy: who created the record.
  • Created: when it was created.
  • LastModifiedBy: who last edited it.
  • LastModified: when it was last changed.

The current user and the clock are injected (_currentUserService and _dateTime), so they’re easy to swap out in tests.

Domain events with EF Core

The same context also publishes domain events:

var events = ChangeTracker.Entries<IHasDomainEvent>()
.Select(x => x.Entity.DomainEvents)
.SelectMany(x => x)
.Where(domainEvent => !domainEvent.IsPublished)
.ToArray();
var result = await base.SaveChangesAsync(cancellationToken);
await DispatchEvents(events);

There are three details I care about in this snippet.

Order. Changes are committed to the database first, and only then are the events published. That way no handler reacts to something that never actually got saved.

Duplicates. Only events that haven’t been published yet are collected (!domainEvent.IsPublished), and each one is marked as published before it’s sent. If SaveChangesAsync runs again on the same entities, the events don’t fire twice.

Coupling. Entities that raise events are identified through the IHasDomainEvent interface, and the actual publishing is handled by a separate service (IDomainEventService). The context just collects the events and hands them over.

Extra model configuration

OnModelCreating has two more settings worth having:

protected override void OnModelCreating(ModelBuilder builder)
{
// Ignore DomainEvent in the model
builder.Ignore<DomainEvent>();
builder.Ignore<List<DomainEvent>>();
// Configure DateTime to use UTC
foreach (var entityType in builder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?))
{
property.SetValueConverter(new ValueConverter<DateTime, DateTime>(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc)));
}
}
}
}

The first one stops EF Core from trying to map domain events as if they were tables: they live on the entities but aren’t persisted. The second applies a converter to every DateTime and DateTime? property so values are always written and read as UTC, regardless of how each date arrives.

What I take from this

Auditing, event publishing and date handling all end up inside the DbContext, and the use cases only deal with their own logic. One thing to keep in mind: since events are published after the transaction commits, if publishing fails the data is already saved. For a lot of cases that’s fine. If an event must never be lost, you need something more, such as storing the events in the same transaction and publishing them afterwards.


Comments

No comments yet. Be the first!

Leave a Comment

Your name and comment will be published once approved. More in the privacy policy.