audit-trail

Audit Trail Generator

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "audit-trail" with this command: npx skills add ronnythedev/dotnet-clean-architecture-skills/ronnythedev-dotnet-clean-architecture-skills-audit-trail

Audit Trail Generator

Overview

Automatic audit trail tracking for entities:

  • IAuditable interface - Standard audit fields

  • SaveChanges interceptor - Automatic field population

  • User context integration - Track who made changes

  • Soft delete support - Track deletions without removing

Quick Reference

Component Purpose

IAuditable

Interface for auditable entities

AuditableEntity

Base class with audit fields

AuditSaveChangesInterceptor

Auto-populates audit fields

SoftDeletable

Interface for soft delete

Audit Structure

/Domain/Abstractions/ ├── IAuditable.cs ├── ISoftDeletable.cs └── AuditableEntity.cs

/Infrastructure/ ├── Interceptors/ │ └── AuditSaveChangesInterceptor.cs └── ApplicationDbContext.cs

Template: Audit Interfaces

// src/{name}.domain/Abstractions/IAuditable.cs namespace {name}.domain.abstractions;

/// <summary> /// Interface for entities that track creation and modification metadata /// </summary> public interface IAuditable { /// <summary> /// UTC timestamp when the entity was created /// </summary> DateTime CreatedAtUtc { get; }

/// &#x3C;summary>
/// ID of the user who created the entity
/// &#x3C;/summary>
Guid? CreatedBy { get; }

/// &#x3C;summary>
/// UTC timestamp when the entity was last modified
/// &#x3C;/summary>
DateTime? UpdatedAtUtc { get; }

/// &#x3C;summary>
/// ID of the user who last modified the entity
/// &#x3C;/summary>
Guid? UpdatedBy { get; }

}

// src/{name}.domain/Abstractions/ISoftDeletable.cs namespace {name}.domain.abstractions;

/// <summary> /// Interface for entities that support soft delete /// </summary> public interface ISoftDeletable { /// <summary> /// Whether the entity has been soft deleted /// </summary> bool IsDeleted { get; }

/// &#x3C;summary>
/// UTC timestamp when the entity was deleted
/// &#x3C;/summary>
DateTime? DeletedAtUtc { get; }

/// &#x3C;summary>
/// ID of the user who deleted the entity
/// &#x3C;/summary>
Guid? DeletedBy { get; }

}

Template: Auditable Entity Base Class

// src/{name}.domain/Abstractions/AuditableEntity.cs namespace {name}.domain.abstractions;

/// <summary> /// Base class for entities that track audit information /// </summary> public abstract class AuditableEntity : Entity, IAuditable, ISoftDeletable { // ═══════════════════════════════════════════════════════════════ // AUDIT FIELDS (IAuditable) // ═══════════════════════════════════════════════════════════════

public DateTime CreatedAtUtc { get; private set; }
public Guid? CreatedBy { get; private set; }
public DateTime? UpdatedAtUtc { get; private set; }
public Guid? UpdatedBy { get; private set; }

// ═══════════════════════════════════════════════════════════════
// SOFT DELETE FIELDS (ISoftDeletable)
// ═══════════════════════════════════════════════════════════════

public bool IsDeleted { get; private set; }
public DateTime? DeletedAtUtc { get; private set; }
public Guid? DeletedBy { get; private set; }

protected AuditableEntity() : base()
{
}

protected AuditableEntity(Guid id) : base(id)
{
}

// ═══════════════════════════════════════════════════════════════
// AUDIT METHODS (called by interceptor or manually)
// ═══════════════════════════════════════════════════════════════

/// &#x3C;summary>
/// Sets creation audit fields. Called automatically by interceptor.
/// &#x3C;/summary>
internal void SetCreatedAudit(DateTime utcNow, Guid? userId)
{
    CreatedAtUtc = utcNow;
    CreatedBy = userId;
}

/// &#x3C;summary>
/// Sets modification audit fields. Called automatically by interceptor.
/// &#x3C;/summary>
internal void SetModifiedAudit(DateTime utcNow, Guid? userId)
{
    UpdatedAtUtc = utcNow;
    UpdatedBy = userId;
}

/// &#x3C;summary>
/// Soft deletes the entity
/// &#x3C;/summary>
public virtual void SoftDelete(DateTime utcNow, Guid? userId)
{
    if (IsDeleted)
    {
        return;
    }

    IsDeleted = true;
    DeletedAtUtc = utcNow;
    DeletedBy = userId;
}

