MVCBeginnersSeptember 2026 · 6 min read

EF Core Soft Delete and Audit Fields in ASP.NET Core MVC

By go2ismail · Published · .NET 10

TL;DR

Soft delete marks a row as deleted instead of removing it. A global query filter hides marked rows from ordinary queries. Audit fields record creation and latest-update metadata, but do not provide a complete history of every change.

The foundation: what the official documentation explains

EF Core global query filters add a predicate to ordinary queries for an entity type. The change tracker records entity states used during SaveChanges. An application can use those mechanisms to implement soft deletion and audit stamping. Bypassing filters or bypassing tracked SaveChanges requires separate consideration.

Implementation context: The examples use a .NET 10 MVC application organized into feature folders (Vertical Slice Architecture). Basic C# classes and async/await are assumed. Reference excerpts show selected parts of that application; separately labeled teaching adaptations explain alternatives. They are not complete standalone projects.

Start with what the user means by delete

When an administrator removes a Country from the active list, the business may still need its historical row. Soft deletion represents that intent with a flag. The reference BaseEntity includes IsDeleted alongside CreatedAt, CreatedBy, UpdatedAt, and UpdatedBy. Country inherits these shared fields rather than declaring a different deletion convention in each feature.

This arrangement puts a common persistence behavior in the database layer while the feature handler decides which operation the user requested. It is compatible with a feature-oriented application: VSA does not require every cross-cutting rule to be duplicated inside each handler.

Reference excerpt: Data/Abstracts/BaseEntity.cs

public abstract class BaseEntity : IHasAudit, IHasIsDeleted
{
    [Key]
    public string Id { get; set; } = Guid.NewGuid().ToString();
    public DateTimeOffset? CreatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public DateTimeOffset? UpdatedAt { get; set; }
    public string? UpdatedBy { get; set; }
    public bool IsDeleted { get; set; } = false;
}

Mark the tracked entity, then save

AppDbContext exposes a SoftDelete helper that sets IsDeleted. The Country delete workflow looks up the entity, reports failure when it cannot find it, calls this helper, and saves asynchronously. The flag change is persisted through EF tracking. Merely changing a property without saving would affect the in-memory instance but not reliably update the stored row.

Reference excerpt: Infrastructures/Databases/AppDbContext.cs

public void SoftDelete<T>(T entity) where T : class, IHasIsDeleted
    {
        entity.IsDeleted = true;
    }

Understand the second deletion path

The context also intercepts tracked entries in the Deleted state and converts them into Modified entries with IsDeleted set. That is a second path: callers using a normal tracked removal can still get a soft delete for entities implementing the marker interface. The direct SoftDelete helper does not need to mark the entity Deleted first.

This distinction matters when reviewing a handler. Search both for the explicit helper and for normal Remove calls before assuming only one operation can hide a row. Also remember that direct SQL and other write paths can bypass this tracked SaveChanges logic.

Reference excerpt: Infrastructures/Databases/AppDbContext.cs

private void ApplySoftDelete()
    {
        var entries = ChangeTracker.Entries<IHasIsDeleted>()
            .Where(e => e.State == EntityState.Deleted);

        foreach (var entry in entries)
        {
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
        }
    }

Explain why ordinary lists stop showing the row

During model creation, the context applies a not-deleted query filter to entity types implementing IHasIsDeleted. GetCountryListHandler can therefore begin with Country.AsQueryable without repeating the deletion condition. The shared configuration expresses a default query rule; the Country feature still owns its search and sort expressions.

For a controlled diagnostic in a disposable database, compare an ordinary query with one using IgnoreQueryFilters. Restrict such bypasses to intentional administration or diagnostics. In a system with additional tenant or access filters, broadly bypassing filters could reveal more than deleted rows. A filter is not a substitute for all authorization rules.

Read the audit behavior accurately

On new entities, the reference context sets creation time and creator. For modifications, it prevents changes to the original creation fields and updates the latest modification fields. The current-user service supplies the user identifier, with system as a fallback. These fields answer who created the row and who most recently changed it.

They do not retain every previous value, every editor, or a chronological event log. Calling them a complete audit trail would overstate the implementation. If the requirement is to reconstruct changes, add an explicit history design with the necessary old and new values and retention behavior.

Reference excerpt: Infrastructures/Databases/AppDbContext.cs

if (entry.State == EntityState.Added)
            {
                auditEntity.CreatedAt = now;
                auditEntity.CreatedBy = userId;
            }
            else
            {
                entry.Property(nameof(IHasAudit.CreatedAt)).IsModified = false;
                entry.Property(nameof(IHasAudit.CreatedBy)).IsModified = false;
            }

            auditEntity.UpdatedAt = now;
            auditEntity.UpdatedBy = userId;
        }
    }

Consider uniqueness and relationships

Soft-deleted rows still occupy the database. CountryConfiguration defines a unique index on Code, while normal queries can hide a deleted country. A create handler may not see that row during its duplicate check, yet a relational unique index can still reject reuse of the code. Decide whether reuse is allowed and align the validation, index strategy, and restoration workflow.

Related Currency rows can also retain a CountryId after the country disappears from an active lookup. Soft deletion is not automatically a cascade across every relationship. A parent-child workflow needs an explicit rule for child visibility, restoration, and historical display.

Verify the intended outcome

With disposable local data, create a record, note its creation metadata, update it, and verify the creation metadata remains stable while update metadata changes. Soft-delete it and query through a fresh context to confirm it leaves the normal list but remains stored. Fresh-context checks avoid confusing a previously tracked instance with a newly executed database query.

Test the intended behavior for reusing a deleted code and for related records. Do not label those cases passed merely because the Delete button removed a table row. The UI, query filter, persistence, and business rules each contribute a different part of the result.

Key Takeaways

  • Soft delete changes visibility while retaining stored data.
  • Audit stamps are not a full change history.
  • Uniqueness and relationship behavior need explicit decisions after deletion.

FAQ

Is a soft-deleted row physically removed?

No. It remains stored with a deletion flag and is hidden from ordinary filtered queries.

Do UpdatedAt and UpdatedBy preserve every edit?

No. They describe the latest update, not a complete sequence of changes.

Can I reuse a deleted country code automatically?

Not necessarily. The retained row can still participate in a unique database index even when a query filter hides it.