Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Value Objects and Owned Types

"A value object has no identity. Two addresses with the same street, city, and zip code are the same address. If you give them separate IDs, you have invented a problem."


The DDD Value Object

In Domain-Driven Design, a value object is a type defined by its attributes rather than by a unique identity. Two Money instances with the same amount and currency are equal, regardless of where they were created.

Value objects have three properties:

  1. No identity — equality is determined by all fields
  2. Immutable — once created, they do not change (you create a new one instead)
  3. Self-contained — no references to entities or other aggregates

In C#, value objects are typically record types or classes with value-based equality. In EF Core, they need special mapping because they do not have their own table — they are part of their owning entity's table (or stored as JSON).

EF Core provides three mechanisms for this, and Entity.Dsl supports all three.


The Three Strategies

Diagram
[OwnedEntity] / [Owned] [ComplexType] / [ValueObject] [Entity]
Has identity Via owner's key No Yes (own PK)
Can be null Yes No Yes
Own table Optional No (same table only) Yes (always)
JSON column Yes No No
Navigations Yes No Yes
Repository No No Yes
DDD mapping Composition Value Object Entity
EF Core API OwnsOne / OwnsMany ComplexProperty HasOne / HasMany

Address as an Owned Type

Our marketplace needs addresses for customers and stores. An address has no identity of its own — it belongs to the entity that owns it.

Class-Level: [Owned]

When a type is always owned (never standalone), apply [Owned] to the class itself:

[Owned]
public class Address
{
    [Required]
    [MaxLength(200)]
    public string Street { get; set; } = "";

    [Required]
    [MaxLength(100)]
    public string City { get; set; } = "";

    [Required]
    [MaxLength(100)]
    public string State { get; set; } = "";

    [Required]
    [MaxLength(20)]
    public string ZipCode { get; set; } = "";

    [Required]
    [MaxLength(100)]
    public string Country { get; set; } = "";
}

Any navigation to an [Owned] type automatically generates OwnsOne. No need for [OwnedEntity] on each property:

[AggregateRoot("Customer")]
[Table("Customers")]
public partial class Customer
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    [Required]
    [MaxLength(254)]
    public string Email { get; set; } = "";

    // Automatically mapped as OwnsOne because Address has [Owned]
    public Address ShippingAddress { get; set; } = null!;
    public Address? BillingAddress { get; set; }

    public List<Order> Orders { get; set; } = new();
}

Generated Output: Inline Columns

// In CustomerConfigurationBase.g.cs
protected virtual void ConfigureShippingAddress(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Customer> builder)
{
    builder.OwnsOne(e => e.ShippingAddress);
}

protected virtual void ConfigureBillingAddress(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Customer> builder)
{
    builder.OwnsOne(e => e.BillingAddress);
}

With inline mapping, the Customers table gets columns like:

Customers
├── Id (uniqueidentifier)
├── Name (nvarchar(200))
├── Email (nvarchar(254))
├── ShippingAddress_Street (nvarchar(200))
├── ShippingAddress_City (nvarchar(100))
├── ShippingAddress_State (nvarchar(100))
├── ShippingAddress_ZipCode (nvarchar(20))
├── ShippingAddress_Country (nvarchar(100))
├── BillingAddress_Street (nvarchar(200))    ← nullable
├── BillingAddress_City (nvarchar(100))      ← nullable
├── BillingAddress_State (nvarchar(100))     ← nullable
├── BillingAddress_ZipCode (nvarchar(20))    ← nullable
└── BillingAddress_Country (nvarchar(100))   ← nullable

Property-Level: [OwnedEntity] with Options

For more control, use [OwnedEntity] on the navigation property:

// Separate table for billing addresses
[OwnedEntity(TableName = "BillingAddresses")]
public Address? BillingAddress { get; set; }

// JSON column (EF Core 7+)
[OwnedEntity(JsonColumn = "shipping_address")]
public Address ShippingAddress { get; set; } = null!;

Generated Output: Separate Table

protected virtual void ConfigureBillingAddress(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Customer> builder)
{
    builder.OwnsOne(e => e.BillingAddress, b => b.ToTable("BillingAddresses"));
}

Generated Output: JSON Column

protected virtual void ConfigureShippingAddress(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Customer> builder)
{
    builder.OwnsOne(e => e.ShippingAddress, b => b.ToJson("shipping_address"));
}

The JSON column stores the address as a JSON object in a single database column:

{"Street":"123 Main St","City":"Springfield","State":"IL","ZipCode":"62701","Country":"US"}

