blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Strongly-typed IDs in EF Core (Revisited)

Using strongly-typed entity IDs to avoid primitive obsession - Part 4

This is another post in my series on strongly-typed IDs. In the first and second posts, I looked at the reasons for using strongly-typed IDs, and how to add converters to interface nicely with ASP.NET Core. In the previous post I looked at ways of using strongly-typed IDs with EF Core. Unfortunately, there was a significant issue with the approach I outlined: querying by a strongly-typed ID could result in client-side evaluation. The workarounds I proposed only partially fixed the problem.

In this post, I show a workaround that seems to solve the issue. EF Core is not my speciality, so it's possible there's some hidden issues, but from my testing so far it works perfectly! 🀞 The secret-sauce is ValueConverterSelector.

Strongly-typed IDs in EF Core

As a quick recap, the solution I proposed in the previous post centred around value converters. As their name suggests, these can be used to convert instances of one type (for example a strongly-typed ID like OrderId) into a type that is supported by a database provider (for example a Guid or an int).

In the last post I showed an example implementation of a custom ValueConverter for an OrderId that is stored in the database as a Guid. The version below is slightly modified to be a nested class of the strongly-typed ID OrderId, which is how we would generate it if using the "snippet" approach from my second post. For this post, we don't need the [StronglyTypedIdEfValueConverter] attribute I previously described.

public readonly struct OrderId 
{
    // Not shown: the OrderId implementation and other converters

    public class StronglyTypedIdEfValueConverter : ValueConverter<OrderId, Guid>
    {
        public StronglyTypedIdEfValueConverter(ConverterMappingHints mappingHints = null)
            : base(id => id.Value, value => new OrderId(value), mappingHints) 
        {
        }
    }
}

You could manually map every use of OrderId in your EF Core model properties to use this converter as before. But as well as being verbose, this would leave you with the client-side evaluation problem from the last post.

Instead, we're going to look at one of the "internal" services of EF Core - the ValueConverterSelector. If you're not interested in why the solution works and just want to see the final code, skip ahead.

A semi-deep dive into ValueConverterSelector - handling built-in conversions

After reaching the conclusion of my last post, I felt like I had hit a brick wall trying to get strongly-typed IDs to work smoothly. There were all sorts of work arounds you could use, but ultimately you were going to get a sub-par experience no matter what.

This got me thinking: EF Core has all sorts of "built-in" value converters that convert between primitive types. These do conversions like Char to string, number to byte[], or string to Guid. Using these value converters doesn't trigger the client-side evaluation problem, and they doesn't require you to register them against each property - they're used automatically.

These converters aren't built into the BCL or anything, so they must be registered somewhere in EF Core. After a bit of searching, I tracked the answer down to the ValueConverterSelector class and the IValueConverterSelector interface.

I've reproduced the interface (from version 2.2.4) below, as the xmldocs describe exactly what this type does:

/// <summary>
/// A registry of ValueConverterInfo that can be used to find
/// the preferred converter to use to convert to and from a given model type
/// to a type that the database provider supports.
/// </summary>
public interface IValueConverterSelector
{
    /// <summary>
    /// Returns the list of ValueConverterInfo instances that can be
    /// used to convert the given model type. Converters nearer the front of
    /// the list should be used in preference to converters nearer the end.
    /// </summary>
    IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null);
}

EF Core uses an implementation of this interface to find the value converters for built-in types. It appears to use these types early in the query generation pipeline, so they don't cause the client-side evaluation issues you see with custom value converters.

The below code is a snippet taken from the Select() method of the default implementation, ValueConverterSelector. This method is essentially a giant if/else statement that finds all the applicable converters for a given modelClrType (the type used in your EF Core entities) and providerClrType (the type stored in the database).

Given the number of built-in converters, this method is big, so I've only shown a snippet of it below:

private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters
    = new ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo>();

public virtual IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null)
{
    // Extract the "real" type T from Nullable<T> if required
    var underlyingModelType = modelClrType.UnwrapNullableType();
    var underlyingProviderType = providerClrType?.UnwrapNullableType();

    // lots of code...

    if (underlyingModelType == typeof(Guid))
    {
        if (underlyingProviderType == null
            || underlyingProviderType == typeof(byte[]))
        {
            yield return _converters.GetOrAdd(
                (underlyingModelType, typeof(byte[])),
                k => GuidToBytesConverter.DefaultInfo);
        }

        if (underlyingProviderType == null
            || underlyingProviderType == typeof(string))
        {
            yield return _converters.GetOrAdd(
                (underlyingModelType, typeof(string)),
                k => GuidToStringConverter.DefaultInfo);
        }
    }

    // lots more code...
}

