Unit of Work: One SaveChanges, Automatic Auditing

Unit of Work: One SaveChanges, Automatic Auditing

01 Sep 2026

Unit of Work: One SaveChanges, Automatic Auditing

The Unit of Work pattern ensures that a group of operations are treated as a single transaction. In EF Core, the DbContext itself is the Unit of Work. Every handler calls SaveChangesAsync() as the single commit point. But Brand Website V3 takes this a step further: every module's DbContext overrides SaveChangesAsync to inject automatic audit fields. No handler manually sets CreatedBy or LastModifiedAt. It just happens.

The DbContext as Unit of Work

// Blog.Database/BlogDbContext.cs
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
{
    foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
    {
        switch (entry.State)
        {
            case EntityState.Added:
                entry.Entity.CreatedBy ??= "SYSTEM";
                entry.Entity.CreatedAt = DateTime.Now;
                break;
            case EntityState.Modified:
                entry.Entity.LastModifiedBy ??= "SYSTEM";
                entry.Entity.LastModifiedAt = DateTime.Now;
                break;
        }
    }
    return await base.SaveChangesAsync(cancellationToken);
}

When a handler calls SaveChangesAsync, the DbContext walks through every tracked entity. If it is newly added, the created audit fields are set. If it is modified, the last-modified audit fields are set. Then base.SaveChangesAsync commits everything in a single transaction. The handler does not need to know about auditing. The auditing just works.

The AuditableEntity Base Class

The AuditableEntity base class (from AugusteVN.Database.Entities) provides the audit properties out of the box:

public abstract class AuditableEntity
{
    public string CreatedBy { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public string? LastModifiedBy { get; set; }
    public DateTime? LastModifiedAt { get; set; }
}

Every entity in every module inherits from this base class. The SaveChangesAsync override in each module's DbContext handles the rest. It is consistent, automatic, and impossible to forget.

Why It Works

  • Every commit is atomic. One SaveChangesAsync call, one transaction. Either all changes commit or none do.
await _dbContext.SaveChangesAsync(cancellationToken);
  • Audit fields are never forgotten. The DbContext enforces them on every save. No handler can skip setting CreatedAt or CreatedBy.
entry.Entity.CreatedBy ??= "SYSTEM";
entry.Entity.CreatedAt = DateTime.Now;
  • Consistent across all modules. Every module's DbContext implements the same override pattern. Blog, Reviews, Shop, they all audit the same way.
return await base.SaveChangesAsync(cancellationToken);
  • Handlers stay focused on business logic. The handler creates or updates an entity and calls SaveChangesAsync. That is it. No audit plumbing, no transaction management.
_dbContext.BlogPosts.Update(existingBlogPost);
await _dbContext.SaveChangesAsync(cancellationToken);

Replicated Across Every Module

This pattern is not unique to the Blog module. Every single module in Brand Website V3 has its own DbContext with the same SaveChangesAsync override. It is a convention that is followed everywhere, which means auditing is guaranteed across the entire application.


Brand Website V3 implements the Unit of Work pattern with automatic auditing across all 11 domain modules. Every SaveChangesAsync call is atomic and audit-safe. See the implementation at kiss-code.com.


Join the community

I continuously build, learn and experiment with innovative technology. Allow me to share what I learn, with you.

Newsletter

Allow me to share what I learn, with you.

Share

Support

An error has occurred. 🗙