Value objects with C# records
Replacing a decimal and a string with a Money type removes a whole class of bugs. Records make value objects short to write, with one catch around with expressions.
Look at this method signature:
void Refund(string orderId, string customerId, decimal amount, string currency);
Nothing stops a caller from swapping the two IDs, passing a negative amount or a currency code of "dollars". Every method that takes these values has to validate them again, or trust that someone else did.
A value object is a small type that represents one concept, validates itself on creation and is compared by its values. Domain-Driven Design popularized the term, but you don't need to be doing DDD to benefit.
A Money type
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Amount can't be negative.");
if (currency is not { Length: 3 } || !currency.All(char.IsAsciiLetterUpper))
throw new ArgumentException("Use a three-letter ISO currency code, such as USD.", nameof(currency));
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException($"Can't add {other.Currency} to {Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:0.00} {Currency}";
}
Now the signature says what it means:
void Refund(OrderId orderId, CustomerId customerId, Money amount);
An invalid Money can't exist, so nothing downstream needs to check again. Adding dollars to euros is an error instead of a silent bug. And because it's a record, two Money values with the same amount and currency are equal.
Strongly typed IDs
The same idea fixes swapped arguments:
public readonly record struct OrderId(Guid Value);
public readonly record struct CustomerId(Guid Value);
Passing a CustomerId where an OrderId is expected is now a compile error. A readonly record struct avoids an allocation for something this small.
The catch: with skips your constructor
Records support with expressions, and they don't run your constructor. If Money were a positional record with validation in the constructor, this would compile and produce an invalid value:
var broken = price with { Amount = -10 };
That's why the example above declares get-only properties and an explicit constructor instead of a positional record. With no init accessors, there's nothing for with to set, so the constructor is the only way in. If you prefer init properties, put the validation in the init accessor itself.
Storing them with EF Core
- A single-value object such as
OrderIdmaps to one column with a value converter:.HasConversion(id => id.Value, value => new OrderId(value)). - A multi-value object such as
Moneymaps to columns on the owning table as a complex type (ComplexProperty, available since EF Core 8) or an owned type.
Takeaway
Wrap important primitives such as amounts, codes and IDs in small types that validate themselves. Records keep them short and give you value equality. Just make sure with can't bypass your validation.