So what is this code doing? First, the method "unwraps" any nullable types - so if the type is a Guid?, it returns a Guid and so on. If the type is not nullable, this is a no-op. It's worth noting that the providerClrType can be null: null here means "give me all the value converters for the modelClrType".

After unwrapping the types, we enter the nested if/else statements - I've shown the if statement for Guid above. There are two built-in converters for Guid: the GuidToBytesConverter, and the GuidToStringConverter. If the underlyingProviderType is null or the correct type, the method uses yield return to return a default instance of ValueConverterInfo.

The implementation uses a ConcurrentDictionary to avoid creating multiple ValueConverterInfo objects, keyed on the underlyingModelType and underlyingProviderType.

The ValueConverterInfo object is a simple DTO that contains a factory method for creating a ValueConverter instance:

public readonly struct ValueConverterInfo
{
    private readonly Func<ValueConverterInfo, ValueConverter> _factory;

    public Type ModelClrType { get; }
    public Type ProviderClrType { get; }
    public ConverterMappingHints MappingHints { get; }
    public ValueConverter Create() => _factory(this);
}

If we look at one of the built-in value converters GuidToStringConverter for example, we see the DefaultInfo property that returns a ValueConverterInfo object:

public class GuidToStringConverter : StringGuidConverter<Guid, string>
{
    public GuidToStringConverter(ConverterMappingHints mappingHints = null)
        : base(ToString(), ToGuid(), _defaultHints.With(mappingHints))
    { }

    public static ValueConverterInfo DefaultInfo { get; }
        = new ValueConverterInfo(
            typeof(Guid), 
            typeof(string), 
            i => new GuidToStringConverter(i.MappingHints), 
            _defaultHints);
}

I haven't shown the base classes involved, so the code above isn't entirely complete, but the DefaultInfo property implementation is pretty simple. It creates a new ValueConverterInfo object, providing the ModelClrType (Guid) the ProviderClrType (string), a function for creating a new GuidToStringConverter given the current ValueConverterInfo instance (i.e. call the constructor), and the default mapping hints to use (for controlling the size of the string column in the database etc).

That's as far as I went digging into the ValueConverterSelector. I haven't worked out quite how it fits in to the overall EF Core query translation system (other than it's used in the ITypeMappingSource implementations), but I know enough now to be dangerous - lets get back to fixing the original problem, strongly-typed IDs.

Creating a custom ValueConverterSelector for strongly-typed IDs

To recap, we have a number of strongly-typed IDs that are used in our EF Core entities. For each strongly-typed ID we have a nested ValueConverter implementation. In this section, we're going to create a custom ValueConverterSelector to automatically register our value converters so they're used in the same way as the built-in value converters.

Luckily, the ValueConverterSelector implementation isn't sealed, and the Select() method is even virtual, so we can easily create our own implementation, while preserving the existing behaviour for built-in converters. The following code is the entire StronglyTypedIdValueConverterSelector - I'll walk through and explain it afterwards.

public class StronglyTypedIdValueConverterSelector : ValueConverterSelector
{
    // The dictionary in the base type is private, so we need our own one here.
    private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters
        = new ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo>();

    public StronglyTypedIdValueConverterSelector(ValueConverterSelectorDependencies dependencies) : base(dependencies)
    { }

    public override IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null)
    {
        var baseConverters = base.Select(modelClrType, providerClrType);
        foreach (var converter in baseConverters)
        {
            yield return converter;
        }

        // Extract the "real" type T from Nullable<T> if required
        var underlyingModelType = UnwrapNullableType(modelClrType);
        var underlyingProviderType = UnwrapNullableType(providerClrType);

        // 'null' means 'get any value converters for the modelClrType'
        if (underlyingProviderType is null || underlyingProviderType == typeof(Guid))
        {
            // Try and get a nested class with the expected name. 
            var converterType = underlyingModelType.GetNestedType("StronglyTypedIdEfValueConverter");

            if (converterType != null)
            {
                yield return _converters.GetOrAdd(
                    (underlyingModelType, typeof(Guid)),
                    k =>
                    {
                        // Create an instance of the converter whenever it's requested.
                        Func<ValueConverterInfo, ValueConverter> factory =
                            info => (ValueConverter) Activator.CreateInstance(converterType, info.MappingHints);

                        // Build the info for our strongly-typed ID => Guid converter
                        return new ValueConverterInfo(modelClrType, typeof(Guid), factory);
                    }
                );
            }
        }
    }

    private static Type UnwrapNullableType(Type type)
    {
        if (type is null) { return null; }

        return Nullable.GetUnderlyingType(type) ?? type;
    }
}

