ASP.NET Core  

Implementing Soft Delete and Auditing in ASP.NET Core with EF Core

Introduction

In enterprise applications, data management isn’t just about storing and retrieving records — it’s also about preserving historical context and preventing accidental data loss.
This is where Soft Delete and Auditing come into play.

In this article, we’ll explore how to implement soft delete and auditing in ASP.NET Core using Entity Framework Core, with clean architecture, reusable code, and real-world best practices.

What Is Soft Delete?

Soft delete is a strategy where records are not physically removed from the database.
Instead, they are marked as deleted using a flag (e.g., IsDeleted = true), and excluded from queries automatically.

This helps to:

  • Prevent data loss by accidental deletion.

  • Maintain historical integrity.

  • Allow easy data restoration.

What Is Auditing?

Auditing means tracking who changed what and when — including inserts, updates, and deletes.
For example, you might want to know:

  • Which user updated an order?

  • When was a product deleted?

  • What fields were changed during an update?

Auditing improves traceability, security, and data compliance.

Technical Workflow (Flowchart)

flowchart TD
A[Client Sends Request] --> B[API Controller Calls EF Core]
B --> C[SaveChanges Intercepted]
C --> D{Operation Type?}
D -->|Insert| E[Add CreatedDate, CreatedBy]
D -->|Update| F[Add ModifiedDate, ModifiedBy]
D -->|Delete| G[Mark IsDeleted = true]
G --> H[Skip Physical Deletion]
F --> I[Write Audit Log Entry]
E --> I
I --> J[Save to Database]
J --> K[Response to Client]

Step 1: Define the Base Entity

We’ll create a base class for all entities that need soft delete and auditing fields.

public abstract class BaseEntity
{
    public int Id { get; set; }

    // Auditing Fields
    public string CreatedBy { get; set; }
    public DateTime CreatedDate { get; set; }
    public string? ModifiedBy { get; set; }
    public DateTime? ModifiedDate { get; set; }

    // Soft Delete Field
    public bool IsDeleted { get; set; }
}

All your entities (e.g., Product, Customer, etc.) will inherit this class.

Step 2: Example Entity Model

public class Product : BaseEntity
{
    public string Name { get; set; }
    public decimal Price { get; set; }
}

Step 3: Configure Soft Delete Query Filter

In EF Core, we can use a Global Query Filter to automatically exclude soft-deleted records.

public class AppDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply global filter for soft delete
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
            {
                modelBuilder.Entity(entityType.ClrType)
                    .HasQueryFilter(GetIsDeletedRestriction(entityType.ClrType));
            }
        }
    }

    private static LambdaExpression GetIsDeletedRestriction(Type type)
    {
        var param = Expression.Parameter(type, "e");
        var prop = Expression.Property(param, nameof(BaseEntity.IsDeleted));
        var condition = Expression.Equal(prop, Expression.Constant(false));
        return Expression.Lambda(condition, param);
    }
}

Now all EF Core queries automatically exclude records with IsDeleted = true.

Step 4: Override SaveChanges for Auditing and Soft Delete

We’ll intercept EF Core’s SaveChanges method to update audit fields and apply soft delete logic.

public override int SaveChanges()
{
    var entries = ChangeTracker.Entries<BaseEntity>();

    foreach (var entry in entries)
    {
        switch (entry.State)
        {
            case EntityState.Added:
                entry.Entity.CreatedDate = DateTime.UtcNow;
                entry.Entity.CreatedBy = "System"; // Replace with logged-in user
                break;

            case EntityState.Modified:
                entry.Entity.ModifiedDate = DateTime.UtcNow;
                entry.Entity.ModifiedBy = "System";
                break;

            case EntityState.Deleted:
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.ModifiedDate = DateTime.UtcNow;
                entry.Entity.ModifiedBy = "System";
                break;
        }
    }

    return base.SaveChanges();
}

This ensures that:

  • Soft delete replaces actual deletion.

  • Audit fields are updated automatically.


Step 5: Create an Audit Log Table

To record changes in detail, we’ll add an AuditLog entity.

