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:
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
After delete (soft delete):
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
| Area | Best Practice |
|---|
| Soft Delete Flag | Always use a boolean field like IsDeleted. |
| Query Filters | Use global query filters to avoid manual filtering. |
| Audit Trail Storage | Store JSON in AuditLogs for flexibility. |
| User Tracking | Pass the logged-in user context to the DbContext. |
| Archival Policy | Periodically move soft-deleted data to archive tables. |
Advantages
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.