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:

A Base Class

C# records give us structural equality for free, making them ideal for value objects:

Example.cs
public abstract record ValueObject;

That's it. Records handle equality and immutability. Let's build on this.

Email Address

EmailAddress.cs
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:

Money.cs
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

Customer.cs
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)

Example.cs
modelBuilder.Entity<Customer>()
    .Property(c => c.Email)
    .HasConversion(
        email => email.Value,
        value => new EmailAddress(value))
    .HasMaxLength(256);

Owned Type (multi-property types like Money)

Example.cs
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:

Example.cs
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.