メインコンテンツまでスキップ

第11章 値オブジェクト(Value Object)①:Moneyを作る💰💎

この章は「お金を“ただのdecimal”にしない」練習だよ〜🙂✨ Money を作って、不変条件を“型の中”に閉じ込める体験をしよう🛡️🎀 (C# 14 / .NET 10 が現行ラインだよ) (Microsoft Learn)


1) 今日のゴール🎯✨

Money VO

できるようになること👇

  • Money値で同一性を持つ(Value Object)って説明できる🙂💎 (Microsoft Learn)
  • Money を **不変(immutable)**にして、壊れた金額を作らせない🔒❄️
  • 通貨+金額+丸めのルールを Money の中に入れられる💰📌
  • 足し算・引き算で「通貨が違う」事故を防げる🚫💥

2) まず「Moneyの仕様」を決めよっか📝💡

Money を作る前に、最低限これを決めると迷子にならないよ🥰

✅ 仕様その1:通貨はISO 4217の3文字コードにする💱

例:JPY USD EUR みたいなやつね✨(ISO 4217) (ISO) 👉 ルール:英字3文字・大文字だけ許可

✅ 仕様その2:金額は decimal を使う💎

お金系は double じゃなくて decimal が基本🫶 理由:10進数の計算に強くて、金融計算向きって公式でも明言されてるよ✨ (Microsoft Learn)

✅ 仕様その3:この章では「支払い金額」用途で 負数は禁止🙅‍♀️

サブスク料金や商品価格って、通常マイナスはおかしいよね?🙂 (残高や差額みたいに負数が必要な世界は、後で “用途別VO” に分ければOK🎀)

✅ 仕様その4:小数点以下は「通貨の桁」に合わせる🔢

  • JPY:0桁
  • USD:2桁
  • BHD:3桁 …など(ISO 4217に minor unit がある) (ウィキペディア)

この章では簡単にするために👇の方針にするよ:

  • Money通貨ごとの小数桁を“設定で渡す”(後で拡張しやすい)

✅ 仕様その5:丸めは “ToEven(銀行丸め)” をデフォにする🏦

.NET の Round はデフォが MidpointRounding.ToEven(いわゆる銀行丸め)だよ✨ (Microsoft Learn)


3) 設計の狙い:decimal直渡しの何が怖いの?😱💥

たとえばこんなの👇が起きるのが嫌なの:

Currency Mismatch

  • 通貨が混ざって足される(JPY + USD とか)💣
  • 小数桁が合ってない(JPYなのに 100.5 円が通る)🌀
  • どこかで -1 円が入り込む😵‍💫
  • 画面入力の "1,000" が国/文化で解釈ズレる🧨

DDDでも Money は代表的な Value Object って言われるくらい「型にすると嬉しい」やつだよ💎 (Microsoft Learn)


4) 実装してみよう🏗️💖(Money + Result)

ここからは、壊れない Money を作るよ〜!✨ (第7章の Result が既にある前提なら、そこは読み替えてOKだよ🙂)

4-1) まずはミニResult🧾🙂

public readonly record struct Result<T>(bool IsSuccess, T? Value, string? Error)
{
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}

4-2) Money本体(不変+Factoryで生成集中)💰🔒

ポイントはこれ👇

  • コンストラクタは private(外からnewさせない)🏭
  • TryCreate で不変条件チェック🛡️
  • record を使って値の等価性(同じ通貨・同じ金額なら同一扱い)💎 (Microsoft Learn)
using System.Text.RegularExpressions;

public sealed record Money
{
public string Currency { get; }
public decimal Amount { get; }

private Money(string currency, decimal amount)
=> (Currency, Amount) = (currency, amount);

// 例: "JPY" -> 0, "USD" -> 2
public static Result<Money> TryCreate(string currency, decimal amount, int minorDigits)
{
// currency
if (string.IsNullOrWhiteSpace(currency))
return Result<Money>.Failure("通貨コードが空だよ🥺");

currency = currency.Trim().ToUpperInvariant();

// ISO 4217 っぽく「英字3文字」だけに制限
if (!Regex.IsMatch(currency, "^[A-Z]{3}$"))
return Result<Money>.Failure("通貨コードは英字3文字(例: JPY, USD)にしてね💱");

// amount
if (amount < 0m)
return Result<Money>.Failure("金額がマイナスはダメだよ🙅‍♀️");

if (minorDigits is < 0 or > 28)
return Result<Money>.Failure("小数桁設定が変だよ😵‍💫");

// 小数桁を通貨の桁に揃える(桁が多すぎたら拒否)
var rounded = decimal.Round(amount, minorDigits, MidpointRounding.ToEven);
if (rounded != amount)
return Result<Money>.Failure($"小数点以下は {minorDigits} 桁までだよ🔢");

return Result<Money>.Success(new Money(currency, amount));
}

public Result<Money> Add(Money other, int minorDigits)
{
if (Currency != other.Currency)
return Result<Money>.Failure("通貨が違うものは足せないよ💥");

return TryCreate(Currency, Amount + other.Amount, minorDigits);
}

public Result<Money> Subtract(Money other, int minorDigits)
{
if (Currency != other.Currency)
return Result<Money>.Failure("通貨が違うものは引けないよ💥");

var result = Amount - other.Amount;
return TryCreate(Currency, result, minorDigits); // マイナスならここで落ちる
}

public Result<Money> Multiply(decimal rate, int minorDigits)
{
if (rate < 0m)
return Result<Money>.Failure("倍率がマイナスはダメだよ🙅‍♀️");

// 金融系は丸め方が大事:ToEven(銀行丸め)が.NETの標準寄り :contentReference[oaicite:8]{index=8}
var raw = Amount * rate;
var rounded = decimal.Round(raw, minorDigits, MidpointRounding.ToEven);
return TryCreate(Currency, rounded, minorDigits);
}

public override string ToString() => $"{Currency} {Amount}";
}