/// &#x3C;summary>
/// Restores a soft-deleted entity
/// &#x3C;/summary>
public virtual void Restore()
{
    IsDeleted = false;
    DeletedAtUtc = null;
    DeletedBy = null;
}

}

Template: SaveChanges Interceptor

// src/{name}.infrastructure/Interceptors/AuditSaveChangesInterceptor.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using {name}.application.abstractions.authentication; using {name}.domain.abstractions;

namespace {name}.infrastructure.interceptors;

/// <summary> /// Interceptor that automatically populates audit fields on SaveChanges /// </summary> public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor { private readonly IUserContext _userContext;

public AuditSaveChangesInterceptor(IUserContext userContext)
{
    _userContext = userContext;
}

public override ValueTask&#x3C;InterceptionResult&#x3C;int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult&#x3C;int> result,
    CancellationToken cancellationToken = default)
{
    if (eventData.Context is not null)
    {
        UpdateAuditFields(eventData.Context);
    }

    return base.SavingChangesAsync(eventData, result, cancellationToken);
}

public override InterceptionResult&#x3C;int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult&#x3C;int> result)
{
    if (eventData.Context is not null)
    {
        UpdateAuditFields(eventData.Context);
    }

    return base.SavingChanges(eventData, result);
}

private void UpdateAuditFields(DbContext context)
{
    var utcNow = DateTime.UtcNow;
    var userId = GetCurrentUserId();

    foreach (var entry in context.ChangeTracker.Entries&#x3C;IAuditable>())
    {
        switch (entry.State)
        {
            case EntityState.Added:
                SetCreatedAudit(entry, utcNow, userId);
                break;

            case EntityState.Modified:
                SetModifiedAudit(entry, utcNow, userId);
                break;
        }
    }

    // Handle soft delete
    foreach (var entry in context.ChangeTracker.Entries&#x3C;ISoftDeletable>())
    {
        if (entry.State == EntityState.Deleted)
        {
            // Convert hard delete to soft delete
            entry.State = EntityState.Modified;
            
            if (entry.Entity is AuditableEntity auditableEntity)
            {
                auditableEntity.SoftDelete(utcNow, userId);
            }
        }
    }
}

private void SetCreatedAudit(EntityEntry&#x3C;IAuditable> entry, DateTime utcNow, Guid? userId)
{
    if (entry.Entity is AuditableEntity auditableEntity)
    {
        auditableEntity.SetCreatedAudit(utcNow, userId);
    }
    else
    {
        // For entities implementing IAuditable but not inheriting AuditableEntity
        entry.Property(nameof(IAuditable.CreatedAtUtc)).CurrentValue = utcNow;
        entry.Property(nameof(IAuditable.CreatedBy)).CurrentValue = userId;
    }
}

private void SetModifiedAudit(EntityEntry&#x3C;IAuditable> entry, DateTime utcNow, Guid? userId)
{
    if (entry.Entity is AuditableEntity auditableEntity)
    {
        auditableEntity.SetModifiedAudit(utcNow, userId);
    }
    else
    {
        entry.Property(nameof(IAuditable.UpdatedAtUtc)).CurrentValue = utcNow;
        entry.Property(nameof(IAuditable.UpdatedBy)).CurrentValue = userId;
    }
}

private Guid? GetCurrentUserId()
{
    try
    {
        return _userContext.IsAuthenticated ? _userContext.UserId : null;
    }
    catch
    {
        // User context may not be available in background jobs
        return null;
    }
}

}

Template: EF Core Configuration for Audit Fields

// src/{name}.infrastructure/Configurations/AuditableEntityConfiguration.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using {name}.domain.abstractions;

namespace {name}.infrastructure.configurations;

/// <summary> /// Base configuration for auditable entities /// Apply using: builder.ApplyConfiguration(new AuditableEntityConfiguration<YourEntity>()); /// </summary> public class AuditableEntityConfiguration<TEntity> : IEntityTypeConfiguration<TEntity> where TEntity : AuditableEntity { public virtual void Configure(EntityTypeBuilder<TEntity> builder) { // ═══════════════════════════════════════════════════════════════ // AUDIT FIELDS // ═══════════════════════════════════════════════════════════════

    builder.Property(e => e.CreatedAtUtc)
        .HasColumnName("created_at_utc")
        .IsRequired();

    builder.Property(e => e.CreatedBy)
        .HasColumnName("created_by");

    builder.Property(e => e.UpdatedAtUtc)
        .HasColumnName("updated_at_utc");

    builder.Property(e => e.UpdatedBy)
        .HasColumnName("updated_by");

    // ═══════════════════════════════════════════════════════════════
    // SOFT DELETE FIELDS
    // ═══════════════════════════════════════════════════════════════
    
    builder.Property(e => e.IsDeleted)
        .HasColumnName("is_deleted")
        .HasDefaultValue(false)
        .IsRequired();

    builder.Property(e => e.DeletedAtUtc)
        .HasColumnName("deleted_at_utc");

    builder.Property(e => e.DeletedBy)
        .HasColumnName("deleted_by");

    // ═══════════════════════════════════════════════════════════════
    // GLOBAL QUERY FILTER (Soft Delete)
    // ═══════════════════════════════════════════════════════════════
    
    builder.HasQueryFilter(e => !e.IsDeleted);

    // ═══════════════════════════════════════════════════════════════
    // INDEXES
    // ═══════════════════════════════════════════════════════════════
    
    builder.HasIndex(e => e.CreatedAtUtc)
        .HasDatabaseName($"ix_{typeof(TEntity).Name.ToLower()}_created_at");

    builder.HasIndex(e => e.IsDeleted)
        .HasDatabaseName($"ix_{typeof(TEntity).Name.ToLower()}_is_deleted")
        .HasFilter("is_deleted = true");
}

}

Template: Using Auditable Entity

// src/{name}.domain/Users/User.cs using {name}.domain.abstractions; using {name}.domain.users.events;

namespace {name}.domain.users;

public sealed class User : AuditableEntity { public string Name { get; private set; } = string.Empty; public Email Email { get; private set; } = null!; public Guid OrganizationId { get; private set; } public bool IsActive { get; private set; }

private User() : base()
{
}

private User(
    Guid id,
    string name,
    Email email,
    Guid organizationId)
    : base(id)
{
    Name = name;
    Email = email;
    OrganizationId = organizationId;
    IsActive = true;
}

public static Result&#x3C;User> Create(
    string name,
    string email,
    Guid organizationId)
{
    var emailResult = Email.Create(email);
    if (emailResult.IsFailure)
    {
        return Result.Failure&#x3C;User>(emailResult.Error);
    }

    var user = new User(
        Guid.NewGuid(),
        name,
        emailResult.Value,
        organizationId);

    user.RaiseDomainEvent(new UserCreatedDomainEvent(user.Id));

    return user;
    // CreatedAtUtc and CreatedBy will be set automatically by interceptor
}

public Result UpdateName(string name)
{
    if (string.IsNullOrWhiteSpace(name))
    {
        return Result.Failure(UserErrors.NameRequired);
    }

    Name = name;
    // UpdatedAtUtc and UpdatedBy will be set automatically by interceptor

    return Result.Success();
}

public void Deactivate()
{
    IsActive = false;
    RaiseDomainEvent(new UserDeactivatedDomainEvent(Id));
}

}