The StronglyTypedIdValueConverterSelector is written to follow the same patterns as the ValueConverterSelector it overrides, so I've created a ConcurrentDictionary<> for tracking the value converters in the same way the base class does. The dictionary in the base class is private so we have to create a new instance of it here, but that's not a big deal. The constructor passes through the required ValueConverterSelectorDependencies object to the base class.

The meat of the implementation is in the Select method. We start by fetching all of the applicable built-in value converters by calling base.Select(), and yield return on all of the returned implementations. That preserves existing behaviour.

Next, we have to "unwrap" nullable types, just as the base class did. We call the simple static UnwrapNullableType() method defined at the end of the class. If the provider type is either null or Guid, then we try and create a converter, otherwise we're done.

When testing the converter, I found that the method was only ever called with providerClrType=null. That's likely due to something specific about my models, I just thought I'd point it out.

Assuming the if() branch returns true, we now need to see if the modelClrType is a strongly-typed ID type with a value converter implementation. This is where the change to the value converter implementation at the start of this post makes sense:

public readonly struct OrderId 
{
    public class StronglyTypedIdEfValueConverter : ValueConverter<OrderId, Guid> { }
}

By creating the value converter as a nested class, and using the same name across all strongly-typed ID types (StronglyTypedIdEfValueConverter), we can both fetch the converter type and test for a strongly-typed ID at the same time with a small bit of reflection:

var converterType = underlyingModelType.GetNestedType("StronglyTypedIdEfValueConverter");
if (converterType != null)
{
    // we have a Type for the converter
}

At this point we know we have a value converter for the modelClrType, so we need to create the correct ValueConverterInfo and yield return it. The base class simplifies this code by using a static DefaultInfo property, but we'd have to invoke a similar method using reflection, and it all gets a bit more hassle than it's worth. Instead, I opted for creating a factory function that creates an instance of the converter by calling Activator.CreateInstance(), passing in the required ConverterMappingHints argument:

Func<ValueConverterInfo, ValueConverter> factory =
    info => (ValueConverter) Activator.CreateInstance(converterType, info.MappingHints);

Don't be fooled by the fact that the StronglyTypedIdEfValueConverter mappingHints parameter has a default value of null. Even though you don't need to provide this value when invoking the constructor normally, you must provide it when invoking the method using reflection (and Activator.CreateInstance())

Finally, we can create an instance of ValueConverterInfo, add it to the dictionary, and yield return it.

This implementation looks a bit complicated because of the reflection required, but I'm pretty confident there's nothing untoward going on there. All that remains is for us to replace the default instance of IValueConverterSelector with our custom class.

Replacing the default IValueConverterSelector with a custom implementation

Replacing "framework" EF Core services is relatively painless thanks to the ReplaceService method exposed by DbContextOptionsBuilder. You can call this method as part of your EF Core configuration in Startup.ConfigureServices, in the AddDbContext<> configuration method:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options
            .ReplaceService<IValueConverterSelector, StronglyTypedIdValueConverterSelector>() // add this line
            .UseSqlServer(
                Configuration.GetConnectionString("DefaultConnection")));
}

That's it.

No more custom DbContext.OnModelCreating code.

No marker attributes.

No more implicit/explicit conversions to force use of the value converter.

And most importantly, no more client-side evaluation.

I'm actually kind of surprised by how well it works, but it all does seem to work. Even the following code (which was broken in the implementation from my last post), works:

public Order GetOrder(OrderId orderId)
{
    return _dbContext.Orders
                .Where(order => order.Id == orderId)
                .FirstOrDefault();
}

public Order GetOrderUsingFind(OrderId orderId)
{
    return _dbContext.Orders
                .Find(orderId);
}

Both of these usages generate the same SQL, which has a server-side where clause:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (6ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']

      SELECT TOP(1) [e].[OrderId], [e].[Name]
      FROM [Orders] AS [e]
      WHERE [e].[OrderId] = @__get_Item_0

Success! One more reason to use strongly-typed IDs in your next ASP.NET Core app πŸ˜ƒ.

Summary

In this post I describe a solution to using strongly-typed IDs in your EF Core entities by using value converters and a custom IValueConverterSelector. The base ValueConverterSelector in the EF Core framework is used to register all built-in value conversions between primitive types. By deriving from this class, we can add our strongly-typed ID converters to this list, and get seamless conversion throughout our EF Core queries. As well as reducing the configuration required, this solves the client-side evaluation problem that plagued the previous implementation.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?