データ表現オブジェクトが語るコードの意図 - DTO, VO, Entityの役割と使い分け
ソフトウェア開発において、データの構造や表現方法はコードの意図を伝える上で非常に重要です。特に、アプリケーションの異なる層間でのデータの受け渡しや、ビジネス上の概念をコードで表現する際には、どのようなデータ表現オブジェクトを選択するかが、コードの可読性、保守性、そして設計思想を大きく左右します。
本稿では、データ転送オブジェクト(DTO: Data Transfer Object)、値オブジェクト(VO: Value Object)、エンティティ(Entity)といった、代表的なデータ表現オブジェクトに焦点を当て、それぞれがどのような意図を持ち、どのように使い分けることでコードの意図を明確に伝えられるのかを解説します。
データ表現オブジェクトの役割と意図
アプリケーションは通常、複数の層に分割されます。例えば、プレゼンテーション層、アプリケーション層、ドメイン層、インフラストラクチャ層などです。これらの層の間でデータをやり取りする際や、各層内でデータを表現する際に、どのようなオブジェクトを使用するかがコードの「意図」を明確にします。
- DTO (Data Transfer Object): 主に異なる層やシステム間でのデータの転送のために使用されます。データそのものを運ぶことに特化しており、原則としてビジネスロジックは持ちません。
- VO (Value Object): 値そのものに意味があり、その値によって同一性が判断されるオブジェクトです。不変であることが多く、関連する振る舞いをカプセル化することがあります。例えば、「金額」や「座標」などがVOとして表現されることがあります。
- Entity: 識別子を持ち、その識別子によって同一性が判断されるオブジェクトです。状態変化を伴うことがあり、ビジネスロジックの中心となることが多いです。例えば、「ユーザー」や「注文」などがEntityとして表現されます(特にドメイン駆動設計において)。
これらのオブジェクトを適切に使い分けることで、「このデータは層をまたいで転送されるためのものだ」「このデータは単なる文字列ではなく、特定の意味を持つ不変な値だ」「これはビジネス上の識別可能な実体を表しており、状態を持つ」といった開発者の意図をコードで表現できます。
DTOで伝える「転送されるデータ」の意図
DTOは、主にプレゼンテーション層とアプリケーション層の間、またはアプリケーション層とインフラストラクチャ層(例えば、外部APIとの連携)の間でデータをやり取りする際に使用されます。DTOを使用する意図は、以下の点を明確にすることです。
- 必要なデータのみを渡す: 層間でやり取りする際に不要な情報を含めない。
- 特定の形式に整形されたデータを提供する: APIレスポンスや画面表示に必要な形にデータを変換して渡す。
- 依存関係を疎にする: 特定の層の内部的なデータ構造(例: DB Entity)が他の層に漏れ出すのを防ぐ。
Before: DTOを使わない場合
例えば、データベースから取得したユーザー情報をAPIレスポンスとして返す際に、DB Entityをそのまま使用するコードです。
// DB Entity (仮定)
public class UserEntity {
private Long id;
private String username;
private String passwordHash; // パスワードハッシュ
private String email;
private boolean isActive;
// ... getter/setter, その他のフィールド
}
// APIコントローラー (仮定)
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public UserEntity getUser(@PathVariable Long id) {
// DB Entityをそのまま返す
return userService.findUserById(id);
}
}
この例では、プレゼンテーション層(コントローラー)がインフラストラクチャ層(DB Entity)に直接依存しています。また、APIレスポンスにpasswordHash
のような公開すべきでない情報が含まれてしまう可能性があります。これは、「API経由でユーザーの公開情報を提供したい」という意図がコードから読み取りにくく、セキュリティリスクも伴います。
After: DTOを使用する場合
APIレスポンス専用のDTOを定義し、Service層やController層でEntityからDTOへの変換を行います。
// APIレスポンス用DTO
public class UserResponseDto {
private Long id;
private String username;
private String email;
private boolean isActive;
// コンストラクタ、getter/setter
public UserResponseDto(Long id, String username, String email, boolean isActive) {
this.id = id;
this.username = username;
this.email = email;
this.isActive = isActive;
}
// ... getter/setter
}
// APIコントローラー (仮定)
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public UserResponseDto getUser(@PathVariable Long id) {
UserEntity userEntity = userService.findUserById(id);
// EntityからDTOへ変換して返す
return new UserResponseDto(userEntity.getId(), userEntity.getUsername(), userEntity.getEmail(), userEntity.isActive());
}
}
Afterのコードからは、「このAPIはユーザーの公開可能な情報のみを返すことを意図している」ということが、UserResponseDto
というデータ構造自体から明確に伝わります。また、プレゼンテーション層とインフラストラクチャ層の結合度が下がり、それぞれの変更が互いに影響しにくくなります。
VOで伝える「意味のある値」の意図
VOは、特定の意味を持つ値を表現し、その値に関連する振る舞いをカプセル化するために使用されます。VOを使用する意図は、以下の点を明確にすることです。
- 単なるプリミティブ型以上の意味を表現する: 例えば、「金額」は単なる数値ではなく、「通貨」や「小数点以下の桁数」といった属性や、「加算」「減算」といった振る舞いを伴う概念であることを示す。
- 不変性を保証する: VOは原則として不変(immutable)です。これにより、値の変更による意図しない副作用を防ぎ、コードの予測可能性を高めます。
- バリデーションルールをカプセル化する: その値が有効であるためのルール(例: メールアドレスの形式、金額が負でないこと)をVO自身が持つことで、バリデーションロジックの重複を防ぎ、意図を集中させます。
Before: プリミティブ型で済ませる場合
金額を単なるint
やdouble
で表現するコードです。
// 注文クラス (仮定)
public class Order {
private int amount; // 金額 (円)
public void processPayment(int paymentAmount) {
if (paymentAmount < amount) {
// エラー処理
throw new IllegalArgumentException("支払金額が不足しています");
}
// 支払い処理...
}
// ... getter/setter, その他のメソッド
}
// 使用箇所 (仮定)
Order order = new Order();
order.setAmount(1000); // 単位や通貨が不明瞭
order.processPayment(900); // バリデーションがメソッド内に散在
このコードでは、amount
が「金額」であること、そしてその単位が「円」であることはコメントや変数名でしか伝わりません。また、金額に関するバリデーション(負の金額は許されないなど)や振る舞い(税込み価格の計算など)は、それを使用する各箇所に散らばりがちです。これは、「この数値が何を表すのか」「どのような制約があるのか」という意図が不明瞭になる典型的な例です。
After: VOを使用する場合
金額を表現するMoney
というVOを定義します。
// 金額VO
public class Money {
private final int amount; // 金額本体
private final String currency; // 通貨
public Money(int amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("金額は負であってはなりません");
}
if (currency == null || currency.isEmpty()) {
throw new IllegalArgumentException("通貨を指定してください");
}
this.amount = amount;
this.currency = currency;
}
public int getAmount() { return amount; }
public String getCurrency() { return currency; }
// VOとしての同一性判定 (金額と通貨が同じなら同一)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount == money.amount && Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
// VOに関連する振る舞いをカプセル化
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("通貨が異なります");
}
return new Money(this.amount + other.amount, this.currency);
}
}
// 注文クラス (仮定)
public class Order {
private Money totalAmount; // 金額 (VOを使用)
public Order(Money totalAmount) {
if (totalAmount == null) {
throw new IllegalArgumentException("合計金額を指定してください");
}
this.totalAmount = totalAmount;
}
public void processPayment(Money paymentAmount) {
// VO自身が持つ情報や振る舞いを利用
if (paymentAmount.getAmount() < totalAmount.getAmount() || !paymentAmount.getCurrency().equals(totalAmount.getCurrency())) {
throw new IllegalArgumentException("支払金額が不足しているか、通貨が異なります");
}
// 支払い処理...
}
// ... getter/setter, その他のメソッド
}
// 使用箇所 (仮定)
Money price = new Money(1000, "JPY"); // 金額が「1000円」であることが明確
Order order = new Order(price);
Money payment = new Money(900, "JPY");
order.processPayment(payment); // VOによって意図が明確になり、関連チェックも容易に
Afterのコードでは、Money
というVOを使用することで、「この値は単なる数値ではなく、通貨を持つ金額という概念である」という意図が強く伝わります。不変性が保証され、金額に関するバリデーションや計算といった振る舞いがMoney
クラス内にカプセル化されるため、コードの再利用性や保守性が向上します。
Entityで伝える「識別可能な実体」の意図
Entityは、一意な識別子を持ち、そのライフサイクルの中で状態が変化する可能性のあるオブジェクトです。Entityを使用する意図は、ビジネス上の「識別可能な何か」をコードで表現し、関連するドメインロジックを保持することにあります。
- ビジネス上の実体を表現: 「ユーザー」「商品」「注文」など、現実世界のオブジェクトや概念をコードでモデル化する。
- 識別子による同一性: 値が同じかどうかではなく、識別子(ID)が同じかどうかでオブジェクトの同一性を判断する。
- 状態と振る舞いの保持: その実体の属性(状態)と、その実体に対して行われる操作(振る舞い、ビジネスロジック)を持つ。
DB EntityがORMによってデータベースのテーブルとマッピングされることが多いですが、ドメイン層におけるEntityは、必ずしもDB構造と1対1である必要はありません。ドメインの意図を正確に反映することが重要です。
DTO, VO, Entityは、それぞれ異なる役割と意図を持っています。これらを適切に使い分けることで、コードは単なる手続きの羅列ではなく、「このデータはなぜこの形なのか」「このオブジェクトは何を表すのか」「この値にどのような制約や意味があるのか」といった、開発者の意図や設計思想を明確に語り始めます。
よくあるアンチパターンとその改善
- DB Entityの使い回し: データベース層のEntityを、アプリケーション層やプレゼンテーション層でDTOやVOのように使い回す。これは、DB構造が他の層に漏れ出し、意図しない依存関係を生みます。また、Entityが持つ不要な情報(パスワードハッシュなど)が露出するリスクや、DTOが必要とする整形されたデータ構造を提供できない問題が発生します。
- 改善策: 各層や目的(APIレスポンス、DB保存、画面入力など)に応じた専用のDTOやVOを定義し、必要に応じてEntityとの間で変換を行います。
- DTOにビジネスロジックを持たせる: DTOはデータ転送用であり、ビジネスロジックはドメイン層のEntityやServiceに持たせるべきです。DTOにロジックを持たせると、責務が曖昧になり、コードの意図が不明瞭になります。
- 改善策: ロジックはDTOではなく、それを操作するServiceや、関連するEntity、VOなどに移譲します。
- VOを使うべき場面でプリミティブ型を使う: 特定の意味を持つべき値(金額、期間、電話番号など)をプリミティブ型や汎用的な文字列型で表現する。これにより、その値の持つ意味や制約、関連する振る舞いがコードから読み取りにくくなります。
- 改善策: 意味のある値には専用のVOを定義し、不変性やバリデーションルール、関連する振る舞いをVOにカプセル化します。
まとめ
DTO、VO、Entityといったデータ表現オブジェクトは、それぞれが明確な目的と役割を持っています。
- DTO: 層間での安全で効率的なデータ転送の意図を伝える。
- VO: 特定の値が持つ意味や制約、関連する振る舞いの意図を伝える。
- Entity: ビジネス上の識別可能な実体とそのライフサイクル、ドメインロジックの意図を伝える。
これらのオブジェクトを適切に設計し、使い分けることは、コードの意図を明確にし、可読性、保守性、そしてチーム開発における共通理解の促進に大きく貢献します。単にデータを保持する箱としてではなく、「このデータはどのような目的で、どのような意味を持っているのか」という意図をコードで表現するために、データ表現オブジェクトの活用を意識してみてください。これにより、コードレビューでの指摘が減り、他者の書いたコードもよりスムーズに理解できるようになるはずです。