Skip to content

Entity Framework Core : Value Objects

Objects saved to the database can be split into three broad categories:

  1. Objects that are unstructured and hold a single value. For example, int, Guid, string, IPAddress. These are (somewhat loosely) called primitive types.
  2. Objects that are structured to hold multiple values, and where the identity of the object is defined by a key value. For example, Blog, Post, Customer. These are called entity types.
  3. Objects that are structured to hold multiple values, but the object has no key defining identity. For example, Address, Coordinate.

Prior to EF8, there was no good way to map the third type of object. Owned types can be used, but since owned types are actually entity types, they have a key and semantics based on the value of that key, even when the key value is hidden.

EF8 now supports Complex Types to cover this third type of object. Complex types in EF Core are very similar to complex types in EF6, but there are some differencee. Complex type objects:

  • Are not identified or tracked by key value.
  • Must be defined as part of an entity type. (In other words, you cannot have a DbSet of a complex type.)
  • Can be either .NET value types or reference types. (EF6 only supports reference types.)
  • Can share the same instance across multiple properties. (See below for more details. EF6 does not allow sharing.)

Simple example

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Address is then used in three places in a simple customer/orders model:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Let’s create and save a customer with their address:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

This results in the following row being inserted into the database:

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

Notice that the complex types do not get their own tables. Instead, they are saved inline to columns of the Customers table. This matches the table sharing behavior of owned types.

Immutable struct record

C# 10 introduced struct record types, which makes it easy to create and work with immutable struct records like it is with immutable class records. For example, we can define Address as an immutable struct record:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

Using complex types in PSS®X

EF Core 8 introduces a new feature called complex types as value objects.

To use complex types as value objects in EF Core 8, you need to do the following steps:

  • Do drive your class from ValueRecordObject abstract record. This base class available at GridLab.Abp.Ddd.Domain package.

For Value Objects, the ideal combination is to use a record with init properties.

[ComplexType]
public record Address : ValueRecordObject
{
    public required string Line1 { get; init; }
    public string? Line2 { get; init; }
    public required string City { get; init; }
    public required string Country { get; init; }
    public required string PostCode { get; init; }
}

Nested complex types

A complex type can contain properties of other complex types. For example, let’s use our Address complex type from above together with a PhoneNumber complex type, and nest them both inside another complex type:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

We will add Contact as a property of the Customer:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

And PhoneNumber as properties of the Order:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Configuration of nested complex types can again be achieved in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

Polymorphic complex types

Polymorphic Complex Types in EF Core 8 allow you to store different derived types of a base complex type in the same database table. Unlike traditional inheritance patterns that require separate tables or discriminator columns, complex types are stored as flattened properties within the parent entity's table.

Architecture Overview

Core Components

  1. Base Interface: IContainerConfiguration
  2. Abstract Base Class: ContainerConfiguration
  3. Concrete Implementations: AwsConfiguration, AzureConfiguration, DatabaseConfiguration
  4. Parent Entity: Container
  5. EF Core Configuration: DataManagementDbContextModelCreatingExtensions

Class Hierarchy

IContainerConfiguration
└── ContainerConfiguration (abstract record)
    ├── AwsConfiguration
    ├── AzureConfiguration
    └── DatabaseConfiguration

Implementation Details

Step 1: Define the Base Interface

  • Simple interface defining the discriminator property
    • ContainerType enum serves as the type discriminator
public interface IContainerConfiguration
{
    ContainerType Type { get; }
}

Step 2: Create the Abstract Base Class

  • Use record type for value semantics and immutability
  • Inherit from ValueRecordObject for proper value comparison
  • Protect constructors enforce type safety
  • Parameterless constructor required for EF Core deserialization
public abstract record ContainerConfiguration : ValueRecordObject, IContainerConfiguration
{
    public ContainerType Type { get; init; }

    protected ContainerConfiguration(ContainerType type)
    {
        Type = type;
    }

    protected ContainerConfiguration()
    {
    }
}

Step 3: Create the Abstract Base Class

AWS Configuration

public record AwsConfiguration : ContainerConfiguration
{
    // AWS-specific properties
    public string AccessKeyId { get; init; }
    public string SecretAccessKey { get; init; }
    public string Region { get; init; }
    public string ContainerName { get; init; }
    // ... other AWS properties

    public AwsConfiguration(/* parameters */) : base(ContainerType.Aws)
    {
        // Initialize AWS-specific properties
    }

    protected AwsConfiguration() : base(ContainerType.Aws) { }
}

Azure Configuration

