Soft delete with Entity Framework

Soft delete with Entity Framework

In many of the applications we build, our users can delete items that are no longer needed. That's great because it gives them lots of autonomy. But sometimes the users aren't thinking hard enough (are they even thinking at all?) before deleting something they think they no longer needed. In that case they contact you and give you some weird reason why they deleted it and then the'll ask you to restore it. If you are a good person and keep daily backups of your applications, then you can go and find the backup and restore the lost data. But even if you have backups it may require lots of work for you to restore that one record without overriding any other changes the user has made. That's why I tend to always implement soft delete in my applications. Pretty much all of my entities will have a property "IsDeleted" of some sort. This field allows me to restore a deleted item in no time. Great, but sometimes these properties can add lots of complexity to our repositories which will cause bugs over time. In this post I'll show you how to implement Soft Delete without adding any complexity to your repositories!

Defining the test case

First I have created the following data model to work with:

public class BaseEntity
{
    public int Id { getset; }
    public bool IsDeleted { getset; }
}
 
public class Student : BaseEntity
{
    public string Firstname { getset; }
    public string Lastname { getset; }
    public ICollection<Lesson> Lessons { getset; }
}
 
public class Teacher : BaseEntity
{
    public string Firstname { getset; }
    public string Lastname { getset; }
    public ICollection<Lesson> Lessons { getset; }
}
 
public class Lesson : BaseEntity
{
    public string Subject { getset; }
    public int TeacherId { getset; }
    public Teacher Teacher { getset; }
    public ICollection<Student> Students { getset; }
}

You can see that I took a classic Teacher-Lesson-Student example with the following requirements:

  • A teacher can teach multiple lessons
  • A lesson has a single teacher and a list of students
  • A student can attend multiple lessons

Of course because we want to demonstrate soft delete, they all implement a BaseEntity class that holds our IsDeleted property (and an Id).

This is the DbContext I created for our model:

public class MyContext : DbContext
{
    public DbSet<Student> Students { getset; }
    public DbSet<Teacher> Teachers { getset; }
    public DbSet<Lesson> Lessons { getset; }
 
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
 
        modelBuilder.Entity<Lesson>()
            .HasRequired(l => l.Teacher)
            .WithMany(t => t.Lessons)
            .HasForeignKey(t => t.TeacherId);
 
        modelBuilder.Entity<Lesson>()
            .HasMany(l => l.Students)
            .WithMany(s => s.Lessons);
    }
}

To have some data to work with I have implemented the seed method of our migration as follows:

protected override void Seed(MyContext context)
{
    if (context.Lessons.Any())
        return;
 
    var students = new List<Student>
    {
        new Student {Firstname = "Student", Lastname = "1"},
        new Student {Firstname = "Student", Lastname = "2"},
        new Student {Firstname = "Student", Lastname = "3"},
        new Student {Firstname = "Student", Lastname = "4", IsDeleted = true},
        new Student {Firstname = "Student", Lastname = "5", IsDeleted = true},
    };
    context.Students.AddRange(students);
 
    var teacher = new Teacher { Firstname = "Sander", Lastname = "Van Looveren" };
    context.Teachers.Add(teacher);
 
    var lessons = new List<Lesson>
    {
        new Lesson
        {
            Subject = "Inheritance in EF",
            Teacher = teacher,
            Students = students.Take(2).ToList()
        },
        new Lesson
        {
            Subject = "Soft Delete",
            Teacher = teacher,
            Students = students
        },
        new Lesson
        {
            Subject = "Artificial Intelligence",
            Teacher = teacher,
            Students = students,
            IsDeleted = true
        }
    };
 
    context.Lessons.AddRange(lessons);
    context.SaveChanges();
}

As you can see here I have created 5 students (2 of them are deleted), 1 teacher and 3 lessons (1 of them is deleted).

That's it, our database is all set up!

Retrieving data

