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行で終わる詰め替えを、ここでは「何を出して何を出さないか」の判断を含めて自分で書きます。
要件
- 戻り値は id=
,name= ,email=<マスクしたメール> の形にすること - passwordHash は戻り値のどこにも含めないこと
- email はローカル部の先頭1文字 + + @ + ドメイン の形に伏せること。null か空なら空文字列、アットマークが無いかローカル部が空なら とすること
入出力例
toPublic(1, "taro", "taro@example.com", "$2a$10$abcdef") → "id=1,name=taro,email=t***@example.com"
toPublic(42, "hanako", "hanako.y@mail.co.jp", "hashed") → "id=42,name=hanako,email=h***@mail.co.jp"
toPublic(7, "guest", "", "x") → "id=7,name=guest,email="
toPublic(9, "noat", "invalid-mail", "y") → "id=9,name=noat,email=***"