Domain-Driven Design Value Objects in C#
Primitive obsession is one of the most common code smells in .NET applications. An email address is a string. A price is a decimal. A currency code is another string. The compiler can't tell them apart, so nothing stops you from passing a currency code where an email address is expected. Value objects fix this.
What Is a Value Object?
In DDD, a value object is an immutable type defined by its attributes rather than an identity. Two value objects with the same properties are considered equal. A Money of 10 GBP is equal to any other Money of 10 GBP — there's no identity to distinguish them.
The key characteristics:
- Immutability — once created, they never change
- Structural equality — compared by value, not reference
- Self-validating — invalid states cannot be constructed
A Base Class
C# records give us structural equality for free, making them ideal for value objects:
public abstract record ValueObject;
That's it. Records handle equality and immutability. Let's build on this.
Email Address
public record EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email address cannot be empty.");
if (!value.Contains('@') || !value.Contains('.'))
throw new ArgumentException($"'{value}' is not a valid email address.");
Value = value.ToLowerInvariant().Trim();
}
public override string ToString() => Value;
public static implicit operator string(EmailAddress email) => email.Value;
}
Now EmailAddress is a type the compiler understands. You can't accidentally pass a product name where an email is expected. The validation runs once at construction — every EmailAddress in your system is guaranteed valid.
Money
Money is a classic value object that pairs an amount with a currency:
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be a 3-letter ISO code.");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException(
$"Cannot add {Currency} to {other.Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(int factor) => new(Amount * factor, Currency);
public override string ToString() => $"{Amount:F2} {Currency}";
}
The Add method enforces a domain rule: you cannot add pounds to dollars. This rule lives in the value object, not scattered across service classes.
Using Value Objects in Entities
public class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public EmailAddress Email { get; private set; }
public Customer(string name, EmailAddress email)
{
Id = Guid.NewGuid();
Name = name;
Email = email;
}
public void ChangeEmail(EmailAddress newEmail)
{
Email = newEmail;
}
}
The entity doesn't need to validate email formats — EmailAddress handles that.
Persisting with EF Core
EF Core supports value objects through owned types or value conversions.
Value Conversion (simple single-value types)
modelBuilder.Entity<Customer>()
.Property(c => c.Email)
.HasConversion(
email => email.Value,
value => new EmailAddress(value))
.HasMaxLength(256);
Owned Type (multi-property types like Money)
modelBuilder.Entity<OrderLine>(builder =>
{
builder.OwnsOne(l => l.Price, price =>
{
price.Property(p => p.Amount).HasColumnName("Price");
price.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(3);
});
});
A Strongly-Typed ID
Value objects also work for identifiers, eliminating the "which Guid is which?" problem:
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
Now GetOrder(OrderId id) and GetCustomer(CustomerId id) are impossible to confuse at the call site.
When to Use Value Objects
Use value objects whenever a primitive carries domain meaning: email addresses, phone numbers, money, quantities, date ranges, coordinates, postal codes. If you find yourself writing the same validation logic for a string or decimal in multiple places, that's a sign it should be a value object.
The cost is minimal — a few extra types. The benefit is significant: domain rules encoded in the type system, validated once, and enforced everywhere.