I have created a LessonRepository with one method to get all our lessons (including the teacher and the students) from our database:

public class LessonRepository : IDisposable
{
    private MyContext context;
 
    public LessonRepository()
    {
        context = new MyContext();
    }
 
    public IEnumerable<Lesson> GetLessons()
    {
        return context.Lessons
            .Include(l => l.Teacher)
            .Include(l => l.Students);
    }
 
    public void Dispose()
    {
        context.Dispose();
    }
}

As you can see I just returned the whole table. We still have to filter out the deleted records.

To test this repository I have written the following program:

class Program
{
    static void Main(string[] args)
    {
        using (var repo = new LessonRepository())
        {
            var lessons = repo.GetLessons();
            Console.WriteLine("Lessons:");
            foreach (var lesson in lessons)
            {
                Console.WriteLine($"- {lesson.Subject}");
                Console.WriteLine($"  -> by {lesson.Teacher.Firstname} {lesson.Teacher.Lastname}");
                Console.WriteLine($"  -> students attending:");
                foreach (var student in lesson.Students)
                {
                    Console.WriteLine($"     - {student.Firstname} {student.Lastname}");
                }
            }
        }
 
        Console.ReadKey();
    }
}

This will print out all the lessons including the teacher and students. When we execute this program we get all the results, including the ones marked with IsDeleted as you can see in the screenshot:

all records

Filtering deleted items

We don't want all of our deleted lessons to show, so let's change the repository to filter them.

public IEnumerable<Lesson> GetLessons()
{
    return context.Lessons
        .Include(l => l.Teacher)
        .Include(l => l.Students)
        .Where(l => !l.IsDeleted);
}

I have added a where clause to exclude the deleted lessons. Lets try again!

available lessons

As you can see, the lesson about Artificial Intelligence is no longer visible! But if we take a closer look, we notice that some of the students that show up are actually deleted and they shouldn't be there! Let's fix this.

public IEnumerable<Lesson> GetLessons()
{
    var lessons = context.Lessons
        .Include(l => l.Teacher)
        .Include(l => l.Students)
        .Where(l => !l.IsDeleted);
 
    foreach (var lesson in lessons)
    {
        lesson.Students = lesson.Students.Where(s => !s.IsDeleted).ToList();
    }
 
    return lessons;
}

I have added a foreach loop to exclude all the deleted students from the lessons. This should fix our issue! We'll give this a try.

available items

Great! our deleted students no longer appear in the list!

Now, what if we want to create a GetLessonById method? Easy, we filter the students from that lesson!

But wait, What if we have lots of other queries? What if our teacher is deleted? What if our students can each have a set of Skills that can be deleted? This will add lots of repetitive code and complexity. There must be a better solution, right?!

Dynamic filters

Lucky for us there is a nuget package we can use that will make our lives a lot easier! Let's take a look at EntityFramework.DynamicFilters. Using this nuget package we can define global filters to our context! For example we can tell our context not to use our deleted entities. Let's implement this!

First install the package

Install-Package EntityFramework.DynamicFilters

Next, define the global filter, add the following line to the OnModelCreating method of our context:

modelBuilder.Filter("IsDeleted", (BaseEntity d) => d.IsDeleted, false);

Add a new migration for this change and update the database using the following commands:

Add-Migration FilterIsDeleted
Update-Database

That's it!

Now we can revert the changes made to our LessonRepository to make GetLessons look like this again:

public IEnumerable<Lesson> GetLessons()
{
    return context.Lessons
        .Include(l => l.Teacher)
        .Include(l => l.Students);
}

Nice and clean, and when we run our application again we get the following result:

available items

No deleted records, great!

Conclusion

As you can see using these dynamic filters it is really easy to implement soft delete in any application using Entity Framwork. The nuget package I have used here has lot's of other great features so make sure you check it out and find which ones will work in your applications!

Thanks for reading, and leave a comment below if you have any questions or suggestions!

comments powered by Disqus