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