Xóa data là chuyện hàng ngày. Nhưng xóa thật — DELETE FROM — thì hầu như không ai dám làm trên production. Lỡ xóa nhầm thì sao? Audit trail mất hết thì sao? Khách hàng hỏi "invoice tháng trước đâu rồi" thì trả lời gì?
Soft delete giải quyết vấn đề này: thay vì xóa record, bạn đánh dấu nó là "đã xóa" bằng một flag — thường là column IsDeleted = true. Record vẫn nằm trong database, nhưng ứng dụng không hiển thị nó nữa.
Nghe đơn giản. Nhưng implement sai thì hậu quả nghiêm trọng: data leak giữa các tenant, performance sập vì thiếu index, unique constraint bị phá vỡ, và cả đống query quên filter IsDeleted. Bài viết này mình đi qua cách implement soft delete đúng cách trong EF Core, từ nền tảng đến những edge case mà mình từng gặp trong dự án thực tế.
Tại sao không chỉ thêm WHERE IsDeleted = false?
Cách đơn giản nhất để implement soft delete: thêm column IsDeleted vào entity, rồi mỗi query thêm .Where(x => !x.IsDeleted). Nhiều dự án bắt đầu bằng cách này — mình cũng vậy.
Vấn đề: con người hay quên. Trong dự án có 50 query, bạn quên thêm filter ở 1 query → data đã xóa hiện ra. Nếu dự án multi-tenant, quên filter ở 1 query → khách hàng A thấy data của khách hàng B đã bị xóa. Đây không phải rủi ro lý thuyết — mình đã gặp bug này trên production.
EF Core cung cấp Global Query Filter — filter tự động được áp dụng vào mọi query trên một entity. Bạn config một lần, EF Core tự thêm WHERE clause cho bạn. Không cần nhớ, không cần sợ quên.
Bước 1: Tạo base entity với soft delete fields
public abstract class BaseEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
Mình luôn thêm DeletedAt bên cạnh IsDeleted. Lý do: khi audit hoặc debug, bạn cần biết record bị xóa lúc nào, không chỉ biết nó đã bị xóa. IsDeleted dùng cho filter, DeletedAt dùng cho audit — hai mục đích khác nhau.
Tất cả entity kế thừa BaseEntity:
public class Invoice : BaseEntity
{
public string InvoiceNumber { get; set; } = string.Empty;
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public decimal TotalAmount { get; set; }
public InvoiceStatus Status { get; set; }
public int TenantId { get; set; }
public List<InvoiceLineItem> LineItems { get; set; } = new();
}
public class Customer : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int TenantId { get; set; }
public List<Invoice> Invoices { get; set; } = new();
}
Bước 2: Config Global Query Filter trong DbContext
public class AppDbContext : DbContext
{
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Áp dụng soft delete filter cho từng entity
modelBuilder.Entity<Invoice>().HasQueryFilter(x => !x.IsDeleted);
modelBuilder.Entity<Customer>().HasQueryFilter(x => !x.IsDeleted);
modelBuilder.Entity<InvoiceLineItem>().HasQueryFilter(x => !x.IsDeleted);
}
}
Từ giờ, mọi query trên Invoice tự động có WHERE "IsDeleted" = false. Bạn không cần nhớ thêm gì:
// Code bạn viết
var invoices = await _context.Invoices.ToListAsync();
// SQL EF Core sinh ra
// SELECT * FROM "Invoices" WHERE "IsDeleted" = false
Cả Include() cũng được filter tự động:
var invoices = await _context.Invoices
.Include(i => i.Customer)
.Include(i => i.LineItems)
.ToListAsync();
// SQL: JOIN với Customer và LineItems đều có WHERE "IsDeleted" = false
Đây là sức mạnh chính của Global Query Filter — nó áp dụng ở mọi nơi, kể cả navigation property.
Bước 3: Tự động áp dụng filter cho tất cả entity
Config từng entity thủ công rất tẻ nhạt và dễ quên khi thêm entity mới. Mình dùng reflection để tự động áp dụng cho mọi entity kế thừa BaseEntity:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Tự động áp dụng soft delete filter cho tất cả entity
// kế thừa BaseEntity
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
continue;
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(BaseEntity.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
Viết bằng Expression Tree nhìn phức tạp, nhưng logic đơn giản: duyệt tất cả entity, nếu kế thừa BaseEntity thì thêm filter e => e.IsDeleted == false. Thêm entity mới chỉ cần kế thừa BaseEntity — filter tự có.
Bước 4: Override SaveChanges — Chuyển Delete thành Update
Khi developer gọi _context.Invoices.Remove(invoice), EF Core mặc định sinh DELETE query. Mình override SaveChanges để chuyển thành UPDATE:
public override int SaveChanges()
{
HandleSoftDelete();
return base.SaveChanges();
}
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
HandleSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
private void HandleSoftDelete()
{
var entries = ChangeTracker.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in entries)
{
// Chuyển từ Delete sang Modified
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
}
Sau khi override, code xóa record trông hoàn toàn bình thường — developer không cần biết đằng sau là soft delete:
// Developer viết code xóa bình thường
var invoice = await _context.Invoices.FindAsync(id);
if (invoice is not null)
{
_context.Invoices.Remove(invoice);
await _context.SaveChangesAsync();
}
// Nhưng SQL sinh ra là UPDATE, không phải DELETE:
// UPDATE "Invoices" SET "IsDeleted" = true, "DeletedAt" = '2026-02-28'
// WHERE "Id" = @id
Approach này có ưu điểm: developer không cần học API mới, code xóa giống như không có soft delete. Nhưng cũng có nhược điểm: implicit behavior — nhìn code thấy Remove() nhưng thực tế không xóa. Team mới vào dự án có thể confuse. Mình giải quyết bằng cách document rõ trong README và thêm comment trong DbContext.
Bước 5: Kết hợp Soft Delete với Multi-tenant
Nếu dự án multi-tenant, bạn cần kết hợp hai filter: IsDeleted và TenantId. Đây là chỗ phức tạp vì EF Core chỉ cho phép một HasQueryFilter cho mỗi entity — filter sau ghi đè filter trước, không phải kết hợp.
// SAI — filter thứ hai ghi đè filter thứ nhất
modelBuilder.Entity<Invoice>().HasQueryFilter(x => !x.IsDeleted);
modelBuilder.Entity<Invoice>().HasQueryFilter(x => x.TenantId == tenantId);
// Kết quả: chỉ filter theo TenantId, KHÔNG filter IsDeleted!
Phải kết hợp cả hai điều kiện trong một filter:
public class AppDbContext : DbContext
{
private readonly int _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider) : base(options)
{
_tenantId = tenantProvider.GetCurrentTenantId();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
continue;
// Kiểm tra entity có TenantId không
var hasTenantId = entityType.ClrType
.GetProperty("TenantId") is not null;
var parameter = Expression.Parameter(entityType.ClrType, "e");
// Luôn có: IsDeleted == false
var isDeletedProp = Expression.Property(parameter, "IsDeleted");
var isNotDeleted = Expression.Equal(
isDeletedProp, Expression.Constant(false));
Expression filterBody = isNotDeleted;
if (hasTenantId)
{
// Thêm: TenantId == _tenantId
var tenantIdProp = Expression.Property(parameter, "TenantId");
// Dùng EF.Property để reference backing field
var tenantIdValue = Expression.Property(
Expression.Constant(this), nameof(_tenantId));
var tenantFilter = Expression.Equal(tenantIdProp,
Expression.Convert(tenantIdValue, typeof(int)));
filterBody = Expression.AndAlso(isNotDeleted, tenantFilter);
}
var lambda = Expression.Lambda(filterBody, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}
Bây giờ mọi query tự động có cả hai filter:
-- Mọi query tự động:
SELECT * FROM "Invoices"
WHERE "IsDeleted" = false AND "TenantId" = @tenantId
Đây là pattern mình dùng trong dự án thực tế — một lần config, áp dụng cho toàn bộ entity. Không bao giờ quên filter, không bao giờ data leak giữa tenant.
Khi cần bỏ qua filter — IgnoreQueryFilters
Có những trường hợp cần truy cập data đã soft-delete: admin muốn xem record bị xóa, report cần tính tổng bao gồm cả record đã xóa, hoặc cần restore record.
// Lấy tất cả invoice, kể cả đã xóa
var allInvoices = await _context.Invoices
.IgnoreQueryFilters()
.ToListAsync();
// Chỉ lấy invoice đã xóa
var deletedInvoices = await _context.Invoices
.IgnoreQueryFilters()
.Where(i => i.IsDeleted)
.ToListAsync();
Cẩn thận: IgnoreQueryFilters() bỏ TẤT CẢ global query filter — bao gồm cả tenant filter. Trong hệ thống multi-tenant, nếu bạn gọi IgnoreQueryFilters() mà quên thêm .Where(x => x.TenantId == tenantId), bạn sẽ thấy data của tất cả tenant. Đây là lỗ hổng bảo mật nghiêm trọng.
Mình tạo extension method an toàn hơn:
public static class QueryableExtensions
{
/// <summary>
/// Bỏ qua soft delete filter nhưng GIỮ NGUYÊN tenant filter.
/// Dùng khi cần truy cập record đã soft-delete trong cùng tenant.
/// </summary>
public static IQueryable<T> IncludeDeleted<T>(
this IQueryable<T> query,
int tenantId) where T : BaseEntity
{
return query
.IgnoreQueryFilters()
.Where(e => EF.Property<int>(e, "TenantId") == tenantId);
}
}
Sử dụng:
// An toàn — vẫn filter theo tenant
var allInvoices = await _context.Invoices
.IncludeDeleted(_tenantId)
.ToListAsync();
Không hoàn hảo vì IncludeDeleted giả sử entity có TenantId, nhưng với dự án mà hầu hết entity đều multi-tenant thì đủ dùng. Entity không có TenantId (như lookup table) thì dùng IgnoreQueryFilters() trực tiếp.
Restore — Khôi phục record đã xóa
Soft delete có nghĩa là có thể undo. Implement khá đơn giản:
public async Task RestoreInvoiceAsync(int id)
{
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.Id == id && i.IsDeleted
&& i.TenantId == _tenantId);
if (invoice is null)
throw new NotFoundException($"Deleted invoice {id} not found");
invoice.IsDeleted = false;
invoice.DeletedAt = null;
invoice.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
Nhớ kiểm tra cả TenantId khi restore — không muốn tenant A restore invoice của tenant B.
Index — Đừng quên performance
Thêm IsDeleted column mà không thêm index sẽ chậm rõ rệt trên table lớn. Mọi query đều có WHERE IsDeleted = false, nên cần index.
Filtered index — Tốt nhất cho soft delete
PostgreSQL hỗ trợ filtered index (partial index) — chỉ index những row thỏa mãn điều kiện:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Invoice>(entity =>
{
// Filtered index: chỉ index record chưa xóa
entity.HasIndex(e => e.Id)
.HasFilter("\"IsDeleted\" = false");
// Composite filtered index cho query phổ biến
entity.HasIndex(e => new { e.TenantId, e.Status, e.CreatedAt })
.HasFilter("\"IsDeleted\" = false")
.HasDatabaseName("IX_Invoices_Tenant_Status_Created_Active");
});
}
Filtered index nhỏ hơn và nhanh hơn full index vì chỉ chứa active records. Nếu 95% record chưa bị xóa, filtered index chỉ nhỏ hơn một chút. Nhưng nếu bảng có nhiều record bị xóa theo thời gian (ví dụ audit log, notification), filtered index tạo ra sự khác biệt lớn.
Unique constraint với soft delete
Đây là vấn đề kinh điển: bạn muốn InvoiceNumber unique trong cùng TenantId. Nhưng khi soft delete một invoice rồi tạo mới với cùng InvoiceNumber — unique constraint bị vi phạm vì record cũ vẫn tồn tại.
Giải pháp: unique index chỉ áp dụng cho active records:
modelBuilder.Entity<Invoice>(entity =>
{
entity.HasIndex(e => new { e.TenantId, e.InvoiceNumber })
.IsUnique()
.HasFilter("\"IsDeleted\" = false")
.HasDatabaseName("IX_Invoices_Tenant_InvoiceNumber_Unique_Active");
});
Index này đảm bảo: trong cùng tenant, không thể có hai active invoice cùng InvoiceNumber. Nhưng có thể có một active và một deleted cùng InvoiceNumber — đúng behavior mong muốn.
Lưu ý: filtered unique index là feature của PostgreSQL. SQL Server cũng hỗ trợ, nhưng cú pháp hơi khác. MySQL không hỗ trợ — bạn cần workaround khác.
Cascade soft delete — Xóa cả child entities
Khi soft delete một Customer, bạn có muốn soft delete tất cả Invoice của customer đó không? EF Core không tự làm điều này — cascade delete của database chỉ hoạt động với DELETE thật.
Mình handle cascade soft delete trong SaveChanges:
private void HandleSoftDelete()
{
var entries = ChangeTracker.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Deleted)
.ToList();
foreach (var entry in entries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
// Cascade soft delete cho child entities
CascadeSoftDelete(entry);
}
}
private void CascadeSoftDelete(EntityEntry<BaseEntity> entry)
{
// Tìm tất cả navigation properties trỏ đến collection
var navigations = entry.Navigations
.Where(n => n.Metadata.IsCollection
&& n.CurrentValue is not null);
foreach (var nav in navigations)
{
if (nav.CurrentValue is not IEnumerable<BaseEntity> children)
continue;
foreach (var child in children)
{
child.IsDeleted = true;
child.DeletedAt = DateTime.UtcNow;
Entry(child).State = EntityState.Modified;
}
}
}
Cách này đơn giản nhưng có hạn chế: chỉ cascade 1 level sâu, và chỉ cascade những child đã được load (Include). Nếu bạn soft delete customer mà không Include invoices, invoices không bị cascade. Với dự án mình, 1 level đủ dùng. Nếu cần cascade sâu hơn, có thể dùng recursive hoặc raw SQL.
Một approach khác: không cascade trong code, mà dùng trigger trong PostgreSQL:
CREATE OR REPLACE FUNCTION cascade_soft_delete_customer()
RETURNS TRIGGER AS $$
BEGIN
IF NEW."IsDeleted" = true AND OLD."IsDeleted" = false THEN
UPDATE "Invoices"
SET "IsDeleted" = true, "DeletedAt" = NOW()
WHERE "CustomerId" = NEW."Id" AND "IsDeleted" = false;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_customer_soft_delete
AFTER UPDATE ON "Customers"
FOR EACH ROW
EXECUTE FUNCTION cascade_soft_delete_customer();
Trigger đảm bảo cascade luôn xảy ra bất kể code gọi từ đâu — EF Core, raw SQL, hay tool quản lý database. Nhưng trade-off: logic nằm trong database, khó test, khó debug.
Mình chọn handle trong code (SaveChanges) cho dự án nhỏ-trung bình, trigger cho dự án lớn cần đảm bảo tuyệt đối.
Những sai lầm phổ biến
Sai lầm 1: Quên filter trong raw SQL
Global Query Filter chỉ áp dụng cho LINQ query qua EF Core. Raw SQL không được filter:
// CÓ filter — qua LINQ
var invoices = await _context.Invoices.ToListAsync();
// KHÔNG CÓ filter — raw SQL
var invoices = await _context.Invoices
.FromSqlRaw("SELECT * FROM \"Invoices\"")
.ToListAsync();
// Trả về CẢ record đã xóa!
Giải pháp: luôn thêm WHERE "IsDeleted" = false trong raw SQL. Hoặc tốt hơn: tạo VIEW trong PostgreSQL:
CREATE VIEW "ActiveInvoices" AS
SELECT * FROM "Invoices" WHERE "IsDeleted" = false;
Rồi dùng raw SQL trên view thay vì table trực tiếp.
Sai lầm 2: Include navigation property đã bị xóa
Global Query Filter áp dụng cho Include, nhưng có edge case: nếu child entity không có query filter, Include sẽ load cả record đã xóa.
// Nếu InvoiceLineItem KHÔNG có HasQueryFilter:
var invoice = await _context.Invoices
.Include(i => i.LineItems) // Load CẢ line items, kể cả deleted!
.FirstOrDefaultAsync(i => i.Id == id);
Lý do mình dùng approach tự động áp dụng filter qua reflection ở Bước 3 — đảm bảo mọi entity đều có filter, không bỏ sót.
Sai lầm 3: Count sai vì không tính deleted records
// Đếm chỉ active invoices (do Global Query Filter)
var activeCount = await _context.Invoices.CountAsync();
// Nếu cần tổng số bao gồm deleted (ví dụ cho report):
var totalCount = await _context.Invoices
.IgnoreQueryFilters()
.Where(i => i.TenantId == _tenantId) // Nhớ filter tenant!
.CountAsync();
Sai lầm 4: Không có chiến lược cleanup
Soft delete nghĩa là data tích lũy mãi — table càng ngày càng lớn. Cần có chiến lược xóa vĩnh viễn record đã soft-delete quá lâu:
// Background job chạy hàng tuần
public async Task PurgeOldDeletedRecords()
{
var cutoffDate = DateTime.UtcNow.AddMonths(-6);
// Hard delete records soft-deleted hơn 6 tháng
await _context.Database.ExecuteSqlRawAsync(@"
DELETE FROM ""Invoices""
WHERE ""IsDeleted"" = true
AND ""DeletedAt"" < {0}", cutoffDate);
}
Thời gian giữ lại phụ thuộc vào yêu cầu business và compliance. Mình thường giữ 6-12 tháng cho data thường, lâu hơn cho financial data.
Tóm tắt pattern hoàn chỉnh
Kết hợp tất cả lại, pattern soft delete hoàn chỉnh gồm 5 phần:
Một là BaseEntity với IsDeleted và DeletedAt. Hai là Global Query Filter áp dụng tự động qua reflection, kết hợp với tenant filter nếu multi-tenant. Ba là override SaveChanges để chuyển Delete thành Update, kèm cascade cho child entities. Bốn là filtered index trên PostgreSQL cho performance và unique constraint đúng cách. Năm là extension method IncludeDeleted an toàn thay vì IgnoreQueryFilters trực tiếp.
Pattern này mình đã dùng qua nhiều dự án, xử lý tốt cả single-tenant lẫn multi-tenant, và quan trọng nhất: developer không cần nhớ thêm gì khi viết query — Global Query Filter lo phần filter, SaveChanges lo phần xóa. Hai thứ phức tạp nhất được xử lý ở một chỗ, phần còn lại code bình thường như không có soft delete.
Leave a comment
Your email address will not be published. Required fields are marked *