DEV Community

Alex
Alex

Posted on

.Net Learning Notes: Custom In-Memory Provider(3) - Storage Write Model and Key-Based Retrieval

Write Model

var e = new TestEntity { Name = "SmokeTest User" };  
ctx.TestEntities.Add(e);
var addCount = ctx.SaveChanges();
Enter fullscreen mode Exit fullscreen mode

EF Core does not immediately write changes to the database when Add, Update, or Remove is called. These operations only modify the internal ChangeTracker. Every tracked entity is assigned an EntityState, such as Added, Modified, or Deleted. The StateManager accumulates these transitions in memory and maintains a consistent view of the unit of work.

When SaveChanges() is invoked, EF Core collects all pending changes from the ChangeTracker, transforms them into a list of IUpdateEntry instances, and forwards that list to the provider through Database(public class CustomMemoryEfDatabase : Database)'s SaveChanges(IList<IUpdateEntry> entries).

At this moment, control shifts from EF Core to the provider.

In the custom in-memory provider, the CustomMemoryEfDatabase class receives the accumulated entries and iterates through them. For each entry, the provider inspects EntityState and routes the entity to the appropriate table-level operation (Add, Update, or Remove). After all entries are processed, the provider commits those pending table changes to the underlying storage by calling IMemoryDatabase.SaveChanges().

All of detecting changes logic is delegated to EF Core. The provider’s responsibility begins only when EF Core decides to persist changes. EF Core owns state management and change detection. The provider owns storage persistence. SaveChanges() becomes the clear write boundary between these two layers.

public class CustomMemoryEfDatabase : Database
{
    public override int SaveChanges(IList<IUpdateEntry> entries)  
    {  
        var affected = 0;  
        foreach (var entry in entries)  
        {        // 先只做 Added,跑通闭环  
            if (entry.EntityState is not  
                (Microsoft.EntityFrameworkCore.EntityState.Added  
                or Microsoft.EntityFrameworkCore.EntityState.Modified  
                or Microsoft.EntityFrameworkCore.EntityState.Deleted))  
                continue;  

            var entityEntry = entry.ToEntityEntry();  
            var entity  = entityEntry.Entity;  
            var runtimeType = entity.GetType();  

            switch (entry.EntityState)  
            {            
                case Microsoft.EntityFrameworkCore.EntityState.Added:  
                    InvokeTableMethod(runtimeType, "Add", entity);  
                    affected++;  
                    break;  
                case Microsoft.EntityFrameworkCore.EntityState.Modified:  
                    InvokeTableMethod(runtimeType, "Update", entity);  
                    affected++;
                    break;  
                case Microsoft.EntityFrameworkCore.EntityState.Deleted:  
                    InvokeTableMethod(runtimeType, "Remove", entity);  
                    affected++; 
                    break;  
            }    
        }  
        _memoryDatabase.SaveChanges();  
        return affected;  
    }
}
Enter fullscreen mode Exit fullscreen mode

Key-Based Retrieval (Find)

var e = new TestEntity { Name = "FinderTest" };  
ctx1.Add(e);  
ctx1.SaveChanges();  

id = e.Id;  
Console.WriteLine($"CTX1: Added entity id={id}");  

// Same context: Find twice (2nd time should be TRACKED HIT)  
var f1 = ctx1.Set<TestEntity>().Find(id);
Enter fullscreen mode Exit fullscreen mode

In this provider, Find is not merely a dictionary lookup. It acts as a controlled bridge between the storage layer and EF Core’s tracking system. The operation consists of two conceptual steps: resolving a row by primary key and ensuring the resulting entity participates in EF Core’s identity map.

When Find is invoked, the provider first validates the supplied key values against the entity’s primary key metadata. It then checks the StateManager to determine whether an entity with the same key is already tracked. If so, that tracked instance is returned directly. This preserves EF Core’s guarantee that only one entity instance per key exists within a DbContext.

