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:
- No identity — equality is determined by all fields
- Immutable — once created, they do not change (you create a new one instead)
- 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
[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; } = "";
}[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();
}[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);
}// 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)) ← nullableCustomers
├── 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)) ← nullableProperty-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!;// 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"));
}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"));
}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"}{"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; }
}[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";
}[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();
}[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);
}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')Products
├── Id (uniqueidentifier)
├── Name (nvarchar(200))
├── Price_Amount (decimal(18,2))
└── Price_Currency (nvarchar(3), default 'USD')ComplexType vs Owned: Key Differences
Money Pricewith[ComplexType]cannot be null. ThePriceproperty must always have a value. This matches the domain: a product always has a price.Address? BillingAddresswith[Owned]can be null. The customer may not have a billing address.ComplexPropertydoes not support separate tables or JSON columns.ComplexPropertydoes 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; }
}[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();// 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);
}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
}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; }
}[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);
}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; }[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; }
}[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);
}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; }[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; }[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 othererror EDSL0004: Property 'CreatedAt' on 'Order' has both Value and Sql on [DefaultValue] — use one or the otherDatabase 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";
}[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");
}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();
}
}[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);
}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})";
}[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 contradictoryerror EDSL0027: Property 'Id' on 'Customer' has both [NotMapped] and [PrimaryKey] — these are contradictorySummary: The Decision Matrix
When modeling a non-entity type, use this decision process:
- Does it have its own identity (PK)? → Use
[Entity] - Can it be null? → Use
[Owned]/[OwnedEntity] - Does it need a separate table or JSON column? → Use
[OwnedEntity(TableName/JsonColumn)] - 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].