はじめに
DTO(Data Transfer Object)を書くとき、最近はclassではなくrecordを使うことが増えてきました。同じチームのコードでも、新しく書かれたDTOの多くはrecordになっています。
特に深く考えず「今っぽい書き方だから」となんとなくrecordを使っていたのですが、ふと立ち止まって考えると——
「DTOにrecordを使うのは、本当に適切な選択なのか? classと比べて何が違うのか?」
がよくわかっていないことに気づきました。なんとなく使い続けるのも不安なので、recordとclassの違いを整理し、DTOに使うべきかどうかを考えてみました。
環境
- .NET 10.0
- C# 14.0
- Visual Studio 2026
そもそもDTOとは
DTO(Data Transfer Object)は、層やプロセスの境界をまたいでデータをやり取りするためだけのオブジェクトです。APIのリクエスト/レスポンス、レイヤー間でのデータ受け渡しなどで使われ、基本的にロジックを持たず、データの入れ物として振る舞うことが期待されます。
この「データの入れ物」という性質がrecordの特性と相性が良さそうだ、というのが今回調べてみようと思ったきっかけです。
recordとは
record(正確にはrecord class)はC# 9で導入された型で、見た目はクラスとほとんど同じですが、コンパイラがいくつかのメンバーを自動生成してくれます。値型のrecord structはC# 10で追加されました。
public record UserDto(string Name, string Email);
これだけ書くと、コンパイラは次のようなメンバーを自動生成します。
-
Name、Emailの公開プロパティ(init専用、つまり初期化時のみ設定可) - 値に基づく
Equals、GetHashCode - プロパティの内容を表示する
ToString -
==、!=演算子 - 分解(Deconstruct)メソッド
これをclassで手書きしようとすると、一部の機能だけでもそれなりの行数が必要になります。
public class UserDtoClass
{
public string Name { get; }
public string Email { get; }
public UserDtoClass(string name, string email)
{
Name = name;
Email = email;
}
public override bool Equals(object? obj)
{
return obj is UserDtoClass other && Name == other.Name && Email == other.Email;
}
public override int GetHashCode() => HashCode.Combine(Name, Email);
public override string ToString() => $"UserDtoClass {{ Name = {Name}, Email = {Email} }}";
}
recordなら1行で済むところが、classでは一部を再現するだけでも十数行になりました。このボイラープレートの削減がrecordの大きな魅力の一つです。
recordとclass(プライマリコンストラクター)の違い
C# 12ではclassにもプライマリコンストラクターが使えるようになり、見た目だけはrecordにかなり近づきました。
// クラス + プライマリコンストラクター(C# 12〜)
public class UserDto(string name, string email)
{
}
ただ、これは見た目が似ているだけで、recordとは中身が大きく異なります。プライマリコンストラクターのパラメーターはクラス本体の中でしか使えず、外部に公開するNameプロパティのようなものは自動生成されません。外に公開したい場合は、自分でプロパティを書いて代入する必要があります。
public class UserDto(string name, string email)
{
public string Name { get; } = name;
public string Email { get; } = email;
}
ここまで書いても、EqualsやToStringは自動生成されません。ざっくり言えば、recordは「プライマリコンストラクター+値の等価性+ToString」をセットで提供する構文だと考えると理解しやすいです。
値に基づく等価性
classはデフォルトで参照型の等価性(同じインスタンスかどうか)を使います。一方recordは、プロパティの値がすべて等しいかどうかで判定します。
var a = new UserDto("Alice", "alice@example.com");
var b = new UserDto("Alice", "alice@example.com");
Console.WriteLine(a == b); // record: True(値が同じ)
var c = new UserDtoClass("Alice", "alice@example.com");
var d = new UserDtoClass("Alice", "alice@example.com");
Console.WriteLine(c == d); // class: False(インスタンスが違う)
テストコードで「期待するDTOと実際の戻り値が一致するか」を検証するとき、recordなら値比較用のEqualsが自動生成されるため、独自の比較ロジックを書かずにAssert.Equalを使いやすくなります。これはDTOにとって地味に嬉しいポイントです。
イミュータブルに書きやすい(with式)
位置指定構文で定義したrecordのプロパティはinit専用になるため、生成後にプロパティを直接書き換えることはできません。値を変えた新しいインスタンスが必要な場合はwith式を使います。
var original = new UserDto("Alice", "alice@example.com");
var renamed = original with { Name = "Alice Smith" };
Console.WriteLine(original.Name); // Alice(元のインスタンスは変化しない)
Console.WriteLine(renamed.Name); // Alice Smith
with式はrecordだけでなく、構造体・タプル・匿名型にも使えます。一方、通常のclassでは使えません。プライマリコンストラクターを持つclassでも同様です。DTOを「一部だけ書き換えた新しいコピー」として扱いたい場面では、recordの方が自然に書けます。
DTOにrecordが向いている理由
ここまで見た特性は、DTOの性質とよく噛み合っています。
- DTO自体には「インスタンスとしての同一性」を持たせないことが多いので、値が同じなら同じものとして扱う
recordの等価性は自然 - 位置指定構文の
record classならinit専用プロパティが自動生成される。init自体はclassでも使えるが、DTOを不変寄りに書きやすい - テストで期待値と実際の値を比較するときに、独自の比較ロジックが要らない
- ログ出力時にも自動生成された
ToStringがそのまま使える
逆に言うと、DTOでclassを使って同じ挙動が必要になった場合は、これらを自分で実装する手間が発生します。
注意した方がいい点
調べていくと、recordをDTOに使う上で気をつけたほうがよい点もいくつか見つかりました。
1. コレクションを持つと値の等価性に注意が必要
recordの自動生成Equalsは、プロパティごとにEqualityComparer<T>.Defaultを使って比較します。ここにList<T>のように、Equalsが要素ごとの比較として実装されていないコレクションが含まれると、想定と違う結果になります。
public record UserDto(string Name, List<string> Roles);
var a = new UserDto("Alice", new List<string> { "Admin" });
var b = new UserDto("Alice", new List<string> { "Admin" });
Console.WriteLine(a == b); // False(中身は同じだが別インスタンス)
List<T>はEqualsを要素比較としてオーバーライドしていないため、中身が同じでも別インスタンスなら「別物」と判定されてしまいます。値が同じでもFalseになるのは、最初に見たときはかなり混乱しました。
これは「参照型だから」というより、その型のEqualsが要素比較になっているかどうかの問題です。たとえばImmutableArray<T>に変えても、実は同じ問題が起きます(要素ごとの比較ではなく、内部配列の参照比較になるため)。コレクションを含むDTOで本当に値の等価性が必要なら、Equalsを自前で実装するか、そもそも「DTOの完全な値比較はしない」という前提で設計したほうが無難です。
この記事のコメントの内容も非常に参考になりました。
ぜひ、一読ください。
InlineArrayAttribute で作られた構造体の検証
2. EF Coreなどのエンティティには向かない
DTOとよく似た立ち位置に「エンティティ(Entity)」がありますが、こちらにrecordを使うのは慎重に考えたいところです。
エンティティは基本的にID(識別子)で同一性を判断したいオブジェクトです。一方で、recordはすべてのプロパティの値で等価性を判定します。そのため、「同じIDだが、他のプロパティが更新された2つのインスタンス」を意図せず「別物」と判定してしまうなど、エンティティの同一性の考え方とズレが生じます。
EF Core自体はコンストラクターバインディングや読み取り専用プロパティのマッピングにも対応しています。ただ、同一性の意味がずれるという点だけでも、エンティティにはclass、データの受け渡し用DTOにはrecord、と役割を分けたほうが安全だと感じました。
3. 継承するなら設計に注意
recordは継承時の等価性にも独自のルールを持っています。同じプロパティ値でも、実行時の型が違う2つのrecordは等しいと判定されません。これはrecordが内部で保持する型情報を使って意図的にそうしているためで、意図した挙動ではあるものの、継承を伴うrecordの設計はやや複雑になりがちです。DTOはそもそも継承させずフラットに保つことが多いので、大きな問題にはなりにくいですが、継承させる必要がないならsealedにして拡張を止めておくと安全です。
4. record structはデフォルトでミュータブル
DTOを大量に生成する処理でヒープ確保を避けたい場合、値型のrecord structを使う選択肢もあります。ただし、位置指定構文のrecord classはinit専用プロパティになる一方で、位置指定構文のrecord structはデフォルトでミュータブルです。
public record struct Point(int X, int Y);
var p = new Point(1, 2);
p.X = 100; // これが普通に通る(ミュータブル)
イミュータブルにしたい場合はreadonly record structを明示的に指定する必要があります。
public readonly record struct Point(int X, int Y);
var p = new Point(1, 2);
p.X = 100; // NG: コンパイルエラー
recordだからイミュータブルだろうと思い込むと、structの場合だけ挙動を見落としやすいので注意が必要です。
個人的な判断基準
調べた内容を踏まえて、自分なりの判断基準を整理しました。
recordにする場合
- レイヤーやプロセスの境界を越えてデータを受け渡すだけのDTO
- テストで期待値との比較を行いたいDTO
- 受け渡し中に書き換えられたくないDTO
classにする場合
- EF Coreなどで変更追跡が必要なエンティティ
- IDによる同一性で比較したいオブジェクト
- ミュータブルなコレクションを多く持ち、値の等価性をそもそも必要としないDTO(この場合はrecordの恩恵が薄い)
判断フロー
EF Coreなどのエンティティ(識別子で同一性を判断したい)?
└─ YES → classを使う
DTOとして層をまたいでデータを受け渡すだけ?
└─ YES → recordを使う(コレクションを含む場合は等価性に注意)
大量生成されヒープ確保のコストが気になる?
└─ YES → readonly record structを検討
└─ NO → record classで十分
まとめ
-
recordは値に基づく等価性・ToString・with式と相性のよいコピー機構などをコンパイラが自動生成してくれる型 - C# 12のプライマリコンストラクターを使った
classと見た目は近づいたが、自動生成されるメンバーは別物 - DTO自体にはインスタンス同一性を持たせないことが多く、その性質が
recordの等価性とよく噛み合う -
List<T>などのミュータブルなコレクションを含むと、値の等価性が期待通りに働かないことがある - EF Coreなどのエンティティには向かない(識別子による同一性とずれるため)
-
record structはデフォルトでミュータブルなので、イミュータブルにしたい場合はreadonly record structを使う
調べる前は「とりあえずDTOはrecordにしておけばいい」くらいの感覚でしたが、コレクションを含む場合の等価性の罠やrecord structの非対称な仕様を知ると、何も考えずに使うのは危ういと感じました。フラットなプロパティだけのDTOであれば素直にrecordを使い、コレクションを持つ場合は等価性に頼った実装をしない、という線引きで使っていこうと思います。
参考になったら いいね や ストック をお願いします!
同じような疑問を持ったことがある方のコメントもお待ちしています。
参考
- レコード - C#リファレンス(Microsoft Learn)
- with 式 - C#リファレンス(Microsoft Learn)
- 等値演算子 - C#リファレンス(Microsoft Learn)
- 言語のバージョン管理 - C#リファレンス(Microsoft Learn)
- C# 12 の新機能(Microsoft Learn)
関連リンク
技術ブログでも学びや検証内容をまとめています。
レバテックIT転職フェア出展
7月26日開催!株式会社ONE WEDGEがレバテック転職フェアに出展します!詳細・事前エントリーはこちら!