Skip to main content

Value Objects

Value objects are immutable objects that represent concepts in your domain. Unlike entities, they have no identity - two value objects with the same attributes are considered equal.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required package:
    dotnet add package Excalibur.Domain
  • Familiarity with domain modeling concepts

Key Characteristics

CharacteristicDescription
No IdentityDefined by attributes, not by a unique identifier
ImmutabilityOnce created, cannot be changed
Structural EqualityEqual if all attributes are equal
Self-ValidationValidate invariants at construction
Side-Effect FreeOperations return new instances

The ValueObjectBase Class

Excalibur provides ValueObjectBase for creating value objects:

using Excalibur.Domain.Model.ValueObjects;

public class Money : ValueObjectBase
{
public decimal Amount { get; }
public string Currency { get; }

public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative");

if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required");

Amount = amount;
Currency = currency.ToUpperInvariant();
}

// Required: Define what makes two instances equal
public override IEnumerable<object?> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

Implementing Equality

The GetEqualityComponents() method defines structural equality:

public class Address : ValueObjectBase
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string PostalCode { get; }
public string Country { get; }

public Address(string street, string city, string state,
string postalCode, string country)
{
Street = street ?? throw new ArgumentNullException(nameof(street));
City = city ?? throw new ArgumentNullException(nameof(city));
State = state ?? throw new ArgumentNullException(nameof(state));
PostalCode = postalCode ?? throw new ArgumentNullException(nameof(postalCode));
Country = country ?? throw new ArgumentNullException(nameof(country));
}

public override IEnumerable<object?> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return PostalCode;
yield return Country;
}

public override string ToString() =>
$"{Street}, {City}, {State} {PostalCode}, {Country}";
}

Equality in Action

var money1 = new Money(100.00m, "USD");
var money2 = new Money(100.00m, "USD");
var money3 = new Money(100.00m, "EUR");

money1 == money2; // true (same amount and currency)
money1 == money3; // false (different currency)

var address1 = new Address("123 Main St", "Seattle", "WA", "98101", "USA");
var address2 = new Address("123 Main St", "Seattle", "WA", "98101", "USA");
address1.Equals(address2); // true (all components match)

Immutable Operations

Value objects should return new instances instead of mutating:

public class Money : ValueObjectBase
{
public decimal Amount { get; }
public string Currency { get; }

public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency.ToUpperInvariant();
}

// Returns NEW instance - doesn't modify 'this'
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");

return new Money(Amount + other.Amount, Currency);
}

public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot subtract different currencies");

var result = Amount - other.Amount;
if (result < 0)
throw new InvalidOperationException("Result cannot be negative");

return new Money(result, Currency);
}

public Money MultiplyBy(decimal factor)
{
if (factor < 0)
throw new ArgumentException("Factor cannot be negative");

return new Money(Amount * factor, Currency);
}

public override IEnumerable<object?> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

Using Immutable Operations

var price = new Money(100.00m, "USD");
var tax = new Money(8.50m, "USD");

// Each operation returns a new Money instance
var total = price.Add(tax); // new Money(108.50, "USD")
var discounted = total.MultiplyBy(0.9m); // new Money(97.65, "USD")

// Original instances unchanged
Console.WriteLine(price.Amount); // 100.00
Console.WriteLine(tax.Amount); // 8.50

Common Value Object Patterns

Date Range

public class DateRange : ValueObjectBase
{
public DateTime Start { get; }
public DateTime End { get; }

public DateRange(DateTime start, DateTime end)
{
if (end < start)
throw new ArgumentException("End date must be after start date");

Start = start;
End = end;
}

public TimeSpan Duration => End - Start;
public bool Contains(DateTime date) => date >= Start && date <= End;
public bool Overlaps(DateRange other) => Start < other.End && End > other.Start;

public DateRange ExtendBy(TimeSpan duration) =>
new DateRange(Start, End.Add(duration));

public override IEnumerable<object?> GetEqualityComponents()
{
yield return Start;
yield return End;
}
}

Email Address

public class EmailAddress : ValueObjectBase
{
public string Value { get; }
public string LocalPart => Value.Split('@')[0];
public string Domain => Value.Split('@')[1];

public EmailAddress(string email)
{
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email cannot be empty");

if (!IsValidEmail(email))
throw new ArgumentException("Invalid email format");

Value = email.ToLowerInvariant();
}

private static bool IsValidEmail(string email)
{
// Simple validation - use proper regex in production
return email.Contains('@') &&
email.Split('@').Length == 2 &&
email.Split('@')[1].Contains('.');
}

public override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}

public override string ToString() => Value;

// Implicit conversion for convenience
public static implicit operator string(EmailAddress email) => email.Value;
}

Percentage

public class Percentage : ValueObjectBase
{
public decimal Value { get; }

public Percentage(decimal value)
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value),
"Percentage must be between 0 and 100");

Value = value;
}

public decimal AsDecimal => Value / 100;
public decimal ApplyTo(decimal amount) => amount * AsDecimal;

public static Percentage Zero => new(0);
public static Percentage Full => new(100);

public override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}

public override string ToString() => $"{Value}%";
}

Value Objects in Aggregates

Use value objects to express domain concepts clearly:

public class Order : AggregateRoot<Guid>
{
public CustomerId CustomerId { get; private set; }
public Money Total { get; private set; }
public Address ShippingAddress { get; private set; }
public DateRange DeliveryWindow { get; private set; }

public void UpdateShippingAddress(Address newAddress)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot update shipped order");

RaiseEvent(new ShippingAddressUpdated(Id, newAddress));
}

private bool Apply(ShippingAddressUpdated e)
{
// Value object replaced entirely - immutability preserved
ShippingAddress = e.NewAddress;
return true;
}
}

Using Records as Value Objects

C# records provide built-in value semantics for simple cases:

// Simple value object using record
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Currency mismatch");
return this with { Amount = Amount + other.Amount };
}
}

// Records have built-in equality
var m1 = new Money(100, "USD");
var m2 = new Money(100, "USD");
m1 == m2; // true

When to use ValueObjectBase vs records:

Use ValueObjectBaseUse Records
Complex validation logicSimple validation
Custom equality rulesStandard equality
Inheritance neededNo inheritance
Framework integrationStandalone use

Value Object vs Entity Decision

Is the concept defined by its attributes?

├── YES → Does it have a lifecycle with changes
│ tracked over time?
│ │
│ ├── YES → Consider Entity
│ │
│ └── NO → Use Value Object
│ Examples: Money, Address, DateRange

└── NO → Use Entity
Examples: Order, Customer, Product

Best Practices

1. Validate at Construction

public class Money : ValueObjectBase
{
public Money(decimal amount, string currency)
{
// Fail fast with invalid data
if (amount < 0)
throw new ArgumentException("Amount cannot be negative");

if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be 3-letter ISO code");

Amount = amount;
Currency = currency.ToUpperInvariant();
}
}

2. Make All Properties Read-Only

public class Address : ValueObjectBase
{
// All properties have private or no setters
public string Street { get; }
public string City { get; }

// No methods that modify state
}

3. Include All State in Equality

public override IEnumerable<object?> GetEqualityComponents()
{
// Include ALL properties that define the value
yield return Amount;
yield return Currency;
// Don't forget calculated or derived properties if relevant
}

4. Override ToString for Debugging

public override string ToString() =>
$"{Amount:N2} {Currency}"; // "100.00 USD"

Next Steps

See Also

  • Aggregates - Using value objects within aggregate roots to express domain concepts
  • Entities - Objects defined by identity rather than attributes
  • Domain Modeling Overview - Introduction to DDD building blocks in Excalibur