If the entity is not tracked, the provider performs a storage-level lookup. In this implementation, the in-memory table already materializes entities when retrieving rows, so table.Find(...) returns an entity instance rather than a raw snapshot. In other words, materialization happens inside the storage layer rather than through the query pipeline.

However, returning the instance alone would bypass EF Core’s tracking guarantees. Therefore, the provider explicitly registers the materialized entity with the StateManager, marking it as Unchanged with acceptChanges: true. This ensures the entity becomes part of EF Core’s identity map and behaves consistently with other tracked entities.

It is important to emphasize that Find does not implement identity resolution itself. Identity resolution is enforced by EF Core’s tracking infrastructure. The provider’s responsibility is to supply a stable key-based lookup and to integrate the resulting entity into the tracking system.

Unlike LINQ queries, Find bypasses the query translation pipeline entirely. It is a specialized, key-based retrieval path that integrates directly with the storage layer while still honoring EF Core’s tracking model.

public sealed class CustomMemoryEntityFinder<TEntity> : IEntityFinder<TEntity> where TEntity : class
{
    public object? Find(object?[]? keyValues) => FindCore(keyValues);

    private TEntity? FindCore(object?[]? keyValues)  
    {  
        if (keyValues is null) return null;  

        var pk = _entityType.FindPrimaryKey()  
             ?? throw new InvalidOperationException($"Entity type '{_entityType.Name}' has no primary key.");  

        // 1) tracked hit  
        var tracked = _stateManager.TryGetEntry(pk, keyValues);  
        if (tracked != null)  
        return tracked.EntityState == Microsoft.EntityFrameworkCore.EntityState.Deleted  
            ? null  
            : (TEntity)tracked.Entity;  

        // 2) store lookup  
        var table = _db.GetTable(_entityType.ClrType);  
        var foundObj = table.Find(ToObjectArray(keyValues));  
        if (foundObj is null)  
        {        
        return null;  
        }  
        var found = (TEntity)foundObj;  

        // 3) attach  
        var internalEntry = _stateManager.GetOrCreateEntry(found, _entityType);  

        internalEntry.SetEntityState(EntityState.Unchanged, acceptChanges: true);  
        return found;  
    }
}
Enter fullscreen mode Exit fullscreen mode

In EF Core, Find exists as a dedicated identity-based retrieval path rather than as part of the normal query pipeline. Its purpose is fundamentally different from LINQ-based queries.

A standard query is expression-driven. It flows through translation, compilation, and shaped execution before entities are materialized. Find, by contrast, is key-driven. Its semantic contract is simple and strict: retrieve an entity by its primary key while preserving identity consistency within the current DbContext.

Performance is another reason for the separation. Primary key lookup is one of the most common operations in data access. It does not require expression tree translation or query compilation. Treating it as a specialized path avoids unnecessary overhead.

In this provider, the distinction is explicit. LINQ queries operate over IQueryable<SnapshotRow> and participate fully in translation and compilation. Find bypasses that mechanism and directly performs key-based lookup, then integrates the result into EF Core’s tracking system. The storage layer supplies row identity; the tracking layer enforces object identity.

In short, Find is intentionally independent from the query pipeline because it serves a different architectural role. It is not expression-based retrieval — it is identity-based retrieval.

                 ┌──────────────────────┐
                 │      DbContext       │
                 └─────────┬────────────┘
                           │
         ┌─────────────────┴─────────────────┐
         │                                   │
     Write Path                            Find
 (Add/Update/Delete)                   (Key-Based Lookup)
         │                                   │
         ▼                                   ▼
   ChangeTracker                       StateManager
 (EntityState entries)             (Identity Map check)
         │                                   │
         ▼                                   ▼
  SaveChanges() call                   Store Lookup
         │                                   │
         ▼                                   ▼
 CustomMemoryEfDatabase                MemoryTable.Find
         │                                   │
         ▼                                   ▼
     MemoryTable                        Entity Attach
 (Snapshot + pending)               (SetEntityState = Unchanged)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)