【C#/Blazor】実務Webアプリ開発編 (19)値オブジェクトをC#のrecordで実装する ~業務ルールを型に閉じ込める~
実務Webアプリ開発編です。前回までPart III(アーキテクチャ概論)として、クリーンアーキテクチャやDDDの考え方を見てきました。
今回からPart IV(ドメイン層の実装)に入り、DDDの考え方でドメイン層をどのように実装していくかを解説します。

最初の題材として、値オブジェクトをC#のrecordでどう実装するかを見ていきましょう。
以下のような方に役立つ内容となっています。
- 値オブジェクトを聞いたことはあるが、実際のコードでどう書くのか分からない
- C#のrecordをどんな場面で使うと効果的か知りたい
- バリデーションをどこに書けばよいか迷っている、あるいは複数の場所に散らばっている
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
Part IVでは、クリーンアーキテクチャにおいて、業務ルールの中核となるドメイン層のコードを見ていきます。
その入口として、まずはEmail型を例に、なぜ専用の型を作るのかを体感していきましょう。
なぜ値オブジェクトが必要か
値オブジェクトは、ドメインの意味を持つ値を専用の型として表現し、そのルールを型の中に閉じ込めたものです。
たとえばメールアドレスをただのstringで扱うと、その文字列が妥当かどうかを毎回どこかで確認しなければなりません。
まずは、stringのまま渡す場合を見てみます。
// stringで渡す場合
void RegisterUser(string email) { ... }
// 呼び出し側は「このstringはバリデーション済みか?」を知らない
// UserServiceでチェック?AuthServiceでもチェック?
// どこかでスキップしても気づきにくいこの形だと、バリデーションが複数箇所に散らばりやすくなります。
逆に、Emailという専用の型を用意すると、型が存在している時点で検証済みという前提を作れます。
次はEmail型で受け取る場合です。
// Email型で渡す場合
void RegisterUser(Email email) { ... }
// Email型が存在している = バリデーション済みの証明
// new Email("invalid") はArgumentExceptionになるため、
// 不正なEmailインスタンスは作れないEmail型のコンストラクタは、不正な値を渡すと即座にArgumentExceptionを投げます。
つまり、Emailインスタンスが存在している時点で、その値はすでに検証を通過しています。
ルール変更時にも、直す場所はEmail.csに集約されます。これはオブジェクト指向における「カプセル化」の仕組みです。カプセル化については以下の記事も参考にしてください。
さらに、stringではなくドメインの概念を型で表すことで、取り違えをコンパイラが防ぐという効果も得られます。
(取り違えの例:string型のnameへ、string型のemailの値を代入してしまう)
Email型に閉じ込めると、型が存在している時点で正しい値という保証を作れるのです。
値オブジェクトの特徴とC#での実装
値オブジェクトを実装する際に押さえておきたい特徴と、C#での実現方法を見ていきます。
値オブジェクトの3つの特徴
値オブジェクトとして実装する際に意識する主な特徴は以下の3つです。
| 特徴 | 意味 | うれしさ |
|---|---|---|
| 不変性 | 一度作ったら中身を変えない | 途中で値が壊れにくい |
| 構造的等価性 | 同じ値なら等しいとみなす | 値として比較しやすい |
| バリデーション内包 | 生成時に妥当性をチェックする | 不正なインスタンスを作れない |
「不正な値のインスタンスを作れない」という感覚をまずしっかりつかんでおくとようでしょう。
後続のコードは「この型ならもう大丈夫」と考えられるため、条件分岐や重複チェックを減らしやすくなります。
DDDではオブジェクトをエンティティと値オブジェクトに分けて考えます。
エンティティはIDで識別され(Userは住所が変わっても同じUser)、値オブジェクトはIDを持たず値そのもので識別されます(同じアドレスのEmailは等しい)。
C#のrecordが値オブジェクトに向いている理由
C#では、値オブジェクトを表現するのにrecordがよく合います。
理由は、構造的等価性を標準で備えているからです。値が同じなら等しい、という値オブジェクトらしい性質を自然に表せます。
new Email("a@b.com") == new Email("a@b.com")この比較が自然に成り立つのは、recordが「参照の同一性」ではなく「値の同一性」を重視しているためです。
さらにsealedを付けると継承を禁止できます。値オブジェクトの意味を途中で変えにくくなるため、設計が安定します。
recordとclassって、結局どこが違うの?
classでもEqualsを自前で実装すれば同じことはできます。recordなら==・Equals・GetHashCodeをコンパイラが自動生成してくれます。
同じ値を持つ2つのインスタンス(record型)を「等しい」とみなせるのは、この仕組みがあるからです。
値オブジェクトではこの性質が重要なので、recordがぴったりはまるのです。
recordについて詳しくは、Microsoftのリファレンスも参考にしてください。
実装を見る
ここからは、MentorAppの実コードを見ながら確認していきます。
Email型の実装
Email.csはMentorApp.Domain/Models/Users/に置かれています。
コード全体は次のとおりです。
using MentorApp.Domain.Models.Shared;
namespace MentorApp.Domain.Models.Users;
/// <summary>
/// メールアドレス値オブジェクト
/// </summary>
/// <remarks>
/// IdP から取得するため最低限のチェックのみ実施。
/// </remarks>
public sealed record Email
{
/// <summary>
/// メールアドレスの最大長(RFC 5321準拠)
/// </summary>
public const int MaxLength = 254;
public string Value { get; }
public Email(string? value)
{
Validate(value).ToValidationErrors(nameof(Value)).ThrowIfInvalid();
Value = value!.Trim();
}
public static IEnumerable<string> Validate(string? value)
{
return ValidationHelper.ValidateEmail(value, MaxLength, "メールアドレス");
}
public override string ToString() => Value;
}まず重要なのは、型宣言がsealed record Emailになっていることです。
recordで値としての等価性を表し、sealedで継承を止めています。値オブジェクトの性質に自然に合う形です。
次に、Validate(value).ToValidationErrors(nameof(Value)).ThrowIfInvalid()をコンストラクタの最初で呼んでいます。
つまり、不正なEmailは生成の時点で弾かれるようになっています。これが「型の存在 = バリデーション済み」の土台です。
加えて、Value = value!.Trim();としているのは、前後の空白を除去してから保持し、同じメールアドレスを余計な空白付きで別物のように扱ってしまうのを防ぐためです。
Validate()を静的メソッドとして分離し、これを単独で外部から呼び出して使えるようにしているのもポイントです。
例えば、UIのフォームバリデーションやAPIのリクエスト処理など、インスタンスを生成する前にルールだけ確認したい場面でも呼び出せます。
バリデーションのロジック自体は、アプリの様々な層で利用したくなるため、再利用しやすいようにしています。
なお、MentorAppでは認証基盤(IdP)がメールアドレスを管理するめ、Email.csでは最低限のフォーマットチェックにとどめています。
バリデーションの基盤
Email型の実装を見たところで、内部で使われているバリデーションの仕組みについて補足します。
Email型のコンストラクタで呼ばれているこの1行が、バリデーションの要です。
Validate(value).ToValidationErrors(nameof(Value)).ThrowIfInvalid();この1行は、3つのステップで動いています。
まずValidate(value)で、もし値が不正であれば何が問題かを文字列メッセージのリストとして取得します。
次に.ToValidationErrors(nameof(Value))で、そのメッセージを「どのプロパティで何が問題か」という情報(ValidationError)に変換します。
最後に.ThrowIfInvalid()で、エラーが1件でもあればArgumentExceptionを投げます。
これらは値オブジェクトをまたいで使える汎用的な仕組みとして、MentorApp.Domain/Models/Shared/配下のValidationError.csとValidationExtensions.csに実装されています。
public record ValidationError(string Property, string Message);
public static IEnumerable<ValidationError> ToValidationErrors(
this IEnumerable<string> messages, string propertyName)
=> messages.Select(m => new ValidationError(propertyName, m));
public static void ThrowIfInvalid(this IEnumerable<ValidationError> errors)
{
var errorList = errors.ToList();
if (errorList.Count > 0)
{
var messages = errorList.Select(e => $"{e.Property}: {e.Message}");
throw new ArgumentException(string.Join(Environment.NewLine, messages));
}
}複数プロパティを持つ値オブジェクト(もしくはエンティティ)について、どこが不正かをすべて一度に収集して返せるのがこの仕組みの要点です。
UIのフォームで「すべてのエラーをまとめて表示したい」場面でも、各Validate()をConcatでつなぐだけで対応できます。
User.csでEmail型の使われ方
値オブジェクトの価値は、そのものの実装だけではなく、どのように使われているかを見るとより分かりやすくなります。
User.csでは、Emailが次のように使われています。
public class User
{
// ...
public Email Email { get; private set; } = null!;
public User(string? externalId, string? displayName, DateTimeOffset createdAt, string? email, Role role = Role.Mentee)
{
Validate(externalId, displayName, email).ThrowIfInvalid();
Id = Guid.NewGuid();
ExternalId = externalId!;
DisplayName = displayName!;
Email = new Email(email);
Role = role;
CreatedAt = createdAt;
}
public static IEnumerable<ValidationError> Validate(
string? externalId, string? displayName, string? email)
{
return ValidateExternalId(externalId).ToValidationErrors(nameof(ExternalId))
.Concat(ValidateDisplayName(displayName).ToValidationErrors(nameof(DisplayName)))
.Concat(Email.Validate(email).ToValidationErrors(nameof(Email)));
}
public void UpdateEmail(string email) => Email = new Email(email);
}UserのEmailプロパティは、もはやstringではありません。
そのため、Userを扱うコードは「Emailは妥当な形式である」という前提で読み書きできます。
また、User.Validate()の中でもEmail.Validate(email)を呼んでいます。これにより、Userを生成する前に全プロパティのエラー一覧をまとめて取得できます。
UIのフォームバリデーションなど、インスタンスを生成せずにルールだけ確認したい場面で活用できます。
User.Validate()内でEmail.Validate()を呼んだ後、new Email(email)のコンストラクタでも内部的にバリデーションが走るため、二重チェックになっています。
これは意図的な設計です。Emailのコンストラクタは呼び出し元を問わず常に不正な値を弾くことを保証します。
MentorAppではEmailだけが値オブジェクト
MentorAppでは、Domain層で値オブジェクトになっているのはEmailだけです。
DisplayNameやExternalIdは、必須・最大長といった比較的単純なルールです。そのため、エンティティ内(Userクラス)の静的Validate()で十分と判断しています。
public class User
{
public string ExternalId { get; private set; } = null!; // string のまま
public string DisplayName { get; private set; } = null!; // string のまま
public Email Email { get; private set; } = null!; // 値オブジェクト
// ...
}一方Emailは、単なる文字列よりも専用の型として扱う意味が明確な値です。型で守る設計上の効果が分かりやすいため、値オブジェクトとする効果が高いと判断しています。
より複雑なドメインでは、以下のような意味やルールを持つ値を値オブジェクトにする場面が増えていきます。
Money(金額と通貨を一体で扱う)PhoneNumber(電話番号の形式を保証する)PostalCode(郵便番号の桁数や形式を保証する)
ただし、値オブジェクトを増やすほど実装コストは上がります。
フレームワークとの連携設定やチームへの設計共有など、採用には一定のコストがかかるため、効果とのトレードオフで判断することになります。
たとえばEF Coreでは、値オブジェクトを増やすとマッピング設定も増えます。この点はInfrastructure層の説明で扱います。
まとめ
今回は、MentorAppのEmail型を通して、値オブジェクトが何を守ってくれるのかを見てきました。
ポイントは、値そのものにルールを閉じ込めることです。そうすると、後続のコードがずっと読みやすくなります。
- 値オブジェクトの役割:ビジネスルールを型に閉じ込め、不正な値のインスタンスを作れないようにする
- recordとの相性:不変性・構造的等価性に加えて、
sealedによる継承禁止も表現しやすい - バリデーションの一元化:ルールを1か所に集めることで、重複やチェック漏れを防ぎやすくなる
次回(第20回)は、エンティティと集約ルートの実装を見ていきます。値オブジェクトの次は、ドメインの中心となるエンティティへ進みましょう。
引き続き、ドメイン層のエンティティ実装について一緒に学んでいきましょう!




