پول به‌عنوان Value Object در سی‌شارپ: decimal خالی کافی نیست

پاسخ کوتاه: پول را به‌شکل یک Value Object کوچک نمایش دهید: یک مبلغ صحیح بر حسب کوچک‌ترین واحد ارز به‌علاوه خود ارز، با محاسباتی که وقتی ارزها متفاوت‌اند خطا می‌دهند و عملیات تقسیمی که هیچ واحدی را گم نمی‌کند. یک decimal خالی اجازه می‌دهد ریال را با دلار جمع کنید، کسری از ریال ذخیره کنید و موقع تقسیم بر سه یک ریال گم کنید؛ همه بی‌صدا. Money این اشتباه‌ها را در مرز دامنه به خطا تبدیل می‌کند، جایی که اصلاحشان ارزان است.

یک مثال واقعی: سه باگ بی‌صدا

این‌ها کارهایی است که decimal خالی اجازه می‌دهد:

  • ارزهای قاطی. اعتبار کیف‌پول به ریال و کارمزد به دلار هر دو decimal هستند؛ credit + fee کامپایل می‌شود و عددی بی‌معنی می‌سازد.
  • دقت ناممکن. amount * 0.075m برای کارمزد ۷٫۵ درصد کسری از ریال تولید می‌کند. کجا می‌روند؟ معمولاً به یک تابع گِرد کردن که هیچ‌کس بازبینی‌اش نکرده.
  • گم شدن واحد در تقسیم. ۱۰۰ ریال را بین سه نفر با 100 / 3 تقسیم کنید و ۹۹ ریال پرداخت می‌کنید. ریال گم‌شده پول واقعی است که لجر دیگر حسابش را ندارد.

هیچ‌کدام استثنا نمی‌دهند. هفته‌ها بعد به‌شکل یک مغایرت ظاهر می‌شوند. Value Object شکست را به لحظه‌ای منتقل می‌کند که کد کار اشتباه را انجام می‌دهد.

کد قابل اجرا

سی‌شارپ ۱۴ و .NET 10؛ خود نوع به هیچ بسته‌ای نیاز ندارد:

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}.");
}

انتخاب‌های طراحی:

  • long Minor و نه decimal: مبلغ تعداد صحیحی از کوچک‌ترین واحد است. ریال صفر رقم اعشار دارد، دلار دو رقم.
  • Of(decimal, Currency) دقت اضافه را رد می‌کند به‌جای این‌که پشت سرتان گِرد کند. اگر گِرد کردن لازم است، قبل از ساختن پول صریح انجامش دهید.
  • محاسبه checked: سرریز به‌جای چرخیدن به یک موجودی منفی، خطا می‌دهد.
  • SameCurrency از همه عملگرها محافظت می‌کند، حتی مقایسه. مقایسه ۱۰ دلار با ۱۰ ریال باگ است، نه false.
  • Allocate باقی‌مانده را پخش می‌کند: Rials(100).Allocate(1, 1, 1) می‌دهد ۳۴، ۳۳ و ۳۳ که جمعش ۱۰۰ است.
  • Toman(long) ضرب در ده را در یک جا نگه می‌دارد. کاربر به تومان فکر می‌کند؛ لجر ریال ذخیره می‌کند.

ذخیره با EF Core

complex typeهای EF Core این struct را به دو ستون در جدول مالک نگاشت می‌کنند؛ بدون جدول جدا و بدون کلید owned entity:

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));
        });
}

استفاده:

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

مزایا و معایب

مزایا

  • دسته‌های کامل باگ (ارز قاطی، باقی‌مانده گم‌شده، سرریز) در همان نقطه اشتباه به استثنا تبدیل می‌شوند.
  • قواعد کسب‌وکار درباره پول در یک نوع زندگی می‌کنند، نه تکرارشده در سرویس‌ها.
  • کد همان چیزی را می‌گوید که منظورش است: price.Allocate(70, 30) مثل خود نیازمندی خوانده می‌شود.

معایب

  • هر مرز (JSON، دیتابیس، UI) نگاشت لازم دارد. complex typeهای EF Core و یک JSON converter کوچک بیشترش را پوشش می‌دهند.
  • محاسباتی که دقت کسری لازم دارند (سود، نرخ ارز) یک گام آگاهانه برای برگشت به واحد کوچک می‌خواهند.
  • تیم باید عادت برداشتن decimal را کنار بگذارد.

کی ارزشش را دارد؟

همین که پول چیزی بیش از یک عدد نمایشی است: کیف‌پول، فاکتور، کارمزد، استرداد، تقسیم درآمد. در سیستمی که یک خطای گِرد کردن پول واقعی یک آدم واقعی است، این نوع یک فایل هزینه دارد و اولین باری که خطا بدهد هزینه‌اش را پس می‌دهد.


چیزی می‌سازید که پول جابه‌جا می‌کند و می‌خواهید مدل دامنه‌اش بازبینی شود؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

چرا مبلغ را long بر حسب کوچک‌ترین واحد نگه داریم و نه decimal؟

عدد صحیح گِرد کردن را صریح می‌کند. یک long بر حسب ریال (یا سنت) نیم‌ریال نگه نمی‌دارد، پس هر جایی که پول تقسیم می‌شود باید تصمیم بگیرد باقی‌مانده کجا برود؛ دقیقاً همان جایی که باگ‌های پولی پنهان می‌شوند. decimal برای محاسباتی مثل سود اشکالی ندارد، به شرطی که آگاهانه به واحد کوچک برگردید.

Money کلاس باشد یا struct؟

یک readonly record struct مناسب است: کوچک، تغییرناپذیر و مقایسه‌شونده بر اساس مقدار، بدون تخصیص حافظه. EF Core آن را به‌صورت complex type نگاشت می‌کند و در جدول مالک به‌شکل دو ستون ذخیره می‌شود.

تومان یا ریال؟

یک واحد ذخیره‌سازی انتخاب کنید (ریال، که واحد رسمی پول است) و فقط در لبه‌ها تبدیل کنید. کاربر تومان می‌بیند و وارد می‌کند؛ لجر ریال ذخیره می‌کند. یک متد Money.Toman(1000) ضریب ده را در یک جا نگه می‌دارد.