
Designing a Money Value Object in .NET - Eliminating Primitive Obsession
Author - Abdul Rahman (Bhai)
DDD
22 Articles
Table of Contents
What we gonna do?
In this article, we'll design a robust Money Value Object for .NET applications using Domain-Driven Design principles. Money is one of the most common value objects in business applications, yet it's frequently mishandled by representing it as primitive types like decimal and string.
We'll explore how to create a Money type that encapsulates amount and currency together, enforces business rules at compile-time, prevents common financial calculation errors, and provides a rich API for monetary operations. This pattern eliminates an entire category of bugs related to currency mismatches, precision issues, and invalid monetary states.
By the end of this article, you'll understand why primitive obsession is problematic for financial domains and how to implement a Money record with proper equality semantics — without any boilerplate.
Why we gonna do?
The Primitive Obsession Problem
Many applications represent money using primitive types, leading to a pattern called primitive obsession. This anti-pattern causes subtle bugs and forces developers to remember business rules throughout the codebase.
// Problematic approach: Using primitives
public class Product
{
public string Name { get; set; }
public double Price { get; set; } // What currency?
public string Currency { get; set; } // Can be null or invalid
}
// Somewhere in the application
var product = new Product
{
Name = "Software License",
Price = -50.0, // ❌ Negative price accepted!
Currency = "rupees" // ❌ Non-standard currency code
};
// Another place in code
product.Price = 99.999999; // ❌ Unrealistic precision
// Yet another location
product.Currency = null; // ❌ Price without currency!
Problems with this approach:
- No validation: Nothing prevents negative amounts or zero-precision edge cases
- Separated concerns: Amount and currency live in separate fields—they can get out of sync
- Floating-point errors: Using double for money causes rounding issues (0.1 + 0.2 ≠ 0.3)
- Non-standard currencies: "INR", "rupees", "₹" all mean the same thing but compare differently
- Scattered logic: Every method that handles money must re-implement the same validation rules
Real-World Consequences
Let's examine what happens when business logic relies on primitive money representation:
public class OrderService
{
public void ProcessOrder(double amount1, string currency1,
double amount2, string currency2)
{
// Developer forgets to check currencies match
var total = amount1 + amount2; // ❌ Mixing INR + EUR!
// Floating point precision loss
var tax = total * 0.075; // ❌ May lose precision
var grandTotal = total + tax;
// What if amounts are negative? Zero? NaN?
// Each developer must remember to validate...
}
}
// Usage scattered throughout codebase
ProcessOrder(100.0, "INR", 50.0, "EUR"); // ❌ No compile-time safety
ProcessOrder(-100.0, "INR", 50.0, "INR"); // ❌ Negative amount accepted
ProcessOrder(100.0, null, 50.0, "INR"); // ❌ Null currency crashes at runtime
Consequences:
- Production bugs from currency mismatches (₹100 + €50 = ₹150? €150?)
- Financial reports with incorrect totals due to floating-point errors
- Regulatory compliance issues from imprecise calculations
- Difficulty testing—every test must set up amount + currency pairs
- Poor discoverability—new developers don't know currency codes are required
Why a Money Value Object Solves These Problems
A well-designed Money value object addresses all these issues by:
- Encapsulation: Amount and currency always travel together
- Validation: Invalid states are impossible to construct
- Precision: Uses decimal instead of double for exact arithmetic
- Type Safety: Currency mismatches are caught at runtime — impossible to accidentally skip the check
- Immutability: Once created, Money instances can't be corrupted
- Free equality: Using a record gives structural equality with zero boilerplate
- Domain Language: Code reads like business requirements
How we gonna do?
Step 1: Define the Money Record Structure
We'll create Money as a record — not a class. Records in C# provide structural equality automatically, meaning two Money instances with the same Amount and Currency are equal without writing a single line of Equals or GetHashCode code. A private constructor keeps creation controlled through factory methods while init properties guarantee immutability after construction.
public record Money
{
public static readonly Money None = new(0m, "INR");
// Private constructor - validation happens here, factory methods call this
private Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException(
"Amount cannot be negative.", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException(
"Currency code is required.", nameof(currency));
var normalised = currency.Trim().ToUpperInvariant();
if (normalised.Length != 3 || !normalised.All(char.IsLetter))
throw new ArgumentException(
$"Invalid currency code '{currency}'. Expected 3-letter ISO 4217 code.",
nameof(currency));
// init properties assigned in constructor body
Amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
Currency = normalised;
}
// init - set once at construction, immutable thereafter
public decimal Amount { get; init; }
public string Currency { get; init; }
}
Key design decisions:
- record gives structural equality for free — no Equals, GetHashCode, or ==/!= boilerplate
- private constructor prevents external instantiation — all creation goes through factory methods
- Currency is normalised to uppercase so "inr", "INR", and "Inr" all resolve to the same value
- Rounding to 2 decimals with AwayFromZero matches standard financial rounding
Step 2: Add Factory Methods
Factory methods provide a clean, readable API for creating Money instances and centralise validation.
public record Money
{
// ... constructor and properties from Step 1 ...
// Convenience factories for common currencies
public static Money Inr(decimal amount) => Create(amount, "INR");
public static Money Usd(decimal amount) => Create(amount, "USD");
public static Money Eur(decimal amount) => Create(amount, "EUR");
public static Money Gbp(decimal amount) => Create(amount, "GBP");
// Generic factory for any ISO 4217 currency
public static Money Create(decimal amount, string currency)
=> new Money(amount, currency);
// Zero sentinel for a currency
public static Money Zero(string currency) => new Money(0m, currency);
// Parse from "100.50 INR" format
public static Money Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Value cannot be null or whitespace", nameof(value));
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
throw new FormatException(
$"Invalid money format '{value}'. Expected: '100.50 INR'");
if (!decimal.TryParse(parts[0], out var amount))
throw new FormatException($"Invalid amount: {parts[0]}");
return new Money(amount, parts[1]);
}
public static bool TryParse(string value, out Money? money)
{
money = null;
try { money = Parse(value); return true; }
catch { return false; }
}
}
Step 3: Implement Arithmetic Operations
All arithmetic operators return new instances — Money is immutable. The EnsureSameCurrency guard is shared by all operators so currency validation is defined exactly once.
public record Money
{
// ... previous code ...
// Implicit conversion lets you pass Money wherever decimal is expected
public static implicit operator decimal(Money money) => money.Amount;
public static Money operator +(Money left, Money right)
{
EnsureSameCurrency(left, right);
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator -(Money left, Money right)
{
EnsureSameCurrency(left, right);
return new Money(left.Amount - right.Amount, left.Currency);
}
public static Money operator *(Money left, decimal factor)
=> new Money(left.Amount * factor, left.Currency);
public static Money operator /(Money left, decimal divisor)
{
if (divisor == 0) throw new DivideByZeroException("Cannot divide by zero");
return new Money(left.Amount / divisor, left.Currency);
}
public static Money Abs(Money money)
=> new Money(Math.Abs(money.Amount), money.Currency);
public Money Percentage(decimal percent) => this * (percent / 100m);
private static void EnsureSameCurrency(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException(
$"Cannot operate on {left.Currency} and {right.Currency}");
}
}
Step 4: Add Comparison and Utility Members
Records handle equality automatically. Add comparison operators to support ordering.
public record Money
{
// ... previous code ...
public bool IsZero => Amount == 0m;
public bool IsPositive => Amount > 0m;
public static bool operator < (Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount < r.Amount; }
public static bool operator > (Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount > r.Amount; }
public static bool operator <=(Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount <= r.Amount; }
public static bool operator >=(Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount >= r.Amount; }
public override string ToString() => $"{Amount:N2} {Currency}";
}
Step 5: Use Money in Domain Entities
Now integrate Money into your domain model. Notice how much clearer the intent becomes compared to raw primitives:
public class Product
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public Money Price { get; private set; } // Type-safe Money, not a bare decimal
private Product(Guid id, string name, Money price)
{
Id = id;
Name = name ?? throw new ArgumentNullException(nameof(name));
Price = price ?? throw new ArgumentNullException(nameof(price));
}
public static Product Create(string name, Money price)
=> new Product(Guid.NewGuid(), name, price);
public void UpdatePrice(Money newPrice)
=> Price = newPrice ?? throw new ArgumentNullException(nameof(newPrice));
public Money CalculatePriceWithTax(decimal taxRate)
=> Price + Price.Percentage(taxRate);
}
// Usage
var product = Product.Create("Pro License", Money.Inr(299));
var priceWithTax = product.CalculatePriceWithTax(18m); // GST 18% → ₹352.82
var originalPrice = product.Price;
product.UpdatePrice(Money.Inr(249));
Console.WriteLine(originalPrice); // Still ₹299.00 INR (immutable record)
Console.WriteLine(product.Price); // ₹249.00 INR
Complete Money Record (All Steps Combined)
public record Money
{
public static readonly Money None = new(0m, "INR");
private Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative.", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency code is required.", nameof(currency));
var normalised = currency.Trim().ToUpperInvariant();
if (normalised.Length != 3 || !normalised.All(char.IsLetter))
throw new ArgumentException(
$"Invalid currency code '{currency}'.", nameof(currency));
Amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
Currency = normalised;
}
public decimal Amount { get; init; }
public string Currency { get; init; }
// Factory methods
public static Money Inr(decimal amount) => Create(amount, "INR");
public static Money Usd(decimal amount) => Create(amount, "USD");
public static Money Eur(decimal amount) => Create(amount, "EUR");
public static Money Gbp(decimal amount) => Create(amount, "GBP");
public static Money Create(decimal amount, string currency) => new Money(amount, currency);
public static Money Zero(string currency) => new Money(0m, currency);
// Implicit conversion
public static implicit operator decimal(Money money) => money.Amount;
// Arithmetic operators (all return new instances)
public static Money operator +(Money l, Money r) { EnsureSameCurrency(l, r); return new Money(l.Amount + r.Amount, l.Currency); }
public static Money operator -(Money l, Money r) { EnsureSameCurrency(l, r); return new Money(l.Amount - r.Amount, l.Currency); }
public static Money operator *(Money l, decimal f) => new Money(l.Amount * f, l.Currency);
public static Money operator /(Money l, decimal d) { if (d == 0) throw new DivideByZeroException(); return new Money(l.Amount / d, l.Currency); }
public static Money Abs(Money m) => new Money(Math.Abs(m.Amount), m.Currency);
public Money Percentage(decimal pct) => this * (pct / 100m);
// Utility
public bool IsZero => Amount == 0m;
public bool IsPositive => Amount > 0m;
// Comparison operators (records handle equality; ordering operators are manual)
public static bool operator < (Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount < r.Amount; }
public static bool operator > (Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount > r.Amount; }
public static bool operator <=(Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount <= r.Amount; }
public static bool operator >=(Money l, Money r) { EnsureSameCurrency(l, r); return l.Amount >= r.Amount; }
public override string ToString() => $"{Amount:N2} {Currency}";
private static void EnsureSameCurrency(Money l, Money r)
{
if (l.Currency != r.Currency)
throw new InvalidOperationException($"Cannot operate on {l.Currency} and {r.Currency}");
}
}
// Usage examples
var price1 = Money.Inr(100);
var price2 = Money.Inr(100);
// Record equality - no custom Equals needed, just works!
Console.WriteLine(price1 == price2); // True
Console.WriteLine(price1.Equals(price2)); // True
// Arithmetic creates new instances (immutability)
var total = price1 + Money.Inr(50);
Console.WriteLine(total); // 150.00 INR
Console.WriteLine(price1); // Still 100.00 INR
// Implicit conversion to decimal
decimal raw = Money.Inr(299); // 299
// Comparison
Console.WriteLine(Money.Inr(50) < Money.Inr(100)); // True
// Currency mismatch throws at runtime
var dollars = Money.Usd(100);
// var invalid = price1 + dollars; // ❌ Throws InvalidOperationException
// Fluent operations
var finalPrice = Money.Inr(100) * 3 - Money.Inr(50); // ₹250.00 INR
Summary
- Primitive obsession for money leads to bugs, scattered validation, and currency mismatch errors
- A Money record encapsulates amount and currency as an inseparable, immutable unit
- Use decimal instead of double for precise financial calculations without floating-point errors
- Implement Money as a record — C# records provide structural equality for free, eliminating Equals, GetHashCode, and ==/!= boilerplate
- Use a private constructor with init properties to keep instances immutable and creation controlled via factory methods
- Validate currency codes (ISO 4217) and amounts at construction time to prevent invalid states
- All arithmetic operators return new Money instances, preserving immutability
- Add comparison operators manually for ordering — records provide structural equality but not ordering
- Currency mismatch operations throw at runtime, preventing silent bugs
- Money value objects eliminate an entire class of financial calculation bugs and provide type-safe domain vocabulary