👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Designing a Money Value Object in .NET - Eliminating Primitive Obsession

Designing a Money Value Object in .NET - Eliminating Primitive Obsession

Author - Abdul Rahman (Bhai)

DDD

23 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

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

Why record and Not struct?

Since DDD Value Objects share characteristics with .NET value types — immutability and equality by value — you might ask: why not use a struct instead of a record? There are three concrete reasons to always prefer record:

  • No inheritance: struct types cannot inherit from other types. If you ever need a base ValueObject class to share common behaviour across multiple value objects, structs are immediately disqualified
  • ORM compatibility: EF Core maps class-based owned types natively via OwnsOne. Structs require extra configuration and create boxing/unboxing overhead internally
  • Nullable semantics: A struct cannot be null. Expressing "no price assigned yet" requires the awkward Nullable<Money> syntax everywhere, whereas a record class is nullable by default
  • Record advantage: C# record is a reference type that gives you structural equality, immutability via init, and inheritance support — all with zero boilerplate

Always use record (or record class) over struct for DDD Value Objects in .NET.

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 4b: Conscious Equality — Not All Properties Must Participate

A subtle but important design consideration: not every property of a Value Object needs to participate in equality. A record automatically includes all init properties in its equality check — which is exactly right for Money, where both Amount and Currency define what makes two money values equal.

However, consider a more complex Value Object like Address. It might carry a Notes field for user comments that should not affect whether two addresses refer to the same location. In those cases, equality must be a conscious, explicit decision — you override the record's generated Equals and GetHashCode to include only the properties that define semantic identity:


// ✅ Money — all properties define equality, record's default is perfect
public record Money
{
    public decimal Amount   { get; init; }
    public string  Currency { get; init; }
    // Amount AND Currency both participate — correct, nothing to override
}

// ⚠️ Address — Notes should NOT affect equality
public record Address
{
    public string  Street  { get; init; }
    public string  City    { get; init; }
    public int     ZipCode { get; init; }
    public string? Notes   { get; init; }  // delivery note — must NOT affect equality

    // Override record's generated Equals to exclude Notes
    public virtual bool Equals(Address? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Street  == other.Street
            && City    == other.City
            && ZipCode == other.ZipCode;
        // Notes intentionally excluded
    }

    public override int GetHashCode() =>
        HashCode.Combine(Street, City, ZipCode);
        // Notes intentionally excluded
}

// Usage
var addr1 = new Address { Street = "123 Main St", City = "Mumbai", ZipCode = 400001, Notes = "Leave at door" };
var addr2 = new Address { Street = "123 Main St", City = "Mumbai", ZipCode = 400001, Notes = "Call on arrival" };

Console.WriteLine(addr1 == addr2);  // True — same location despite different Notes
      

Key takeaway: Equality for Value Objects is a domain decision, not a technical one. Ask: "What makes two instances of this concept the same thing?" For Money, both amount and currency matter — ₹100 and $100 are not the same. For Address, only the location data matters — a delivery note doesn't change where a parcel will be sent.

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
  • Always prefer record (reference type) over struct for Value Objects — records support inheritance, integrate cleanly with ORMs via OwnsOne, and allow nullable semantics
  • Equality in Value Objects is a domain decision — explicitly choose which properties define sameness; when a property should not affect equality (like a comment or label), override Equals and GetHashCode to exclude it
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Value Objects
  • Money Pattern
  • Primitive Obsession
  • .NET
  • C#
  • Financial Calculations
  • Immutability
  • Type Safety