共通DTOの“仕様分岐”をレビューアーはどう判断すべきか
1. シーケンス図とコード提示による文脈の説明
まず全体構造を理解するために、DogAPIおよびCatAPIのリクエスト受信からサービス層までの流れを示します。

構造概要:
Can
クラスは共通DTOであり、Dog・CatいずれのAPIにも投入される。Can
の中にはSnack
が含まれ、その中にToy
が1つ入る設計だった。- コントローラ層とサービス層はDogとCatで分離しているが、DTOは完全に共通。
2. 設計変更が必要になった背景と仕様差の顕在化
発端
CatAPIにて以下のような要求が追加されました:
複数の
Toy
(爪研ぎ、おもちゃマウス等)を登録したい。
これにより、Can → Snack → Toy
が下記のような構造に進化しました:
public class Snack {
private List<Toy> toys; // 従来は Toy toy;
}
一方、DogAPIでは変更がなく、依然としてSnack
は1つのToy
のみを持ちます。これが「API間のDTO仕様差」です。
DTOを共通化したまま仕様分岐を許すと、どのAPIがどの仕様に準拠しているのかがコードベースから見えにくくなり、将来的な改修で不具合を誘発する温床になります。
3. PRにおける変更コードとその設計判断の是非
以下がCatAPI側でPRとして提出された変更です。
// Can.java
public class Can {
private Snack snack;
}
// Snack.java
public class Snack {
- private Toy toy;+ private List<Toy> toys; // ← PRにより変更}
// CatService.java
public class CatService {
public void feedCat(Can can) {
for (Toy toy : can.getSnack().getToys()) {
// 複数のおもちゃ処理
}
}
}
このPRの判断として、レビューアーは以下を確認する責務があります。
- DogAPIへの影響の有無
Can
という共通クラスの変更が妥当か- 既存処理の互換性が保たれているか
- 設計上、どこまでを共通にし、どこからを分岐させるか
4. レビューアーが行った指摘の解説
指摘①:「CatAPIのみの仕様変更が共通DTOに波及している」
共通DTOは双方の仕様を満たす最小構成であるべき。Cat専用の構造はCat専用DTOで切り出すべきという原則から逸脱している。
今回の Snack に toys(複数)を持たせる設計は、現状では CatAPI 特有の要件かと思います。
DogAPI 側では引き続き toy(単一)想定のため、Snack に共通で持たせると意図が混在してしまいそうです。
Cat 専用の構造として `CatSnack` を新設し、`CatCan` 経由で扱うと責務が整理されて、APIごとの進化にも備えやすくなります。
指摘②:「将来の進化に対する構造的備えが弱い」
現時点では Cat 側だけの変更ですが、将来的に Dog 側でも toy 複数化のような進化が起こる可能性はあるかもしれません。
その場合に備えて、今のうちに責務の分岐点をクラス構造上ではっきりさせておくと、後々の拡張がしやすくなります。
「共通のままにするか/APIごとに分けるか」は、進化方向の見通しで判断しておきたいところです。
指摘③:「既存DogAPIへの回帰試験が不足している」
今回の変更は CatAPI 側の拡張中心ですが、共通 DTO にも影響があるため、DogAPI 側の処理が崩れていないかを確認しておきたいです。
たとえば `null` や `空の toys リスト` を受けた場合など、DogAPI 側のユニットテストに追加しておくと安心です。
5. 指摘を反映してどうリファクタリングされたかの最終コード
共通部(Can)
public class Can {
private SnackBase snack;
}
public interface SnackBase {}
Dog向けのSnack
public class DogSnack implements SnackBase {
private Toy toy;
}
Cat向けのSnack
public class CatSnack implements SnackBase {
private List<Toy> toys;
}
CatAPIの受け口と利用
public class CatService {
public void feedCat(Can can) {
if (!(can.getSnack() instanceof CatSnack catSnack)) {
throw new IllegalArgumentException("CatSnack expected");
}
for (Toy toy : catSnack.getToys()) {
// 処理
}
}
}
この構造により、Snackの進化をAPIごとに独立して進められるようになり、DogAPIの変更は不要なままCatAPIのみ拡張できる設計となった。
6. 最終まとめとベストプラクティスの明文化
結論
共通DTOをAPI横断で使用する場合、各APIでの進化の速度差・方向差に常に備える必要がある。
今回のように、一方のAPIの仕様変更が共通DTOに影響する場合、以下の判断軸が有効です。
- “責務が共通でない箇所”を見極め、専用クラスに切り出す
- 今後の進化の余地があるかどうかを必ず検討する
- 既存APIの影響範囲はPR単位で見える化し、回帰テストで担保する
ベストプラクティス
- 共通DTOには仕様の交差部分のみを含める
- 分岐が顕在化した時点で、API別DTOを導入する判断を行う
- クラス階層を用いた
責務の局所化(interface + instanceof)
は有効な手法
このような設計判断は、レビューアーが中心となってレビュー中に指摘・提案していくべき重要な視点であり、形骸化した「文法レビュー」にとどまらない構造的レビューの実例といえます。
7. このような設計変更のレビューに必要なチェック観点
共通DTOの設計変更を含むPRレビューは、「文法レベル」や「動作確認レベル」の観点では不十分です。以下に、レビューアーが意識すべき観点を構造的に整理します。
7.1 影響範囲の特定と把握
- 共通DTOに変更が加わった場合、どのAPI・どのサービス層・どのバリデーションに影響が及ぶかを一覧化する。
- 既存の使用箇所(DogAPI)が「壊れていないか」ではなく、「設計的に破綻していないか」を確認する。
7.2 過去との整合性 vs 将来の柔軟性
- 現在のコードは動作していても、将来的な変化を見越した設計判断ができているか。
- 一見些細なDTO構造の違いが、将来的なメンテナンス負荷を大きく変える。
7.3 コンストラクタやバリデーションの責務の所在
- DTOの生成時にバリデーションがあるか、それは共通か専用か。
- 今回のように「CatSnackはtoysが1つ以上必要」など、API固有の制約をどこで表現すべきかを検討する。
7.4 破壊的変更の有無とマイグレーション
- JSONスキーマ上、
toy
→toys
のような変更はクライアント側に破壊的な影響を与えうる。 - クライアントの対応タイミングと整合を取るべきか、サーバー側で吸収すべきかを検討する。
8. 似た状況で参考にすべき設計パターン・アンチパターン
DTOの共通化と分岐設計において、経験的に見られる設計方針とその功罪をここで整理しておきます。
8.1 DTOの無制限拡張は“設計の汚染”を招く
NGパターン:
public class Snack {
private Toy toy;
private List<Toy> toys; // ←両方持たせて共通化のつもり
}
このように複数のフィールドを「互換性」の名のもとに残すと、開発者が混乱しやすくなり、実装誤りの温床になります。
複数の用途に対応するために1つのクラスにあらゆるフィールドを詰め込む設計は「God DTO」化の兆候。責務があいまいになり保守不能な構造へと向かいます。
God DTOとは、「何でもかんでも詰め込まれた道具箱」のような存在です。 本来DTOは、特定の用途に必要な情報だけを包んで渡す小箱であるべきですが、God DTOはそれを無視して、あらゆる場面に対応しようと全部入りのリュックサックのようになります。
例えるなら、旅行用のポーチに、歯ブラシも、地図も、現地通貨も、雨具も、非常食も…と詰め込んだ状態。確かに便利そうに見えますが、使うたびに中を探るのが面倒で、どこで何を使っているのか誰にも分からなくなります。
つまりGod DTOは、「一つにまとめて再利用性を上げたつもりが、構造の追跡性と変更耐性を大きく下げてしまう」失敗例です。レビューでは、「それ、本当に全APIで必要?」と立ち止まるのが重要です。
8.2 API別DTO戦略のススメ
推奨パターン:
Can
を抽象インターフェースにし、DogCan
,CatCan
として具象クラスを分離。- サービス層では適切なインスタンスを期待し、適切にキャスト。
この戦略により、仕様差による拡張がAPIごとに閉じた構造で設計可能になります。
8.3 DTOにドメインロジックを持たせない原則
アンチパターン例:
public class Snack {
public boolean isForCat() { return toys != null; }
}
DTOにこのようなロジックが入ると、責務の境界が曖昧になり、テスト困難かつ再利用性が落ちます。
9. FAQと現場での応用知見
Q1: APIごとにDTOを分けるとコード量が増えるが、無駄ではないか?
A: 増えるのは一時的な記述量であり、将来的な保守性・誤変更の回避という観点では長期的に見てコスト削減に寄与します。複雑性を早期に分離することは、ドメインの鮮明化と密接に関係します。
Q2: JSONからの逆シリアライズでどのクラスにすべきか自動判定できますか?
A: Jackson等のライブラリでは、@JsonTypeInfo
や@JsonSubTypes
を使ってシリアライズ先のクラスを切り替えることができます。が、現場では「エンドポイントで受け取る型を分ける」方が安定性・保守性が高い選択です。
Q3: 他にどういうタイミングでDTOの分離を検討すべきですか?
- 一部フィールドがAPIによって使われたり使われなかったりする場合
- バリデーションがAPIで異なる場合
- 進化方向(今回のようなリスト化など)が異なる見通しがある場合
以上、今回の記事では以下の学びを得るべきです。
- 「共通化」はコスト削減の手段だが、安易な共通化は長期的コスト増へと転化しうる。
- 設計は“現在の動作”ではなく、“将来の拡張”を基点に判断する。
- 責務境界を見極め、必要に応じてクラス構造を分離する判断をレビューアーが主導する。