Blog

Auditoría y eventos de dominio con EF Core en el DbContext

DbContext
EF
C#
.NET
Azure

Cómo sobrescribir SaveChangesAsync en EF Core para rellenar los campos de auditoría, publicar eventos de dominio tras guardar y tratar las fechas en UTC.

Auditoría y eventos de dominio con EF Core en el DbContext

En casi todas las aplicaciones empresariales acabo necesitando dos cosas: saber quién creó o modificó cada registro y cuándo, y que el dominio pueda avisar de lo que ha pasado sin acoplar cada caso de uso al resto. Las dos se pueden resolver en el mismo sitio, el DbContext de Entity Framework Core, sin repetir código en cada servicio.

Auditoría automática de entidades

La idea es sobrescribir SaveChangesAsync y, antes de guardar, recorrer las entidades que tiene el ChangeTracker:

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;
}
}
// Resto de la implementación...
}

Si la entidad es nueva, se rellenan el autor y la fecha de creación; si se ha modificado, los de la última modificación. Nadie tiene que acordarse de hacerlo a mano en cada comando, y todos los cambios que pasan por el contexto quedan registrados de la misma forma.

Toda la lógica en un solo lugar

Lo único que se pide a las entidades es que hereden de AuditableEntity (o implementen una interfaz equivalente) con estas propiedades:

  • CreatedBy: quién creó el registro.
  • Created: cuándo se creó.
  • LastModifiedBy: quién lo editó por última vez.
  • LastModified: cuándo se modificó por última vez.

El usuario actual y la fecha llegan inyectados (_currentUserService y _dateTime), así que en los tests se pueden sustituir sin problema.

Eventos de dominio con EF Core

El mismo contexto sirve para publicar eventos de dominio:

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);

Hay tres detalles que me importan de este fragmento.

El orden: primero se confirman los cambios en la base de datos y después se publican los eventos. Así ningún manejador reacciona a algo que al final no se ha guardado.

Los duplicados: solo se recogen los eventos que aún no están publicados (!domainEvent.IsPublished), y cada evento se marca como publicado antes de enviarlo. Si se vuelve a llamar a SaveChangesAsync sobre las mismas entidades, no se disparan otra vez.

El acoplamiento: las entidades que generan eventos se identifican con la interfaz IHasDomainEvent, y la publicación en sí la hace un servicio aparte (IDomainEventService). El contexto solo los recoge y los entrega.

Configuración adicional del modelo

En OnModelCreating hay dos ajustes más que conviene tener:

protected override void OnModelCreating(ModelBuilder builder)
{
// Ignorar DomainEvent en el modelo de la DB
builder.Ignore<DomainEvent>();
builder.Ignore<List<DomainEvent>>();
// Configurar DateTime para usar siempre 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)));
}
}
}
}

El primero evita que EF Core intente mapear los eventos de dominio como si fueran tablas: viven en las entidades, pero no se persisten. El segundo aplica un conversor a todas las propiedades DateTime y DateTime? para que se guarden y se lean siempre como UTC, sin depender de cómo venga cada fecha.

Lo que me llevo

Con esto, la auditoría, la publicación de eventos y el tratamiento de fechas quedan dentro del DbContext, y los casos de uso solo se ocupan de su lógica. Hay que tener presente una cosa: como los eventos se publican después de confirmar la transacción, si la publicación falla los datos ya están guardados. Para muchos casos es suficiente; si un evento no se puede perder, hace falta algo más, como guardar los eventos en la misma transacción y publicarlos después.


Comentarios

Aún no hay comentarios. ¡Sé el primero!

Deja un comentario

Tu nombre y tu comentario se publicarán cuando los apruebe. Más información en la política de privacidad.