Money as a value object in C#: stop using a bare decimal

Short answer: represent money as a small value object: an integer amount in the currency's smallest unit plus the currency, with arithmetic that throws when currencies differ and a split operation that never loses a unit. A bare decimal lets you add Rials to dollars, store fractions of a Rial, and lose a Rial when you divide by three, all silently. Money makes those mistakes fail at the domain boundary, where they are cheap to fix.

A real example: three silent bugs

Here's what a bare decimal allows:

  • Mixed currencies. A wallet credit in IRR and a fee in USD are both decimal, so credit + fee compiles and produces a meaningless number.
  • Impossible precision. amount * 0.075m for a 7.5% fee yields fractions of a Rial. Where do they go? Usually into a rounding call nobody reviewed.
  • Lost units on split. Split 100 Rials between three parties as 100 / 3 and you pay out 99. The missing Rial is real money that the ledger no longer accounts for.

None of these throws. They show up weeks later as a reconciliation mismatch. A value object moves the failure to the moment the code does the wrong thing.

Runnable code

C# 14 / .NET 10, no packages needed for the type itself:

public sealed record Currency(string Code, int MinorUnits)
{
    public static readonly Currency Irr = new("IRR", 0);
    public static readonly Currency Usd = new("USD", 2);

    public static Currency FromCode(string code) => code switch
    {
        "IRR" => Irr,
        "USD" => Usd,
        _ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown currency."),
    };
}

/// <summary>An amount in a currency's smallest unit (Rials, cents). Arithmetic across currencies throws.</summary>
public readonly record struct Money(long Minor, Currency Currency) : IComparable<Money>
{
    public static Money Zero(Currency currency) => new(0, currency);

    public static Money Rials(long rials) => new(rials, Currency.Irr);

    /// <summary>1 Toman = 10 Rials. Users think in Toman; the ledger stores Rials.</summary>
    public static Money Toman(long toman) => new(checked(toman * 10), Currency.Irr);

    public static Money Of(decimal amount, Currency currency)
    {
        var scaled = amount * (decimal)Math.Pow(10, currency.MinorUnits);
        if (scaled != decimal.Truncate(scaled))
        {
            throw new ArgumentException($"{amount} has more precision than {currency.Code} allows.", nameof(amount));
        }

        return new Money((long)scaled, currency);
    }

    public bool IsNegative => Minor < 0;

    public static Money operator +(Money a, Money b) => new(checked(a.Minor + b.Minor), SameCurrency(a, b));

    public static Money operator -(Money a, Money b) => new(checked(a.Minor - b.Minor), SameCurrency(a, b));

    public static Money operator -(Money a) => new(checked(-a.Minor), a.Currency);

    public static bool operator <(Money a, Money b) => a.CompareTo(b) < 0;

    public static bool operator >(Money a, Money b) => a.CompareTo(b) > 0;

    public static bool operator <=(Money a, Money b) => a.CompareTo(b) <= 0;

    public static bool operator >=(Money a, Money b) => a.CompareTo(b) >= 0;

    public int CompareTo(Money other)
    {
        SameCurrency(this, other);
        return Minor.CompareTo(other.Minor);
    }

    /// <summary>Splits by ratios without losing or inventing a single minor unit: leftovers go to the first parts.</summary>
    public IReadOnlyList<Money> Allocate(params int[] ratios)
    {
        if (ratios.Length == 0 || ratios.Any(r => r < 0) || ratios.Sum() == 0)
        {
            throw new ArgumentException("Ratios must be non-negative and not all zero.", nameof(ratios));
        }

        var (minor, currency, total) = (Minor, Currency, ratios.Sum());
        var parts = ratios.Select(r => minor * r / total).ToArray();
        var remainder = minor - parts.Sum();
        for (var i = 0; remainder > 0; i = (i + 1) % parts.Length, remainder--)
        {
            parts[i]++;
        }

        return [.. parts.Select(p => new Money(p, currency))];
    }

    public override string ToString() => Currency == Currency.Irr
        ? $"{Minor:N0} IRR"
        : $"{Minor / (decimal)Math.Pow(10, Currency.MinorUnits):N2} {Currency.Code}";

    private static Currency SameCurrency(Money a, Money b) =>
        a.Currency == b.Currency
            ? a.Currency
            : throw new InvalidOperationException($"Cannot combine {a.Currency.Code} with {b.Currency.Code}.");
}

The design choices:

  • long Minor, not decimal: an amount is a whole number of the smallest unit. IRR has 0 minor units, USD has 2.
  • Of(decimal, Currency) refuses extra precision instead of rounding behind your back. If you need rounding, do it explicitly before you create the money.
  • checked arithmetic: an overflow throws instead of wrapping into a negative balance.
  • SameCurrency guards every operator, including comparisons. Comparing 10 USD with 10 IRR is a bug, not false.
  • Allocate distributes the remainder: Rials(100).Allocate(1, 1, 1) gives 34, 33 and 33, which sums to 100.
  • Toman(long) keeps the ×10 in one place. Users think in Toman; the ledger stores Rials.

Storing it with EF Core

EF Core complex types map the struct to two columns in the owner's table, with no separate table and no owned-entity key:

public sealed class Invoice
{
    public int Id { get; set; }
    public Money Total { get; set; }
}

public sealed class BillingDb(DbContextOptions<BillingDb> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        // Stored as two columns, TotalAmount and TotalCurrency; still one value in C#.
        modelBuilder.Entity<Invoice>().ComplexProperty(i => i.Total, money =>
        {
            money.Property(m => m.Minor).HasColumnName("TotalAmount");
            money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3)
                .HasConversion(c => c.Code, code => Currency.FromCode(code));
        });
}

Using it:

var total = Money.Toman(250_000);   // 2,500,000 IRR
var shares = total.Allocate(70, 30); // the shares always add back up to total
var mixed = total + Money.Of(1.5m, Currency.Usd); // throws InvalidOperationException

Pros and cons

Pros

  • Whole classes of bugs (mixed currencies, lost remainders, overflow) become exceptions at the point of the mistake.
  • Business rules about money live in one type instead of being repeated across services.
  • The code says what it means: price.Allocate(70, 30) reads like the requirement.

Cons

  • Every boundary (JSON, database, UI) needs a mapping. EF Core complex types and a small JSON converter cover most of it.
  • Calculations that need fractional precision (interest, FX rates) need a deliberate step back to minor units.
  • The team has to stop reaching for decimal out of habit.

When it's worth it

As soon as money is more than a display value: wallets, invoices, fees, refunds, revenue splits. In a system where a rounding error is a real person's real money, the type costs one file and pays for itself the first time it throws.


Building something that moves money and want its domain model reviewed? See how I can help.

Frequently asked questions

Why store minor units as long instead of using decimal?

Integers make rounding explicit. A long of Rials (or cents) can't hold half a Rial, so every place that divides money has to decide what happens to the remainder, which is exactly where money bugs hide. decimal is fine for calculations like interest, as long as you convert back to minor units deliberately.

Should Money be a class or a struct?

A readonly record struct suits it: it's small, immutable and compared by value, and it doesn't allocate. EF Core maps it as a complex type, so it lives in the owner's table as two columns.

What about Toman versus Rial?

Pick one storage unit, the Rial, the official currency unit, and convert only at the edges. Users type and read Toman; the ledger stores Rials. A Money.Toman(1000) factory keeps the factor of ten in one place.