This is useful when you want to avoid the column prefix explosion (especially with multiple owned types) and you do not need to query individual address fields at the database level.

Store with Address

The Store aggregate also uses Address:

[AggregateRoot("Store")]
[Table("Stores")]
public partial class Store
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    // ... other properties from Part III

    // Address is [Owned], so this auto-maps as OwnsOne
    public Address? Address { get; set; }
}

Money as a Complex Type

Money is the quintessential value object: an amount and a currency. Two Money(10.00, "USD") instances are identical. It should never be null (an order always has a total), and it does not need a separate table or navigations.

This is a perfect fit for [ComplexType] / [ValueObject]:

[ValueObject("Money")]
public class Money
{
    [Precision(18, 2)]
    public decimal Amount { get; set; }

    [Required]
    [MaxLength(3)]
    [DefaultValue(Value = "USD")]
    public string Currency { get; set; } = "USD";
}

Using Money in Entities

[AggregateRoot("Product")]
[Table("Products")]
public partial class Product
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    // ... other properties

    [ComplexType]
    public Money Price { get; set; } = new();
}

Generated Output

protected virtual void ConfigurePrice(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
    builder.ComplexProperty(e => e.Price);
}

The Products table gets:

Products
├── Id (uniqueidentifier)
├── Name (nvarchar(200))
├── Price_Amount (decimal(18,2))
└── Price_Currency (nvarchar(3), default 'USD')

ComplexType vs Owned: Key Differences

  • Money Price with [ComplexType] cannot be null. The Price property must always have a value. This matches the domain: a product always has a price.
  • Address? BillingAddress with [Owned] can be null. The customer may not have a billing address.
  • ComplexProperty does not support separate tables or JSON columns.
  • ComplexProperty does not support navigations from the complex type to other entities.

If your value object is never null and never needs a separate table, use [ComplexType] / [ValueObject]. If it needs nullable semantics or flexible storage, use [Owned] / [OwnedEntity].


Product Variants with Composition

A Product has multiple variants (size, color combinations). Each variant has its own SKU and price. Variants are meaningless without their product — this is composition.

[Entity("ProductVariant")]
[Table("ProductVariants")]
public partial class ProductVariant
{
    [PrimaryKey]
    public Guid Id { get; set; }

    public Guid ProductId { get; set; }

    [HasOne(WithMany = "Variants")]
    public Product Product { get; set; } = null!;

    [Required]
    [MaxLength(100)]
    public string VariantName { get; set; } = "";

    [Required]
    [MaxLength(50)]
    public string Sku { get; set; } = "";

    [Precision(18, 2)]
    public decimal Price { get; set; }

    public int StockQuantity { get; set; }
}

And the Product gains the composition:

// Added to Product class
[Composition]
[HasMany(WithOne = "Product", ForeignKey = "ProductId")]
public List<ProductVariant> Variants { get; set; } = new();

Generated: Cascade Delete

protected virtual void ConfigureVariants(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
    builder.HasMany(e => e.Variants)
        .WithOne(e => e.Product)
        .HasForeignKey(e => e.ProductId)
        .OnDelete(DeleteBehavior.Cascade);
}

Enum Storage

Our marketplace has status enums for orders and products:

public enum OrderStatus
{
    Pending,
    Confirmed,
    Processing,
    Shipped,
    Delivered,
    Cancelled,
    Refunded
}

public enum ProductStatus
{
    Draft,
    Active,
    OutOfStock,
    Discontinued
}

Storing as Strings

By default, EF Core stores enums as integers. But string storage is often more readable in the database and survives enum reordering:

[AggregateRoot("Order")]
[Table("Orders")]
public partial class Order
{
    // ... other properties

    [EnumStorage(AsString = true)]
    [DefaultValue(Value = "Pending")]
    public OrderStatus Status { get; set; }
}

Generated Output

protected virtual void ConfigureStatus(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Order> builder)
{
    builder.Property(e => e.Status)
        .HasConversion<string>()
        .HasDefaultValue(global::Marketplace.Domain.OrderStatus.Pending);
}

The Orders table stores "Pending", "Confirmed", "Shipped" as varchar values instead of 0, 1, 2.

Storing as Integers (Default)

[EnumStorage(AsString = false)]
public OrderStatus Status { get; set; }

Or simply omit [EnumStorage] — integers are the EF Core default.


Computed Columns

The OrderItem's LineTotal is computed from Quantity * UnitPrice. This should be a database-computed column, not application-side calculation:

[Entity("OrderItem")]
[Table("OrderItems")]
public partial class OrderItem
{
    // ... other properties