public record AzureConfiguration : ContainerConfiguration
{
    // Azure-specific properties  
    public string ConnectionString { get; init; }
    public string ContainerName { get; init; }
    public bool CreateContainerIfNotExists { get; init; }

    public AzureConfiguration(/* parameters */) : base(ContainerType.Azure)
    {
        // Initialize Azure-specific properties
    }

    protected AzureConfiguration() : base(ContainerType.Azure) { }
}

Database Configuration

public record DatabaseConfiguration : ContainerConfiguration
{
    public DatabaseConfiguration() : base(ContainerType.Database)
    {
    }
}

Step 4: Define the Parent Entity

  • Use the abstract base class as the property type
  • Type property mirrors the configuration type for easier querying
  • Protected setters ensure controlled access
public class Container : AuditedAggregateRoot<Guid>, IMultiTenant, IHasEntityVersion
{
    public virtual ContainerConfiguration Configuration { get; protected set; }
    public virtual ContainerType Type { get; protected set; }

    // Other properties...

    public virtual void SetConfiguration(ContainerConfiguration configuration)
    {
        Configuration = Check.NotNull(configuration, nameof(configuration));
        Type = configuration.Type;
    }

    public virtual void UpdateConfiguration(ContainerConfiguration configuration)
    {
        Check.NotNull(configuration, nameof(configuration));

        if (Type != configuration.Type)
        {
            throw new CannotChangeContainerTypeException();
        }

        Configuration = configuration;
    }
}

Step 5: Configure EF Core Mapping

  1. Database Storage

    • All configuration properties are stored as flattened columns in the Containers table
    • Column names use prefixes (e.g., Configuration_AccessKeyId) to avoid conflicts
    • Properties not applicable to a specific type are stored as NULL
  2. Type Discrimination

    • The Configuration_Type column stores the enum value (1=Database, 2=AWS, 3=Azure)
    • EF Core uses this to determine which concrete type to instantiate during deserialization
  3. Serialization/Deserialization

    • When saving: EF Core flattens all properties from the concrete type
    • When loading: EF Core uses the Type discriminator to create the correct concrete instance
    • Only properties relevant to the specific type are populated
public static void ConfigureDataManagement(this ModelBuilder builder)
{
    builder.Entity<Container>(b =>
    {
        // Complex Property Configuration
        b.ComplexProperty(c => c.Configuration, cb =>
        {
            cb.IsRequired(); // Mark as required

            // Base configuration properties
            cb.Property(p => p.Type)
                .IsRequired()
                .HasColumnName("Configuration_Type")
                .HasConversion<int>();

            // AWS Configuration Properties
            cb.Property<string>("AccessKeyId")
                .HasColumnName("Configuration_AccessKeyId")
                .HasMaxLength(256);

            cb.Property<string>("SecretAccessKey")
                .HasColumnName("Configuration_SecretAccessKey")
                .HasMaxLength(512);

            // Azure Configuration Properties
            cb.Property<string>("ConnectionString")
                .HasColumnName("Configuration_ConnectionString")
                .HasMaxLength(2000);

            // Shared Properties
            cb.Property<string>("ContainerName")
                .HasColumnName("Configuration_ContainerName")
                .HasMaxLength(256);

            // ... other property configurations
        });
    });
}

Step 6: Querying by Configuration Type

// Query containers by type
var awsContainers = await context.Containers
    .Where(c => c.Type == ContainerType.Aws)
    .ToListAsync();

// Access specific configuration properties
foreach (var container in awsContainers)
{
    if (container.Configuration is AwsConfiguration awsConfig)
    {
        var region = awsConfig.Region;
        var containerName = awsConfig.ContainerName;
    }
}

Benefits

  1. Single Table Storage: All container types stored in one table
  2. Type Safety: Compile-time type checking for configuration properties
  3. Performance: No joins required for configuration data
  4. Flexibility: Easy to add new configuration types
  5. Immutability: Record types ensure configuration integrity

Best Practices

  1. Always provide parameterless constructors for EF Core deserialization
  2. Use meaningful column names with prefixes to avoid conflicts
  3. Set appropriate constraints (max length, required, etc.) on properties
  4. Keep configurations as records for value semantics
  5. Use protected setters on parent entities to control access
  6. Add proper validation in constructors and setters

Limitations

  1. All properties stored in one table: Can lead to wide tables with many NULL values
  2. Schema changes: Adding properties requires database migrations
  3. No inheritance queries: Cannot query across all configuration types easily
  4. Property conflicts: Different types cannot have properties with same names but different types