The DbContext in Entity Framework Core has the Find and FindAsync methods that allow you to easily find an entity just by using the id(s). I say "id(s)" as the primary key can comprise more than one property so you need to pass the values for each of the properties in the primary key.

These methods are a really handy shortcut and I recommend using them.

I've happily used these for some time, but I recently had cause to go check out the source code to see how the values were matched to properties of the primary key. I Wanted to create an extension method for deleting entities which would avoid a round trip to the database. Here's what I wanted the signature to look like:

await db.RemoveAsync<TEntity>(123);

Here's how I went about it...

Removing without finding first

Let's set the scene. I'll create a ficticious entity class:

public class MyEntity {
    public int Id { get; set; }
    public string Name { get; set; }
}

And a db context like this:

public class MyDbContext {
    public DbSet<MyEntity> MyEntities { get; set; }
}

The pattern that I commonly see is something like this:

    var entity = await db.FindAsync<MyEntity>(123);
    db.MyEntities.Remove(entity);
    await db.SaveChangesAsync();

But this results in 2 trips to the database which we really don't want to do. Instead, we can do this:

    var entity = new MyEntity {
        Id = 123
    };
    db.MyEntities.Remove(entity);
    await db.SaveChangesAsync();    

Entity Framework will then know how to delete this entity from the database using is primary key. Seems simple enough, so let's go ahead and create the extension method.

First attempt at the extension method

Here's the signature that I went with:

public static async Task RemoveAsync<TEntity>(this DbContext db, params object[] keyValues) {
}

The params object[] keyValues was a straight copy from the source for FindAsync.

The first challenge is creating a new instance of TEntity with the correct properties set for the primary key.

To do that, we need to create a new entry of the correct type:

var entry = db.Entry(Activator.CreateInstance<TEntity>());

Next we will need to set the primary key properties, which I'll come back to.

Lastly, we'll mark as deleted and save. That will give us a method that looks like this:

public static async Task RemoveAsync<TEntity>(this DbContext db, params object[] keyValues) {

    // Get the entry for a new instance of TEntity
    var entry = db.Entry(Activator.CreateInstance<TEntity>());
    
    //
    // TODO: set the primary key properties
    //
    
    // Mark as deleted and save
    entry.State = EntityState.Deleted;
    await db.SaveChangesAsync();
    
}

So the only thing to do now is set the primary key properties right? Well, not quite, but let's start with that.
You can get the primary key details by accessing the metadata for the entry:

var primaryKey = entry.Metadata.FindPrimaryKey();

primaryKey then has a Properties property that contains the details for the properties in the primary key. Makes sense!

So, we should be able to loop through and set the values on our entry. Here's what the extension method looks like now:

public static async Task RemoveAsync<TEntity>(this DbContext db, params object[] keyValues) {

    // Get the entry for a new instance of TEntity
    var entry = db.Entry(Activator.CreateInstance<TEntity>());
    
    // Set the primary key properties
    var primaryKey = entry.Metadata.FindPrimaryKey();
    for(var i = 0; i < primaryKey.Properties.Count; i++) {
        entry.Property(primaryKey.Properties[i].Name).CurrentValue = keyValues[i];
    }
    
    // Mark as deleted and save
    entry.State = EntityState.Deleted;
    await db.SaveChangesAsync();
    
}

Great, we're done, right?! No, not quite...

The change tracker

The code above works fine unless the entity being deleted is already in the change tracker. In my scenario, that is because I have some validation in place that checks to see if the items exists before trying to delete it. If it doesn't exist, I return a 404 in my API to be handled by the client.

If my entity is being tracked by EF already, then the code above will error as I will be trying to add a second entity of the same type with the same key into the change tracker.

So, I need to update my code to handle this scenario.

The final code

Here's what I've ended up with which works in all the scenarios I've tested.

public static async Task RemoveAsync<TEntity>(this DbContext db, params object[] keyValues)
    where TEntity : class {

    // find any entries in the change tracker that have the right keys
    var entries = db
        .ChangeTracker
        .Entries<TEntity>()
        .Where(entry => PrimaryKeyEquals(entry, keyValues))
        .ToList();

    if (entries.Any()) {

        // mark entries as deleted and save
        foreach (var entry in entries)
            entry.State = EntityState.Deleted;

        await db.SaveChangesAsync();

    } else {

        // create a new instance of the entry
        var entry = db.Entry(Activator.CreateInstance<TEntity>());

        // get the primary key for the new entry
        var primaryKey = entry.Metadata.FindPrimaryKey();

        // validate that the provided key values match the primary key properties in length and type
        ValidatePrimaryKeyValues(entry, keyValues);

        // set the primary key values
        for (var i = 0; i < primaryKey.Properties.Count; i++) {
            entry.Property(primaryKey.Properties[i].Name).CurrentValue = keyValues[i];
        }

        // mark as deleted and save
        entry.State = EntityState.Deleted;
        await db.SaveChangesAsync();

    }

}


private static bool PrimaryKeyEquals<TEntity>(EntityEntry<TEntity> entry, object[] keyValues)
    where TEntity: class {

    var properties = entry.Metadata.FindPrimaryKey().Properties;

    // validate that the provided key values match the primary key properties in length and type
    ValidatePrimaryKeyValues(entry, keyValues);

    for (var i = 0; i < properties.Count; i++) {

        // check the property value matches
        if (!entry.Property(properties[i].Name).CurrentValue.Equals(keyValues[i]))
            return false;

    }

    // everything must match if we got this far
    return true;

}

private static void ValidatePrimaryKeyValues<TEntity>(EntityEntry<TEntity> entry, object[] keyValues) where TEntity : class {

    var properties = entry.Metadata.FindPrimaryKey().Properties;

    // ensure the number of properties being matched are equal
    if (properties.Count != keyValues.Length)
        throw new ArgumentException("Primary key properties count mismatch");

    for (var i = 0; i < properties.Count; i++) {

        // ensure the property types being matched are equal
        if (keyValues[i].GetType() != properties[i].ClrType)
            throw new ArgumentException(string.Concat(
                "Primary key property type mismatch",
                Environment.NewLine,
                $"Property: {properties[i].Name}, Value type: {keyValues[i].GetType().Name}"
                ));

    }

}

I've added in some extra validation and tried to throw meaningful errors that will help the developer.