Template: Entity Configuration Using Auditable Base

// src/{name}.infrastructure/Configurations/UserConfiguration.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using {name}.domain.users;

namespace {name}.infrastructure.configurations;

internal sealed class UserConfiguration : AuditableEntityConfiguration<User> { public override void Configure(EntityTypeBuilder<User> builder) { // Call base configuration for audit fields base.Configure(builder);

    builder.ToTable("users");

    builder.HasKey(u => u.Id);

    builder.Property(u => u.Name)
        .HasColumnName("name")
        .HasMaxLength(100)
        .IsRequired();

    builder.OwnsOne(u => u.Email, emailBuilder =>
    {
        emailBuilder.Property(e => e.Value)
            .HasColumnName("email")
            .HasMaxLength(255)
            .IsRequired();

        emailBuilder.HasIndex(e => e.Value)
            .IsUnique();
    });

    builder.Property(u => u.OrganizationId)
        .HasColumnName("organization_id")
        .IsRequired();

    builder.Property(u => u.IsActive)
        .HasColumnName("is_active")
        .HasDefaultValue(true)
        .IsRequired();

    builder.HasIndex(u => u.OrganizationId)
        .HasDatabaseName("ix_users_organization_id");
}

}

Template: DbContext Registration

// src/{name}.infrastructure/ApplicationDbContext.cs using Microsoft.EntityFrameworkCore; using {name}.domain.abstractions;