public class AuditLog
{
    public int Id { get; set; }
    public string TableName { get; set; }
    public string KeyValues { get; set; }
    public string? OldValues { get; set; }
    public string? NewValues { get; set; }
    public string Action { get; set; }
    public string UserName { get; set; }
    public DateTime Timestamp { get; set; }
}

Step 6: Implement Audit Logging in SaveChanges

We can extend our context to log changes before saving them.

private List<AuditLog> OnBeforeSaveChanges()
{
    ChangeTracker.DetectChanges();
    var auditEntries = new List<AuditLog>();

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

        var auditEntry = new AuditLog
        {
            TableName = entry.Entity.GetType().Name,
            UserName = "System",
            Timestamp = DateTime.UtcNow,
            Action = entry.State.ToString(),
            KeyValues = JsonSerializer.Serialize(
                entry.Properties.Where(p => p.Metadata.IsPrimaryKey()).ToDictionary(p => p.Metadata.Name, p => p.CurrentValue))
        };

        if (entry.State == EntityState.Added)
        {
            auditEntry.NewValues = JsonSerializer.Serialize(entry.CurrentValues.ToObject());
        }
        else if (entry.State == EntityState.Modified)
        {
            auditEntry.OldValues = JsonSerializer.Serialize(entry.OriginalValues.ToObject());
            auditEntry.NewValues = JsonSerializer.Serialize(entry.CurrentValues.ToObject());
        }
        else if (entry.State == EntityState.Deleted)
        {
            auditEntry.OldValues = JsonSerializer.Serialize(entry.OriginalValues.ToObject());
        }

        auditEntries.Add(auditEntry);
    }

    return auditEntries;
}

public override int SaveChanges()
{
    var auditLogs = OnBeforeSaveChanges();

    var result = base.SaveChanges();

    if (auditLogs.Any())
    {
        AuditLogs.AddRange(auditLogs);
        base.SaveChanges();
    }

    return result;
}

Now, every insert, update, and delete automatically generates an entry in the AuditLogs table.

Step 7: Example API Controller

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _context;

    public ProductsController(AppDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    public IActionResult GetAll() => Ok(_context.Products.ToList());

    [HttpPost]
    public IActionResult Create(Product product)
    {
        _context.Products.Add(product);
        _context.SaveChanges();
        return Ok(product);
    }

    [HttpDelete("{id}")]
    public IActionResult Delete(int id)
    {
        var product = _context.Products.Find(id);
        if (product == null) return NotFound();

        _context.Products.Remove(product);
        _context.SaveChanges();
        return Ok("Soft deleted successfully");
    }
}

When you call the DELETE API, it won’t remove the record but mark it as deleted.

Step 8: Verifying Results

Before delete

SELECT * FROM Product
IdNameIsDeleted
1Laptop0

After delete (soft delete):

IdNameIsDeleted
1Laptop1

The record stays in the database, and you can restore it anytime.

Step 9: Restore Deleted Records

If needed, we can restore soft-deleted records:

[HttpPut("restore/{id}")]
public IActionResult Restore(int id)
{
    var product = _context.Products.IgnoreQueryFilters().FirstOrDefault(p => p.Id == id);
    if (product == null) return NotFound();

    product.IsDeleted = false;
    _context.SaveChanges();
    return Ok("Record restored successfully");
}

Best Practices

AreaBest Practice
Soft Delete FlagAlways use a boolean field like IsDeleted.
Query FiltersUse global query filters to avoid manual filtering.
Audit Trail StorageStore JSON in AuditLogs for flexibility.
User TrackingPass the logged-in user context to the DbContext.
Archival PolicyPeriodically move soft-deleted data to archive tables.

Advantages

  • Prevents data loss

  • Simplifies recovery

  • Improves accountability

  • Enables historical data tracking

  • Supports compliance and auditing standards

Conclusion

Soft delete and auditing are critical for building secure, enterprise-grade applications.
With EF Core’s global query filters, interceptors, and SaveChanges overrides, you can implement these features cleanly and maintainably.

By following this approach, you ensure your ASP.NET Core applications maintain data integrity, traceability, and compliance without compromising performance or scalability.