EF Core domain-wide value conversion for (nested) owned types

Viewed 418

I have several value objects (DDD paradigm) set up in EF Core as Owned Types.

EF Core supports configuring owned types in a way that it will automatically treat all references to the given type as an owned type, via the Owned() method.

However, I cannot seem to find a way to specify their configuration, especially value conversion, in a similar, centralized manner.

    {   // Configure value objects as owned types.
        builder.Owned(typeof(Money));
        builder.Owned(typeof(Currency));
        builder.Owned(typeof(Address));
        builder.Owned(typeof(Mass));
        builder.Owned(typeof(MassUnit));

        // Store and restore mass unit as symbol.
        builder.Entity<Product>()
            .OwnsOne(p => p.Mass, c => c.Property(m => m.Unit)
                .HasConversion(
                    u => u.Symbol,
                    s => MassUnit.FromSymbol(s))
                .HasMaxLength(3)
            );
    }

As you can see above, there is a value conversion configured for MassUnit, which is a Value Object nested in Mass.

But I'd have to do this manually for all places where the value objects are used. For example I'm already using the Money type at 3 distinct places, and this type contains Currency, for which I'd wish to configure a similar value conversion.

Is there any (good) way to specify general, domain-wide configuration for the owned types?

I already tried to configure them through builder.Entity<Mass>().Property(m => m.Unit).HasConversion(..), but it seems that EF Core throws if you try to configure an owned type via Entity<>.

2 Answers

This is possible using a shared ValueConverter:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var converter = new ValueConverter<EquineBeast, string>(
        v => v.ToString(),
        v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v));

    modelBuilder
        .Entity<Rider>()
        .Property(e => e.Mount)
        .HasConversion(converter);
}

Update: Actually I found a new feature in EF Core 6 that can be used for central configuration. It's called Pre-convention model configuration. In your DB Context class you can define a new ConfigureConventions() method override that accepts a ModelConfigurationBuilder instance.

With this, the old sample I provided in the 'obsolete answer' section can become:

protected override void ConfigureConventions(ModelConfigurationBuilder configBuilder)
{
    configBuilder.Properties<Currency>()
        .HaveConversion<CurrencyConverter>()
        .HaveMaxLength(3);
}

// Create the converter:

public class CurrencyConverter : ValueConverter<Currency, string>
{
    public CurrencyConverter()
        : base(
            currency => currency.Code,
            currencyCode => Currency.FromCode(currencyCode))
    {}
}

The only thing I don't like is that we're forced to define converter classes instead of being able to use lambdas. For simple conversions like this it definitely feels like too much ceremony.

Also, don't forget that owned types without explicit conversion must still be configured as .Owned<>(), so in my example I should have builder.Owned<Money>() in OnModelCreating(). I think it's slightly confusing to keep .Owned<>() configuration in OnModelCreating, since this is also a centralized/universal configuration for a given type.

Bonus tip: You can also centrally configure e.g. string lengths and decimal precision:

// So, this awkward old solution:
protected override void OnModelCreating(ModelBuilder builder)
{
    foreach (var entityType in builder.Model.GetEntityTypes())
    {
        foreach (var decimalProperty in entityType.GetProperties()
            .Where(x => x.ClrType == typeof(decimal)))
        {
            decimalProperty.SetPrecision(18);
            decimalProperty.SetScale(4);
        }
    }
}

// Can become:
protected override void ConfigureConventions(ModelConfigurationBuilder configBuilder)
{
    configBuilder.Properties<decimal>()
        .HavePrecision(precision: 18, scale: 4);
}


Obsolete answer: I still haven't found an actual solution, even EF Core 6 seems to have no feature improvement with respect to this. But, to answer Bodgan's question in the comments, the workaround I resorted to looks like the following; essentially similar to what Ionix suggested, just goes a step further:

// Store and restore currency as currency code.
builder.Entity<Product>().OwnsOne(p => p.Price, StoreCurrencyAsCode);
builder.Entity<Transaction>().OwnsOne(p => p.Total, StoreCurrencyAsCode);
builder.Entity<TransactionLine>().OwnsOne(p => p.UnitPrice, StoreCurrencyAsCode);

static void StoreCurrencyAsCode<T>(OwnedNavigationBuilder<T, Money> onb) where T : class
    => onb.Property(m => m.Currency)
        .HasConversion(
            c => c.Code,
            c => Currency.FromCode(c))
        .HasMaxLength(3);
Related