namespace {name}.infrastructure;

public sealed class ApplicationDbContext : DbContext, IUnitOfWork { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
    
    base.OnModelCreating(modelBuilder);
}

}

// Registration in DependencyInjection.cs public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { services.AddDbContext<ApplicationDbContext>((sp, options) => { options.UseNpgsql(configuration.GetConnectionString("Database")); options.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()); });

services.AddScoped&#x3C;AuditSaveChangesInterceptor>();

return services;

}

Template: Audit Log Entity (Optional - Full History)

// src/{name}.domain/Auditing/AuditLog.cs namespace {name}.domain.auditing;

/// <summary> /// Stores complete audit history of changes /// </summary> public sealed class AuditLog { public Guid Id { get; set; } public string EntityName { get; set; } = string.Empty; public string EntityId { get; set; } = string.Empty; public string Action { get; set; } = string.Empty; // Created, Modified, Deleted public string? OldValues { get; set; } // JSON public string? NewValues { get; set; } // JSON public string? AffectedColumns { get; set; } // JSON array public DateTime OccurredAtUtc { get; set; } public Guid? UserId { get; set; } public string? UserName { get; set; } public string? IpAddress { get; set; } }

// src/{name}.infrastructure/Interceptors/AuditLogInterceptor.cs using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using {name}.application.abstractions.authentication; using {name}.domain.auditing;

namespace {name}.infrastructure.interceptors;

/// <summary> /// Interceptor that logs complete change history to AuditLog table /// </summary> public sealed class AuditLogInterceptor : SaveChangesInterceptor { private readonly IUserContext _userContext;

public AuditLogInterceptor(IUserContext userContext)
{
    _userContext = userContext;
}

public override async ValueTask&#x3C;InterceptionResult&#x3C;int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult&#x3C;int> result,
    CancellationToken cancellationToken = default)
{
    if (eventData.Context is ApplicationDbContext context)
    {
        await CreateAuditLogs(context, cancellationToken);
    }

    return await base.SavingChangesAsync(eventData, result, cancellationToken);
}

private async Task CreateAuditLogs(ApplicationDbContext context, CancellationToken ct)
{
    context.ChangeTracker.DetectChanges();

    var auditLogs = new List&#x3C;AuditLog>();
    var utcNow = DateTime.UtcNow;
    var userId = _userContext.IsAuthenticated ? _userContext.UserId : (Guid?)null;

    foreach (var entry in context.ChangeTracker.Entries())
    {
        if (entry.Entity is AuditLog || 
            entry.State == EntityState.Detached || 
            entry.State == EntityState.Unchanged)
        {
            continue;
        }

        var auditLog = new AuditLog
        {
            Id = Guid.NewGuid(),
            EntityName = entry.Entity.GetType().Name,
            EntityId = GetPrimaryKeyValue(entry),
            OccurredAtUtc = utcNow,
            UserId = userId
        };

        switch (entry.State)
        {
            case EntityState.Added:
                auditLog.Action = "Created";
                auditLog.NewValues = SerializeProperties(entry, p => p.CurrentValue);
                break;

            case EntityState.Modified:
                auditLog.Action = "Modified";
                auditLog.OldValues = SerializeProperties(entry, p => p.OriginalValue, true);
                auditLog.NewValues = SerializeProperties(entry, p => p.CurrentValue, true);
                auditLog.AffectedColumns = JsonSerializer.Serialize(
                    entry.Properties
                        .Where(p => p.IsModified)
                        .Select(p => p.Metadata.Name)
                        .ToList());
                break;

            case EntityState.Deleted:
                auditLog.Action = "Deleted";
                auditLog.OldValues = SerializeProperties(entry, p => p.OriginalValue);
                break;
        }

        auditLogs.Add(auditLog);
    }

    if (auditLogs.Any())
    {
        await context.Set&#x3C;AuditLog>().AddRangeAsync(auditLogs, ct);
    }
}

private static string GetPrimaryKeyValue(EntityEntry entry)
{
    var keyProperty = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey());
    return keyProperty?.CurrentValue?.ToString() ?? "unknown";
}