丸めの話:.Round の既定は ToEven だよ(.NET docs) (Microsoft Learn)


5) 使ってみよ〜🥰🎀(サブスク課金の例)

const int JpyDigits = 0;

var fee = Money.TryCreate("JPY", 980m, JpyDigits);
var discount = Money.TryCreate("JPY", 100m, JpyDigits);

if (fee.IsSuccess && discount.IsSuccess)
{
var payable = fee.Value!.Subtract(discount.Value!, JpyDigits);
Console.WriteLine(payable.IsSuccess ? payable.Value!.ToString() : payable.Error);
}

6) テストを書こう🧪✨(xUnit例)

「Money の不変条件が“壊れない”」って、テストで守るのが超大事だよ🫶

using Xunit;

public class MoneyTests
{
[Fact]
public void Create_Fails_When_Negative()
{
var r = Money.TryCreate("JPY", -1m, 0);
Assert.False(r.IsSuccess);
}

[Fact]
public void Create_Fails_When_TooManyDecimals_For_JPY()
{
var r = Money.TryCreate("JPY", 100.5m, 0);
Assert.False(r.IsSuccess);
}

[Fact]
public void Add_Fails_When_Currency_Differs()
{
var a = Money.TryCreate("JPY", 100m, 0).Value!;
var b = Money.TryCreate("USD", 1m, 2).Value!;
var r = a.Add(b, 0);
Assert.False(r.IsSuccess);
}

[Fact]
public void Multiply_Rounds_ToEven()
{
// 例: ToEven を確認するテストは値を丁寧に選ぶのがコツ🙂
var m = Money.TryCreate("USD", 2.345m, 3).Value!;
var r = m.Multiply(1m, 2); // 2桁へ丸め
Assert.True(r.IsSuccess);
Assert.Equal(2.34m, r.Value!.Amount); // ToEvenの例として(docs参照) :contentReference[oaicite:10]{index=10}
}
}

7) “落とし穴”チェックリスト🤖🔍(AIに洗い出しさせる)

AIにこう聞くと便利だよ〜✨(コピペOK)

  • 「Money 値オブジェクトでありがちな落とし穴を10個、理由付きで」🤖
  • 「通貨が違う加算をコンパイル時/実行時に防ぐ設計案を3つ」🤖⚖️
  • 「JPY(0桁) / USD(2桁) / BHD(3桁) を想定したテストケースを大量生成して」🤖🧪

※AIが提案する「通貨の小数桁」を文化情報から取る案が出てきたら注意⚠️ NumberFormatInfo.CurrencyDecimalDigits は “カルチャの通貨表示” 寄りで、通貨コードそのものの厳密表とは別軸になりやすいよ〜🌀 (Microsoft Learn)


8) ちょい発展🎀(通貨コードはどう取る?)

「国/地域 → ISO通貨コード」を取りたい時は RegionInfo.ISOCurrencySymbol が使えるよ💱✨ (Microsoft Learn)

using System.Globalization;

var jp = new RegionInfo("JP");
Console.WriteLine(jp.ISOCurrencySymbol); // "JPY"

ただしこれは “地域” から引くので、アプリの仕様によっては「ユーザー選択の通貨」を素直に受け取る方が安全なことも多いよ🙂


9) まとめ🏁🎉

  • Money を VO にすると、意図が伝わるし、事故が減る💎✨ (Microsoft Learn)
  • decimalrecordFactory で「壊れないお金」を作れる🛡️
  • 通貨一致・小数桁・丸めは、Money の責務に入れると強い💰📌
  • 丸めは .NET の標準(ToEven)を理解して使い分けできると安心🏦✨ (Microsoft Learn)

次章予告📧💎

次は Email みたいな 文字列VO にいくよ〜! 「正規表現はほどほど(KISS)」「正規化(trim/lower)」あたりが楽しいところ🥰🎀