On this page

Skip to content

A Brief Discussion on Exception Handling and State Restoration for SaveChanges() in Entity Framework

This is likely the last note related to Entity Framework for a while. I’ve been researching WSL lately, yet my notes keep ending up being about Entity Framework, making it seem like I’m at odds with it.

I originally intended to split this into two articles, but since the content is highly related and I'm feeling a bit lazy, I’ve combined them into one.

Entity Framework Exception Messages

There are three common exceptions in Entity Framework:

  • DbUpdateException: Thrown when an error occurs while saving to the database (e.g., violating database constraints or other storage operation failures). This exception usually wraps lower-level exceptions, such as database connection errors or SQL execution errors.

  • DbUpdateConcurrencyException: Thrown when a concurrency issue occurs while saving to the database. This typically happens when RowVersion or ConcurrencyCheck attributes are set on an Entity type to implement concurrency control. When EF detects that the data in the database has been modified by another operation and the current operation's data version does not match, it throws this exception.

  • DbEntityValidationException: Thrown when SaveChanges() is called and Entity validation fails. This exception is typically used to catch data validation errors, such as property values not meeting Data Annotation requirements (e.g., [Required], [MaxLength]). It has been removed in Entity Framework Core.

TIP

While handling Entity Framework error messages, I found that I couldn't locate DbEntityValidationException. I just checked and realized it has been removed, which was quite surprising. For the reasons behind its removal, you can refer to Will 保哥's article: "EF Core no longer performs additional validation on entity models during SaveChanges()". Although I believe Model Binding validation and Entity validation should be viewed separately, upon reflection, the benefit of Entity validation is checking data before writing to the database, which can reduce some overhead. However, in practice, Model Binding and Service Layer validation can block most scenarios, so the need for Entity validation is indeed rare.

To be honest, I find EF exception messages quite frustrating. For example, you might see the following:

  • EF Core DbUpdateException message:

An error occurred while saving the entity changes. See the inner exception for details.

  • EF DbEntityValidationException message:

One or more entities failed validation. For more details, see the 'EntityValidationErrors' property.

Only heaven knows the actual cause, which forces us to handle these exceptions specifically. How to handle Entity exceptions depends primarily on whether the frontend will see the exception error message:

  • When the system returns the raw exception message directly to the frontend: To avoid exposing too many details, you should extract the full error message from the InnerException or EntityValidationErrors when logging the exception. This ensures the log contains detailed information while the frontend only sees a generic message.
  • When the frontend cannot see the exception message: In this case, you can override the SaveChanges() method in the DbContext to catch the Entity exception and re-throw an exception of the same type with the Message set to the full error details. This eliminates the need for extra processing in the exception log and makes error handling and responsibility clearer.

Restoring Entity State When SaveChanges() Fails

During data processing, we usually rely on database constraints to ensure invalid data isn't written, or rely on default values to avoid errors caused by missing data. However, in my opinion, one should not over-rely on database checks or default values, as this can lead to unexpected issues. This section stems from a mistake I made many years ago.

At the time, the scheduled program used ADO.NET for data writes. To save time, the developer didn't check for duplicate data before writing, relying instead on the primary key to block duplicates. When I rewrote this code in Entity Framework, I continued this approach. As mentioned in "A Brief Discussion on Synchronizing Navigation Properties and Foreign Keys in Entity Framework", when SaveChanges() fails, the Entity state is preserved. This means if the first SaveChanges() fails, when you try to add a second record and call SaveChanges() again, the generated SQL will include the first record. Consequently, once one failure occurs, all subsequent changes will also fail.

Of course, preserving the Entity state after a SaveChanges() failure can be helpful in some cases, such as retries due to network instability. I have seen projects where SaveChanges() automatically retries up to three times until it succeeds or gives up. However, if you do not want failed changes to be preserved in certain scenarios, you can consider overriding SaveChanges() and, upon catching a DbUpdateException, restoring the Entity state to ignore that specific transaction.

