DTOとエンティティを分ける
このレッスンでは、エンティティをそのままレスポンスに使うことの危うさを理解し、公開してよい形へ詰め替える DTO の役割を説明できるようになります。
エンティティをそのまま返すとどうなるでしょうか
データベースのテーブルに対応するクラスを、そのままコントローラの戻り値にすると、コードは短くなります。
Java
@GetMapping("/users/{id}")
public User find(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}しかし User は本来テーブルの列をそのまま写した内部向けのクラスです。passwordHash、failedLoginCount、internalMemo、deletedAt のような、外に出す気のなかった項目まで一緒に JSON へ載ります。Jackson は「見せてよい項目」を知りません。フィールドがあれば、そのまま文字列にします。
3つの問題が同時に起きます
問題は次の3つです。
- 漏洩 — パスワードのハッシュや内部メモのような、外部に出してはいけない値が返る
- 内部構造の露出 — 列名の変更やリレーションの追加が、そのままレスポンスの形の変更になる。テーブル設計を変えただけでクライアントが壊れる
- 意図しない再帰 — 双方向の関連を持つエンティティどうしを直列化すると、参照をたどり続けて無限ループになる
とくに2つ目が効いてきます。エンティティは永続化の都合で決まる形、レスポンスは利用者の都合で決まる形です。この2つを同じクラスで兼ねると、片方の都合がもう片方を必ず引きずります。
DTO は「外に出す形」を宣言したものです
DTO は Data Transfer Object の略で、層をまたいで運ぶためだけの入れ物です。
Java
public record UserResponse(Long id, String name) {}
@GetMapping("/users/{id}")
public UserResponse find(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return new UserResponse(user.getId(), user.getName());
}このように書くと、公開する項目が型として明示されます。User にフィールドを増やしても UserResponse は変わらないので、レスポンスは勝手に太りません。逆に公開したいものが増えたときは、DTO に書き足すという明確な操作が必要になります。うっかり漏れることがなくなる、というのが最大の利点です。
| 観点 | エンティティ | レスポンス DTO |
|---|---|---|
| 形を決める都合 | テーブル設計 | クライアントの必要 |
| 含む項目 | すべての列 | 公開してよい項目だけ |
| 変更の影響 | DB 都合で変わる | API の互換性で守られる |
詰め替えはどこで書くのでしょうか
小さなうちはコントローラや Service の中で new UserResponse(...) と書くだけで十分です。規模が大きくなったら、DTO 側に static UserResponse from(User user) のようなファクトリメソッドを置くか、専用のマッパークラスを用意します。いずれにしても、やっていることは「必要なフィールドを選んで新しいオブジェクトに入れ直す」という単純な作業です。
演習で書くもの
演習では、内部モデル相当の値である id、name、email、passwordHash の4つを受け取り、公開してよい項目だけを選んで連結した文字列を返します。passwordHash は絶対に出してはいけません。email はメールアドレスなので、そのまま全部を出すのではなく、ローカル部の先頭1文字だけを残して伏せます。Spring では DTO のコンストラクタ1行で終わる詰め替えを、ここでは「何を出して何を出さないか」の判断を含めて自分で書きます。
演習で使うひな形
maskEmail("taro@example.com") は "t***@example.com" を返します。null や空文字は空文字、アットマークが無い場合やその前が空の場合は "***" になります。切り出しは用意済みなので、toPublic では公開項目を選び、passwordHash を出力しないことに集中します。
要件
- id・name・マスキング済み email の3項目だけを指定の順序で返すこと
- メールの変換には maskEmail を使うこと
- passwordHash を結果に含めないこと