Search code examples
.netentity-frameworkentity-framework-core

EF Core Tracking Issue .NET9


Consider the following loop in which I am save journal entries for accounting purposes. The JournalEntry type is:

public class AccountJournalEntry
{
    public int Id { get; set; }
    public string Owner {  get; set; }
    public Account Account {  get; set; } //the second level account that is used in the transaction
    public DateOnly Date { get; set; }
    public string Party { get; set; }
    public bool IsDebit { get; set; } = true; //determines entry is of type debit or credit
    public decimal DebitAmount { get; set; }
    public decimal CreditAmount { get; set; }
    public string Description { get; set; } //record user comments here like "Purchased fans for office"
    public Guid TenantId { get; set; }
    public Guid TransactionId { get; set; } //each journal entry is identified by a transactionid that groups all journal entries. This makes it easy to 
                                            //delete all entries belonging to a transaction. This is helpful if making a mistake in a transaction and you want to delete all entries and start over



}

and the following loop takes a list of these entries and saves in database:

using (var context = factory.CreateDbContext())
{
    var guid = Guid.NewGuid(); //how to get a new Guid?
    var originalAccountTypes = new Dictionary<int, AccountTypes>();

    for (int i = 0; i < journalEntries.Count(); i++)
    {

        var entry = journalEntries[i];
        // Store the current AccountType in a dictionary with the loop index as the key
        originalAccountTypes[i] = entry.Account.AccountType.DeepCopy();
        
        entry.Account.AccountType = null;
        // Set the TenantId
        entry.TenantId = _tenantId;
        // Assign a unique TransactionId
        entry.TransactionId = guid;
        // Update the entry in the AccountJournal
        context.AccountJournal.Update(entry);
    }
    await context.SaveChangesAsync();

Notice that I am using a single database context for the whole loop. While making these entries, if an Account is present in two records, lets say I have an Asset 1000 account, with its primary key 22, and it is being used in two entries, I get the EF Core tracking error: The instance of entity type 'Account' cannot be tracked because another instance with the key value '{Id: 22}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.

In order to fix this, if I move the creation of db context to inside the loop, then I am able to execute the whole loop without exception:

for (int i = 0; i < journalEntries.Count(); i++)
{

    using (var context = factory.CreateDbContext())
    {
        var entry = journalEntries[i];
        // Store the current AccountType in a dictionary with the loop index as the key
        originalAccountTypes[i] = entry.Account.AccountType.DeepCopy();
        // Nullify AccountType to avoid EF Core tracking issues
        entry.Account.AccountType = null;
        // Set the TenantId
        entry.TenantId = _tenantId;
        // Assign a unique TransactionId
        entry.TransactionId = guid;
        // Update the entry in the AccountJournal
        context.AccountJournal.Update(entry);
        await context.SaveChangesAsync();

    }
}

Is this approach correct or I should handle the tracking in some other way?


Solution

  • The simple answer is "don't pass around detached entities, especially entity graphs. (Entities with related entities)" Working with detached entities beyond a single simple entity that has no relations to anything else is a complicated process. You can do it safely, (and by safely I don't mean things like nulling references) but it involves a bit of work.

    Take a simpler example with just a set of JournalEntries that each reference an account. Some entries will reference unique accounts, while others share an account reference. We ultimately want to update all of the journal entries but when we call Update on an entry that references the same account that a previously updated entry, we get a tracking issue. This is a common issue with web projects, and the reason stems from the fact that EF cares about reference equality. When we have 2 JournalEntries that both reference Account ID #1, when we read that data, we get the 2 Journal Entries instances, and they both will reference the same Account instance. We can modify those entries and even details in the account and call SaveChanges() and everything works without a complaint. (With tracked entities we don't even have to call Update()) The issues start to appear when we pass these entities around, and especially when they get serialized and re-materialized. When you pass the two Journal Entries back to a controller referencing Account ID #1, what you get are two new instances of Entries constructed by ASP.Net, and Two distinct new instances of an Account for ID #1, not the same reference. When we call Update() on the first Journal Entry, by extension the first instance of Account ID #1 is attached to the DbContext. When EF gets sent the second Journal Entry and told to Update() it, EF will see a new instance of Account ID #1 and try and attach it, but it is already tracking the reference from the first one updated. Hence your error.

    How to work with detached entities:

    using (var context = factory.CreateDbContext())
    {
        for (int i = 0; i < journalEntries.Count(); i++)
        {
            var entry = journalEntries[i];
            var existingAccount = context.Accounts.Local.FirstOrDefault(a => a.AccountId == entry.Account.AccountId);
            if (existingAccount != null)
                entry.Account = existingAccount;
            else
            {
                var existingAccountType = context.AccountTypes.Local.FirstOrDefault(a => a.AccountTypeId == entry.Account.AccountType.AccountTypeId);
                if(existingAccountType != null)
                    entry.Account.AccountType = existingAccountType;
            }
            // ... Carry on with any other references etc. then call Update on entry
        }
        await context.SaveChangesAsync();
    }
    

    The significant point to note is the use of .Local. This checks the DbContext tracking cache for instances that might already be tracked. When we find one, we replace the reference in the entity to be updated to use the tracked instance. This avoids the "already tracked" error. This has to be done for every single entity reference.

    A better approach is to simply not pass entities around. Especially with web projects when you pass an entity to serve as a model for a View you aren't "sending" an entity to the client, and a client cannot send an entity back. All it is doing is sending name-value details for input fields that ASP.Net is re-materializing into a model in the controller action parameter for you. Instead, if you use a simple POCO ViewModel/DTO you can pass just the fields that need to be changed along with the Ids of the PK and FKs you care about. There is no confusion about whether you're getting a complete entity or entities that are tracked, untracked, duplicated instances, etc. to worry about. You fetch the entities by ID (fast) and copy across values and update references if needed. Simpler, safer coding than messing around with detached references and dealing with duplicates.