TIP

There are different industry perspectives on whether to set default values:

  • Supporting default values: Setting default values helps avoid errors when data is missing or not saved, reducing the probability of application issues and ensuring data integrity.

  • Opposing default values: Supporting fields set to NOT NULL without default values ensures that if data is not saved correctly, the program throws an error immediately, helping developers discover and fix potential issues early and avoiding the risk of hidden errors.

The design philosophies differ, and neither is strictly "right" or "wrong," but if the team has no specific requirements, I personally prefer the second approach.

WARNING

Note that the method of restoring Entity State after a SaveChanges() failure only applies to Entity structures without foreign keys. The specific reasons will be explained later.

Code Implementation

I will combine the code for both sections here. Since EF Core has removed DbEntityValidationException, I will not handle it. The handling of Entity States is as follows:

StateDescriptionHandling Method
DetachedNot tracked.No action needed.
UnchangedRetrieved from DB and not modified.No action needed.
DeletedRetrieved from DB and removed via Remove.Change State to Unchanged.
ModifiedRetrieved from DB and properties modified.Change State to Unchanged and use entry.CurrentValues.SetValues(entry.OriginalValues) to restore data.
AddedData existing only locally.Change State to Detached.
csharp
public partial class TestEFContext {
    public override int SaveChanges() {
        return SaveChanges(true);
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess) {
        try {
            return base.SaveChanges(acceptAllChangesOnSuccess);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) {
        return SaveChangesAsync(true, cancellationToken);
    }

    public override async Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess,
        CancellationToken cancellationToken = default
    ) {
        try {
            return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    private DbUpdateException ResetEntityStateAndFixMessage(DbUpdateException ex) {
        ResetEntityStates(ChangeTracker.Entries());

        return new DbUpdateException(ex.InnerException.Message, ex);
    }

    private static void ResetEntityStates(IEnumerable<EntityEntry> entries) {
        foreach (EntityEntry entry in entries) {
            ResetEntityState(entry);
        }
    }

    private static void ResetEntityState(EntityEntry entry) {
        switch (entry.State) {
            case EntityState.Added:
                entry.State = EntityState.Detached;
                break;
            case EntityState.Modified:
                entry.CurrentValues.SetValues(entry.OriginalValues);
                entry.State = EntityState.Unchanged;
                break;
            case EntityState.Deleted:
                // Normally, the deleted EntityState should be set to Unchanged.
                // However, in practice, whether set to Unchanged or Detached, 
                // entities removed via Remove() cannot be added back to navigation properties.
                // Setting EntityState to Unchanged might cause navigation properties 
                // to remain missing the previously removed Entity upon re-querying.
                // Therefore, for related EntityEntry.State, set to EntityState.Detached
                // so that navigation properties can be correctly re-queried.
                entry.State = entry.Entity is Dictionary<string, object>
                    ? EntityState.Detached
                    : EntityState.Unchanged;
                break;
        }
    }
}

Test Results

When adding an Entity via navigation properties, that Entity is also added to tracking. Therefore, the issue is more likely to occur when removing associations. I used the following test code to test the scenario of removing associations:

Entity structure:

csharp
modelBuilder.Entity<Table1>(entity => {
    entity.ToTable("Table1");

    entity.Property(e => e.Id).ValueGeneratedNever();

    entity.HasMany(d => d.Table2s)
        .WithMany(p => p.Table1s)
        .UsingEntity<Dictionary<string, object>>(
            "TableRef",
            l => l.HasOne<Table2>().WithMany().HasForeignKey("Table2Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table2"),
            r => r.HasOne<Table1>().WithMany().HasForeignKey("Table1Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table1"),
            j => {
                j.HasKey("Table1Id", "Table2Id").HasName("PK_Table_3");

                j.ToTable("TableRef");
            });
});

modelBuilder.Entity<Table2>(entity => {
    entity.ToTable("Table2");

    entity.Property(e => e.Id).ValueGeneratedNever();
});

public partial class Table1 {
    public Table1() {
        Table2s = new HashSet<Table2>();
    }

    public long Id { get; set; }

    public virtual ICollection<Table2> Table2s { get; set; }
}

public partial class Table2 {
    public Table2() {
        Table1s = new HashSet<Table1>();
    }

    public long Id { get; set; }

    public virtual ICollection<Table1> Table1s { get; set; }
}

Existing database data: Table1

Id
1
2
3

Table2

Id
1
2

TableRef

Table1IdTable2Id
11
22

Test code:

csharp
using TestEFContext dbContext = new(dbContextOptions);
// Retrieve records from Table1 and Table2, including navigation properties
Table1 table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Table1 table12 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 2);
Table2 table21 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 1);
Table2 table22 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 2);