    public int Quantity { get; set; }

    [Precision(18, 2)]
    public decimal UnitPrice { get; set; }

    [ComputedColumn("[Quantity] * [UnitPrice]", Stored = true)]
    public decimal LineTotal { get; set; }
}

Generated Output

protected virtual void ConfigureLineTotal(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.OrderItem> builder)
{
    builder.Property(e => e.LineTotal)
        .HasComputedColumnSql("[Quantity] * [UnitPrice]", stored: true);
}

Stored = true means the value is physically stored in the table (persisted computed column). Stored = false (the default) means it is computed on read.


Default Values

Entity.Dsl supports two kinds of default values:

CLR Default Value

[DefaultValue(Value = "true")]
public bool IsActive { get; set; }

[DefaultValue(Value = "0.00")]
[Precision(5, 2)]
public decimal TaxRate { get; set; }

Generated: builder.Property(e => e.IsActive).HasDefaultValue(true);

SQL Expression Default

[DefaultValue(Sql = "GETUTCDATE()")]
public DateTimeOffset CreatedAt { get; set; }

[DefaultValue(Sql = "NEWID()")]
public Guid TrackingId { get; set; }

Generated: builder.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");

Conflict Detection

Setting both Value and Sql on the same property triggers diagnostic EDSL0004:

error EDSL0004: Property 'CreatedAt' on 'Order' has both Value and Sql on [DefaultValue] — use one or the other

Database Comments

Add documentation directly to database tables and columns:

[Entity("OrderItem")]
[Table("OrderItems")]
[Comment("Individual line items within an order")]
public partial class OrderItem
{
    [PrimaryKey]
    public int Id { get; set; }

    [Comment("Reference to the parent order")]
    public Guid OrderId { get; set; }

    [Comment("ISO 4217 currency code")]
    [MaxLength(3)]
    public string Currency { get; set; } = "USD";
}

Generated Output

protected virtual void ConfigureTable(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.OrderItem> builder)
{
    builder.ToTable("OrderItems");
    builder.HasComment("Individual line items within an order");
}

protected virtual void ConfigureCurrency(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.OrderItem> builder)
{
    builder.Property(e => e.Currency)
        .HasMaxLength(3)
        .HasComment("ISO 4217 currency code");
}

These comments appear in database tools (SSMS, pgAdmin, etc.) and in migration scripts.


Backing Fields

For DDD-style encapsulated properties with private setters:

[AggregateRoot("Customer")]
[Table("Customers")]
public partial class Customer
{
    [PrimaryKey]
    public Guid Id { get; set; }

    private string _email = "";

    [BackingField("_email", AccessMode = "Field")]
    public string Email
    {
        get => _email;
        private set => _email = value.ToLowerInvariant();
    }
}

Generated Output

protected virtual void ConfigureEmail(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Customer> builder)
{
    builder.Property(e => e.Email)
        .HasField("_email")
        .UsePropertyAccessMode(PropertyAccessMode.Field);
}

EF Core reads and writes the _email field directly, bypassing the property setter. The setter's ToLowerInvariant() logic only runs when application code sets the property.

Access modes:

  • "Field" — always use the field
  • "Property" — always use the property getter/setter
  • "PreferField" — use field if available, fall back to property
  • "PreferFieldDuringConstruction" — use field when materializing from database, property otherwise

NotMapped Properties

Exclude properties from EF Core mapping:

[AggregateRoot("Customer")]
[Table("Customers")]
public partial class Customer
{
    // ... mapped properties

    [NotMapped]
    public string FullDisplayName => $"{Name} ({Email})";
}

The generator skips [NotMapped] properties — no Configure* method is emitted.

Combining [NotMapped] with [PrimaryKey] triggers diagnostic EDSL0027:

error EDSL0027: Property 'Id' on 'Customer' has both [NotMapped] and [PrimaryKey] — these are contradictory

Summary: The Decision Matrix

When modeling a non-entity type, use this decision process:

  1. Does it have its own identity (PK)? → Use [Entity]
  2. Can it be null? → Use [Owned] / [OwnedEntity]
  3. Does it need a separate table or JSON column? → Use [OwnedEntity(TableName/JsonColumn)]
  4. None of the above → Use [ComplexType] / [ValueObject]

In our marketplace:

  • Address[Owned] (can be null for billing address, always inline or JSON)
  • Money[ValueObject] (never null, always inline, no identity)
  • ProductVariant[Entity] with [Composition] (has its own PK, its own table, its own queries)

In the next part, we tackle many-to-many relationships with [AssociationClass] and tree structures with [SelfReference].