第28章:DTOの感覚:外に出す箱📦
この章でできるようになること🎯
- DTOが「何のために存在するか」を説明できる📣
- ドメイン型をそのまま返さない理由が腹落ちする🧠✨
- 入力DTO → ドメイン(VO/Entity)へ変換できる🔁
- ドメイン → 出力DTOへ変換できる🔁
- 「DTOを置く場所」「変換を書く場所」が迷わない🧭
1) DTOってなに?(一言で)📦
DTO(Data Transfer Object)は “境界をまたぐとき専用のデータの箱” だよ〜☺️✨ たとえば APIのリクエスト/レスポンス、画面表示用データ、外部サービス連携の入出力…そういう「外の世界」とやり取りするときに使う箱📦
ポイントはこれ👇
- DTOは ただのデータ(ふるまい・不変条件は持たない)🧊
- ドメインは ルールとふるまい(不変条件を守る本体)🏯🛡️
2) ドメイン型をそのまま返しちゃダメな理由💥
「え、Orderをそのまま返せば楽じゃない?」ってなるんだけど…事故が起きやすい😵💫⚠️
代表的な理由(超重要)🔥
- 外に出すと“契約”になる 一度APIで返すと、クライアントがそれ前提で作る → ドメイン変更が超しんどい😇
- シリアライズで壊れる(JSON化でクラス/メソッド消える問題) クラスのふるまいはJSONに乗らない → ただの“形だけの物体”になる🫠
- 情報漏えい(内部の都合や機密が混ざる)🔐 例:内部用フラグ、監査情報、計算途中の値…外に出す必要ないよね🙅♀️
- UI都合がドメインを汚す(表示のための整形をドメインが背負う)🧹 “表示用の文字列”をドメインが持ち始めると、ルールの本体がブレる
- 境界が崩れる(第27章の「importの向き」も崩れやすい)🔁💣 UIがドメインの内部構造に依存 → 変更が波及して大炎上🔥
理想の流れはこうだよ〜🌊
3) DTO設計のコツ🪄✨(迷ったらこれ)
✅ コツ1:DTOは「ユースケース単位」で作る🎬
PlaceOrderInputDTO(注文する入力)OrderViewDTO(注文を見る出力) みたいに 目的に合わせて別々にするのがコツ☺️
✅ コツ2:DTOは“プリミティブ中心”にする🧱
外に出す形は基本これ👇
- string / number / boolean / null / 配列 / オブジェクト(JSONにそのまま乗るもの)
Money VO をそのまま返すんじゃなくて、
{ amount: number; currency: "JPY" } みたいな形に変換する感じ💴✨
✅ コツ3:入力DTOは「信用しない」😈
外から来るデータは「汚れてる前提」 → DTOを検証してからドメインへ変換するのが王道🧼✨
4) “3つの形”を分けると一気にラクになる🧠✨
同じ「注文」でも、実は形が3つあるよ〜👇
- ドメインモデル:ルールと不変条件を守る本体🏯
- DTO:外に出す/外から受ける箱📦
- 永続化モデル(DB用):保存しやすい形💾(これはinfra側の都合)
この3つを同一視すると、だいたい後で泣く😭
5) 例題:カフェ注文でDTOを作る☕🧾
5-1. 入力DTO(注文する)📥
「注文する画面」や「APIのPOST」で受ける想定の形👇
// app/dto/PlaceOrderInputDTO.ts
export type PlaceOrderInputDTO = {
customerId: string;
items: Array<{
menuItemId: string;
quantity: number; // まずはnumberで受ける(外から来るので)
}>;
};
5-2. 出力DTO(表示する)📤
「注文詳細画面」や「APIのGET」で返す想定の形👇
// app/dto/OrderViewDTO.ts
export type OrderViewDTO = {
orderId: string;
status: "Draft" | "Confirmed" | "Paid" | "Fulfilled" | "Canceled";
total: { amount: number; currency: "JPY" };
items: Array<{
menuItemId: string;
name: string;
unitPrice: { amount: number; currency: "JPY" };
quantity: number;
lineTotal: { amount: number; currency: "JPY" };
}>;
};
🌟 ここで大事:ドメインのOrderをそのまま返さない UI/APIが欲しい形に整形して返す📦✨
6) 変換(マッピング)はどこに書く?📍
おすすめはこれ👇
- DTO ↔ ドメインの変換は “アプリ層”(UseCase側)に置く
- ドメイン層はDTOを知らない(境界が守れる)🛡️
例:入力DTO → ドメインへ変換🧊➡️🏯
(VOに詰め替えて、不変条件に乗せるイメージ✨)
// app/mappers/placeOrderMapper.ts
import type { PlaceOrderInputDTO } from "../dto/PlaceOrderInputDTO";
// domain側(例)
import { CustomerId } from "../../domain/valueObjects/CustomerId";
import { MenuItemId } from "../../domain/valueObjects/MenuItemId";
import { Quantity } from "../../domain/valueObjects/Quantity";
export function toDomainPlaceOrderCommand(dto: PlaceOrderInputDTO) {
return {
customerId: CustomerId.fromString(dto.customerId),
items: dto.items.map((x) => ({
menuItemId: MenuItemId.fromString(x.menuItemId),
quantity: Quantity.fromNumber(x.quantity),
})),
};
}
例:ドメイン → 出力DTOへ変換🏯➡️📦
// app/mappers/orderViewMapper.ts
import type { OrderViewDTO } from "../dto/OrderViewDTO";
import type { Order } from "../../domain/entities/Order";
export function toOrderViewDTO(order: Order): OrderViewDTO {
return {
orderId: order.id.value, // OrderId VOの中身だけ
status: order.status.value,
total: {
amount: order.total.amount,
currency: order.total.currency,
},
items: order.items.map((i) => ({
menuItemId: i.menuItemId.value,
name: i.name.value,
unitPrice: { amount: i.unitPrice.amount, currency: i.unitPrice.currency },
quantity: i.quantity.value,
lineTotal: { amount: i.lineTotal.amount, currency: i.lineTotal.currency },
})),
};
}
💡 コツ:変換は “ただの関数” でOK! 変換クラスにしないといけない訳じゃないよ☺️✨
7) 入力DTOの検証(2026の定番:スキーマで守る🧪)
外から来るデータは信用しないので、パース(検証)→ DTO確定 → ドメイン変換が安全ルート🛡️✨
7-1. Zod / Valibot どっち?🤔
- Zod:TypeScript向けバリデーションで超定番。Zod 4は安定版として案内されてるよ📌(Zod)
さらに v4.3.0 では JSON Schema 変換(
z.fromJSONSchema())など話題も出てる🧩(GitHub) ※Zod v4は import の仕方でツリーシェイキングに影響が出る注意も共有されてるよ🌲🪓(Zenn) - Valibot:モジュラー&型安全を強く打ち出してるスキーマライブラリ(軽量志向)🪶(valibot.dev)
(どっちでもOKだけど、教材としては“まずZodで体験”が分かりやすいこと多い☺️)
7-2. Zodで入力DTOをパースする例🧼
// app/validation/placeOrderSchema.ts
import * as z from "zod"; // v4でツリーシェイキング意識の例🌲
export const placeOrderSchema = z.object({
customerId: z.string().min(1),
items: z.array(
z.object({
menuItemId: z.string().min(1),
quantity: z.number().int().min(1).max(99),
})
).min(1),
});
export type PlaceOrderInputDTO = z.infer<typeof placeOrderSchema>;
UseCase側でこう使う👇
// app/usecases/placeOrder.ts
import { placeOrderSchema } from "../validation/placeOrderSchema";
import { toDomainPlaceOrderCommand } from "../mappers/placeOrderMapper";
export async function placeOrder(raw: unknown) {
const dto = placeOrderSchema.parse(raw); // ここで“汚れた入力”を止める🛑
const command = toDomainPlaceOrderCommand(dto);
// あとはドメイン操作へ…
}
8) よくある事故(DTOまわり)😂⚠️
- DTOに メソッド を生やす(DTOが肥大化)🌋
- DTOをドメイン層に置く(境界が壊れる)💣
- 1個の巨大DTOで何でも表す(optional地獄)🫠
- 出力DTOに「内部都合の項目」を混ぜる(漏えい)🔓
- “とりあえず any” で流す(将来の自分が泣く)😭
9) ミニ演習(手を動かす)✍️✨
演習A:DTOを2種類に分けよう📦📦
PlaceOrderInputDTO(入力)OrderViewDTO(出力) を作って、同じOrderでも形が違うことを確認しよう☺️
演習B:変換をテストしよう🧪
-
入力DTO → ドメイン変換で
- quantity=0 が弾かれる(VOで止まる)
-
ドメイン → 出力DTO変換で
- 余計な内部情報が含まれてない をチェック✅
演習C:表示用DTOの項目を“削る”✂️
UIに不要なもの(例:単価、通貨、内部ステータス詳細など)を削って、 「DTOは必要なものだけ」を体験しよう🌸
10) AIの使い方テンプレ(この章は相性よすぎ🤖💕)
そのままコピペで使えるやつ置いとくね👇
- 「このユースケース(注文確定)で、入力DTO/出力DTOを提案して。外部に出すべきでない項目も指摘して」
- 「DTO→ドメイン変換の骨格だけ作って。VO生成は
fromString/fromNumberを呼ぶ形で」 - 「出力DTOが将来の変更に強いように、項目の粒度を2案出して(詳細/簡易)」
- 「DTOを1つにまとめた場合のデメリデメを列挙して」
コツ:AIには「目的」「境界」「出さない情報」を必ず言うと精度が上がるよ☺️✨
11) まとめ(この章の合言葉)📣✨
- DTOは 境界の箱📦
- ドメインは 本丸🏯(不変条件とふるまい)
- 外に出す形と内側の形は違っていい(むしろ違うべき)💖
- 変換はアプリ層で、素直に関数でOK🔁
- 入力はスキーマで止めると安心🧼🛡️(Zod/Valibotなど)
2026ミニ豆知識(最新ネタ)🗞️✨
- TypeScriptは 5.8 で
--module node18の安定フラグが追加されてるよ📦(TypeScript) - TypeScript 5.9 では
import deferが入ってきて、モジュール実行タイミングを遅らせる提案に対応してるよ⏳(TypeScript) (この章の主役ではないけど、“境界”や“モジュール”が大きくなるほど効いてくる話!)
次の第29章は、いよいよ「フォルダと空ファイル」を用意して、ここまでの型(DTO/Mapper)を置く場所まで一気に固めていくよ〜📁✨