本文へスキップ
BecomeCoder

C# MVVMコース · 第2章 Entity ― 業務データの核 · レッスン6

ValueObject ― TaskTitle

ブラウザで完結

導入

ValueObjectは「値そのものが同一性」を持つ型です。1000円は1000円というだけで等しく、どの1000円かを区別する必要はありません。タスクのタイトルも「空文字は不可・50文字まで」というルールを持つ以上、ただのstringのまま各所でチェックするより、専用の型TaskTitleにまとめた方が安全です。

図解

flowchart TB
    A["stringのまま各所で<br/>if (title.Length > 50) throw ..."] -->|検証がバラバラで漏れる| X["バグの温床"]
    B["TaskTitleに一本化"] -->|生成時に必ず検証| Y["不正な値が存在しえない"]

サンプル

var m1 = new Money(1000);
var m2 = new Money(1000);
Assert(m1.Equals(m2), "同じ金額は同じ値として扱う");
AssertThrows<ArgumentException>(() => new Money(-1), "負の金額は作れない");
Console.WriteLine("全テスト成功");

readonly struct Money : IEquatable<Money>
{
    public int Amount { get; }
    public Money(int amount)
    {
        if (amount < 0) throw new ArgumentException("金額は0以上です");
        Amount = amount;
    }
    public bool Equals(Money other) => Amount == other.Amount;
}

void Assert(bool condition, string name)
{
    if (!condition) throw new Exception($"FAIL: {name}");
}
void AssertThrows<TException>(Action action, string name) where TException : Exception
{
    try
    {
        action();
        throw new Exception($"FAIL: {name}(例外が発生しませんでした)");
    }
    catch (TException) { }
}
  • readonly structIEquatable<T>で「不変・値で等しい」を表現する
  • コンストラクタで不正な値をthrowして弾く(生成できた時点で常に正しい状態が保証される)
  • AssertThrows<TException>は「期待どおり例外が発生したか」を確認するテストヘルパー。これ以降、検証ロジックのテストで多用する

演習

契約どおりのTaskTitleをTDDで実装します。

var t1 = new TaskTitle("  牛乳を買う  ");
var t2 = new TaskTitle("牛乳を買う");
Assert(t1.Value == "牛乳を買う", "前後の空白はトリムされる");
Assert(t1.Equals(t2), "同じ文字列なら値として等しい");
AssertThrows<ArgumentException>(() => new TaskTitle(""), "空文字は作れない");
AssertThrows<ArgumentException>(() => new TaskTitle("   "), "空白だけも作れない");
AssertThrows<ArgumentException>(() => new TaskTitle(new string('a', 51)), "51文字は作れない");
Console.WriteLine("全テスト成功");

// TODO: 次のルールを満たす TaskTitle を実装してください
//   ・Value(string)を公開する読み取り専用プロパティに持つ
//   ・空文字/空白だけの値はArgumentExceptionで拒否する
//   ・50文字を超えたらArgumentExceptionで拒否する
//   ・前後の空白はトリムして保持する
//   ・IEquatable<TaskTitle> を実装し、Valueが同じなら等しいとみなす
void Assert(bool condition, string name)
{
    if (!condition) throw new Exception($"FAIL: {name}");
}
void AssertThrows<TException>(Action action, string name) where TException : Exception
{
    try
    {
        action();
        throw new Exception($"FAIL: {name}(例外が発生しませんでした)");
    }
    catch (TException) { }
}
___
  • 期待される出力: 全テスト成功
ヒント1を見る

readonly struct TaskTitle : IEquatable<TaskTitle> { public string Value { get; } public TaskTitle(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("タイトルは空にできません"); if (value.Length > 50) throw new ArgumentException("タイトルは50文字以内です"); Value = value.Trim(); } public bool Equals(TaskTitle other) => Value == other.Value; public override string ToString() => Value; }

ヒント2を見る

空白判定はstring.IsNullOrWhiteSpace(value)、整形はvalue.Trim()を使います(Lengthのチェックはトリム前・後どちらでも51文字の例では変わりません)

まとめ

  • ValueObjectは値そのものが同一性。不変(readonly struct)にして、コンストラクタで不正値を拒否する
  • TaskTitleは「空文字禁止・50文字以内・トリムする」という検証ロジックを1か所に閉じ込めた
  • AssertThrowsのように、例外が起きることを確認するテストの書き方も手に入れた

次回: 決まった選択肢から1つを選ぶ値、Priority(優先度)をenumで表現します。

実際に動かしてみよう

本文のサンプルや演習のコードは、コードブロック右上の「コピー」ボタンでコピーして、下のエディタに貼り付ければそのまま実行できます。

C# — ブラウザ内で実行

ブラウザ内でC#を動かす環境を読み込みます(初回のみ数秒)。
スクロールして表示された時点でも自動で読み込まれます。