private static string? SerializeProperties(
    EntityEntry entry,
    Func&#x3C;PropertyEntry, object?> valueSelector,
    bool onlyModified = false)
{
    var properties = entry.Properties
        .Where(p => !p.Metadata.IsPrimaryKey())
        .Where(p => !onlyModified || p.IsModified)
        .ToDictionary(
            p => p.Metadata.Name,
            p => valueSelector(p));

    return properties.Any() 
        ? JsonSerializer.Serialize(properties) 
        : null;
}

}

Template: Query Including Deleted Records

// When you need to include soft-deleted records: public async Task<User?> GetByIdIncludingDeletedAsync( Guid id, CancellationToken cancellationToken) { return await _dbContext.Users .IgnoreQueryFilters() // Include soft-deleted records .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); }

// Query only deleted records: public async Task<IReadOnlyList<User>> GetDeletedUsersAsync( CancellationToken cancellationToken) { return await _dbContext.Users .IgnoreQueryFilters() .Where(u => u.IsDeleted) .OrderByDescending(u => u.DeletedAtUtc) .ToListAsync(cancellationToken); }

Database Migration

-- Add audit columns to existing table ALTER TABLE users ADD COLUMN created_at_utc TIMESTAMP NOT NULL DEFAULT NOW(), ADD COLUMN created_by UUID NULL, ADD COLUMN updated_at_utc TIMESTAMP NULL, ADD COLUMN updated_by UUID NULL, ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN deleted_at_utc TIMESTAMP NULL, ADD COLUMN deleted_by UUID NULL;

-- Create indexes CREATE INDEX ix_users_created_at ON users (created_at_utc); CREATE INDEX ix_users_is_deleted ON users (is_deleted) WHERE is_deleted = TRUE;

-- Audit log table (if using full history) CREATE TABLE audit_log ( id UUID PRIMARY KEY, entity_name VARCHAR(255) NOT NULL, entity_id VARCHAR(255) NOT NULL, action VARCHAR(50) NOT NULL, old_values JSONB NULL, new_values JSONB NULL, affected_columns JSONB NULL, occurred_at_utc TIMESTAMP NOT NULL, user_id UUID NULL, user_name VARCHAR(255) NULL, ip_address VARCHAR(50) NULL );

CREATE INDEX ix_audit_log_entity ON audit_log (entity_name, entity_id); CREATE INDEX ix_audit_log_occurred_at ON audit_log (occurred_at_utc DESC); CREATE INDEX ix_audit_log_user_id ON audit_log (user_id);

Critical Rules

  • UTC timestamps always - Never use local time

  • Interceptor order matters - Register after other interceptors

  • Handle null user context - Background jobs have no user

  • Query filters are global - Use IgnoreQueryFilters() when needed

  • Audit fields are readonly - Only interceptor should modify

  • Index audit columns - CreatedAtUtc, IsDeleted commonly queried

  • Soft delete by default - Convert hard delete to soft

  • Keep audit log table lean - Archive old records periodically

Anti-Patterns to Avoid

// ❌ WRONG: Setting audit fields manually user.CreatedAtUtc = DateTime.Now; // Let interceptor handle it!

// ✅ CORRECT: Interceptor handles automatically var user = User.Create(...); await _unitOfWork.SaveChangesAsync(); // Audit fields set by interceptor

// ❌ WRONG: Using local time builder.Property(e => e.CreatedAtUtc) .HasDefaultValueSql("NOW()"); // Could be local time!

// ✅ CORRECT: Explicitly UTC builder.Property(e => e.CreatedAtUtc) .HasDefaultValueSql("NOW() AT TIME ZONE 'UTC'");

// ❌ WRONG: Forgetting query filters exist var allUsers = await _dbContext.Users.ToListAsync(); // Missing deleted users might be intentional, but be aware!

// ✅ CORRECT: Explicit about including deleted var allUsersIncludingDeleted = await _dbContext.Users .IgnoreQueryFilters() .ToListAsync();

Related Skills

  • domain-entity-generator

  • Entity base classes

  • ef-core-configuration

  • Entity configuration

  • jwt-authentication

  • IUserContext for tracking user

  • repository-pattern

  • Repository methods with soft delete

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

dotnet-clean-architecture

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

unit-testing

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

quartz-background-jobs

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

dapper-query-builder

No summary provided by upstream source.

Repository SourceNeeds Review