PrintLog();

table11.Table2s.Remove(table21);

PrintLog();

table12.Table2s.Add(table21);

PrintLog();

try {
    // Intentionally trigger primary key conflict by inserting an existing Table1 record
    dbContext.Add(new Table1 {
        Id = 3
    });

    dbContext.SaveChanges();
} catch (Exception) {
}

PrintLog();

table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Console.WriteLine($"table11 Table2s association count: {table11.Table2s.Count}");

void PrintLog() {
    foreach (EntityEntry entry in dbContext.ChangeTracker.Entries()) {
        Console.WriteLine(entry.ToString());
    }

    Console.WriteLine($"table11 Table2s association count: {table11.Table2s.Count}");
    Console.WriteLine($"table12 Table2s association count: {table12.Table2s.Count}");
    Console.WriteLine($"table21 Table1s association count: {table21.Table1s.Count}");
    Console.WriteLine($"table22 Table1s association count: {table22.Table1s.Count}");

    Console.WriteLine();
}

Execution results:

text
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11 Table2s association count: 1
table12 Table2s association count: 1
table21 Table1s association count: 1
table22 Table1s association count: 1

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11 Table2s association count: 0
table12 Table2s association count: 1
table21 Table1s association count: 0
table22 Table1s association count: 1

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 1} Added FK {Table1Id: 2} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11 Table2s association count: 0
table12 Table2s association count: 2
table21 Table1s association count: 1
table22 Table1s association count: 1

Exception error

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11 Table2s association count: 0
table12 Table2s association count: 1
table21 Table1s association count: 0
table22 Table1s association count: 1

From the results, adding or removing associations via navigation properties does not affect the Entity State, but an EntityEntry for the association is created. When restoring the EntityEntry.State of the association, only the Add() change is restored, while the Remove() part is not handled.

Regarding the handling of the EntityState.Deleted scenario on line 50 of TestEFContext, here is the difference in results based on how the TableRef Entity State is handled:

  • TableRef count:

    • EntityState.Unchanged: There will be two records in TableRef, which is the same as before Remove(), which is correct.

    • EntityState.Detached: There is only one record in TableRef, missing TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}.

  • table11.Table2s.Count: Both are 0.

  • When re-querying Table1.Id 1 from the database:

    • EntityState.Unchanged: Table2s.Count remains 0. This is likely related to the DbContext caching mechanism mentioned in "EF Core DbContext Caching Experiment" and "How Query Works". Although it queries the database, because the DbContext already has the data and is tracking it, it returns the Entity from the DbContext directly. Honestly, this feels like a bug...

    • EntityState.Detached: Table2s.Count will be 1, and the navigation property is successfully re-retrieved from the database.

Although setting it to EntityState.Detached seems slightly better in usage, both have issues. Therefore, it is not recommended to use Entity state restoration when foreign keys are involved.

WARNING

Using DbSet.Add() to add an Entity with the same PK as already queried data will throw an InvalidOperationException. Since the exception is thrown during Add(), not SaveChanges(), it will not be caught by the existing error handling mechanism.

Change Log

  • 2024-08-17 Initial document creation.