第30章 HTTP導入②:Request→DTO変換、Response整形 🔁📮
![hex_ts_study_030[(./picture/hex_ts_study_030_request_processing_flow.png) ![hex_ts_study_030[(./picture/hex_ts_study_030_request_response_dtos.png)
この章は「HTTPの入口(Inbound Adapter)」がやるべき 翻訳 を、ちゃんと“型”と“ルール”で固める回だよ〜😊💖 やることはシンプル!
- リクエスト(params/query/body)→ UseCase入力DTO に変換する 🔁
- 返り値(UseCase出力DTO)→ HTTPレスポンス に整形する 📦
- 失敗(バリデーション/仕様エラー)→ 標準っぽいエラー形式 に変換する 🧯
0) 2026/01/23 時点の“最新版メモ”🆕📝
- Node.js は v24 が Active LTS(新規ならこれが安心寄り)で、v22/v20 は Maintenance LTS だよ 📌 (nodejs.org)
- TypeScript の安定版は 5.9.3(npmのlatest)🧩 (npm)
- Zod は v4系が安定で、npmのlatestは 4.3.5(2026/01時点)🔍 (npm)
- エラー形式は RFC 9457(Problem Details) が “標準の軸” として使いやすいよ 🧯 (RFCエディタ)
1) 今回のゴール 🎯✨
この章を終えると…
- 「HTTPの生データ」→「UseCaseが欲しい形」に 迷わず変換できる 🔁😊
- 入口でバリデーションして、中心へゴミを持ち込まない 🚯🛡️
- エラー時も、レスポンスが いつも同じ形 で返せる 📦✨
2) まず“流れ”を1枚で理解しよ 🗺️🏃♀️💨
HTTP Request
↓ (params/query/body を読む)
Inbound Adapter(この章)
↓ ① validate(入口で!)
↓ ② map(UseCase Input DTOへ)
UseCase(中心)
↓ Output DTO or Domain/App Error
Inbound Adapter(この章)
↓ ③ present(HTTP Responseへ)
HTTP Response
ポイントはこれ👇 中心(UseCase)は “HTTPを1ミリも知らない” こと!🙅♀️🌐
3) 入口(HTTP Adapter)が「やること / やらないこと」🚪🧩
やること ✅
- params/query/body を読む 👀
- 文字列→number/boolean などに変換する 🔁
- バリデーションする(空文字NG、UUID形式、長さ制限…)🧪
- UseCase入力DTOを作る 📮
- UseCaseの結果をHTTPレスポンスへ整形する 📦
- エラーをHTTP向けに変換する(ステータス、形)🧯
やらないこと ❌
- 業務ルール判断(「完了は二重適用禁止」みたいな本体ルール)🚫 → それは UseCase / Domain の仕事だよ 🧠🛡️
4) 今回の方針:Zodで“入口を固める” 🔒🧪✨
TypeScriptの型だけだと、実行時に「変なJSON」が来たとき守れないの🥲 だから入口で ランタイム検証するよ!
- Zodは スキーマ=型 を作れて便利(v4系が安定)🔍 (npm)
5) 実装の配置(迷子防止)📁🧭
こんな感じで分けるとスッキリするよ〜😊
-
src/adapters/http/todo.schemas.ts(Zodスキーマ置き場)🧪todo.mapper.ts(Request→InputDTO、OutputDTO→Response)🔁todo.presenter.ts(成功レスポンス整形)📦problem.ts(エラーレスポンス整形)🧯
6) コード:スキーマ(入口の検問所)🧪🚧
// src/adapters/http/todo.schemas.ts
import { z } from "zod";
export const AddTodoBodySchema = z.object({
title: z.string().trim().min(1, "title は必須だよ").max(200, "title は200文字までだよ"),
});
export const TodoIdParamSchema = z.object({
id: z.string().uuid("id は UUID 形式でね"),
});
export const ListTodosQuerySchema = z.object({
// 例:?completed=true
completed: z
.string()
.optional()
.transform((v) => {
if (v === undefined) return undefined;
if (v === "true") return true;
if (v === "false") return false;
return "INVALID";
})
.refine((v) => v !== "INVALID", "completed は true/false だけだよ"),
});
ここが気持ちいいポイント💖
.trim().min(1)で「空白だけ」も弾ける ✂️- queryの
"true"/"false"を boolean に翻訳できる 🔁
7) コード:Request → UseCase入力DTO へ変換 🔁📮
UseCase側に、例えばこういうDTOがある想定ね👇(すでに前章までで作ってる感じ)
// src/app/dto/todo.ts(例)
export type AddTodoInputDto = { title: string };
export type CompleteTodoInputDto = { id: string };
export type ListTodosInputDto = { completed?: boolean };
HTTP側で “翻訳関数” を作るよ😊
// src/adapters/http/todo.mapper.ts
import { ZodError } from "zod";
import {
AddTodoBodySchema,
TodoIdParamSchema,
ListTodosQuerySchema,
} from "./todo.schemas";
import type { AddTodoInputDto, CompleteTodoInputDto, ListTodosInputDto } from "../../app/dto/todo";
export function toAddTodoInput(body: unknown): AddTodoInputDto {
const parsed = AddTodoBodySchema.parse(body);
return { title: parsed.title };
}
export function toCompleteTodoInput(params: unknown): CompleteTodoInputDto {
const parsed = TodoIdParamSchema.parse(params);
return { id: parsed.id };
}
export function toListTodosInput(query: unknown): ListTodosInputDto {
const parsed = ListTodosQuerySchema.parse(query);
return { completed: parsed.completed };
}
// ZodError かどうか判定したい時用(便利)
export function isZodError(err: unknown): err is ZodError {
return err instanceof ZodError;
}
超だいじ📌
ここで作ったDTOだけ を UseCase に渡すの。
request.body をそのまま投げるのは卒業〜🎓✨
8) レスポンス整形:成功時の“見せ方”📦✨
成功時も「UseCaseの返り値」をそのまま返すんじゃなく、HTTPの形に整えると未来が楽😊💕
// src/adapters/http/todo.presenter.ts
export type TodoHttpResponse = {
todo: {
id: string;
title: string;
completed: boolean;
};
};
export function presentTodo(todo: { id: string; title: string; completed: boolean }): TodoHttpResponse {
return {
todo: {
id: todo.id,
title: todo.title,
completed: todo.completed,
},
};
}
export type TodosHttpResponse = {
todos: Array<{ id: string; title: string; completed: boolean }>;
};
export function presentTodos(todos: Array<{ id: string; title: string; completed: boolean }>): TodosHttpResponse {
return { todos: todos.map((t) => ({ id: t.id, title: t.title, completed: t.completed })) };
}
「いまは同じ形じゃん?」って思ってもOK👌 将来(フィールド追加/削除、レスポンス互換)で守られるよ🛡️
9) エラー整形:RFC 9457(Problem Details)で統一 🧯📦
エラーの形が毎回バラバラだと、クライアント側が泣く😭 そこで “標準の型” に寄せるのが強い✨(RFC 9457) (RFCエディタ)
// src/adapters/http/problem.ts
export type ProblemDetails = {
type: string; // エラー種別URI(自分のドメインでOK)
title: string; // 人間向け短いタイトル
status: number; // HTTP status
detail?: string; // 具体説明
instance?: string;// そのエラーの発生箇所(任意)
// extension fields: 追加情報も入れてOK(RFC的に許可される)
errors?: Array<{ path: string; message: string }>;
};
export function problem(params: ProblemDetails): ProblemDetails {
return params;
}
10) “入口バリデーションエラー”を ProblemDetails にする 🧪➡️🧯
Zodのエラーは情報がたっぷりあるから、それを “整形して返す” と超親切😊💖
// src/adapters/http/zodProblem.ts
import type { ZodError } from "zod";
import { problem, type ProblemDetails } from "./problem";
export function zodToProblem(err: ZodError, instance?: string): ProblemDetails {
return problem({
type: "https://example.com/problems/validation-error",
title: "Validation Error",
status: 400,
detail: "リクエストの形式が正しくないよ",
instance,
errors: err.issues.map((i) => ({
path: i.path.join(".") || "(root)",
message: i.message,
})),
});
}
11) “UseCaseのエラー”を HTTP に翻訳する 🧠➡️🌐
ここがヘキサゴナルの気持ちよさポイント💖 中心が投げるエラー(仕様)を、HTTP向けに ここでだけ 変換する!
例:
TodoNotFound→ 404AlreadyCompleted→ 409BusinessRuleViolation→ 400 など
// src/adapters/http/appErrorMap.ts
import { problem, type ProblemDetails } from "./problem";
// 例:中心が返すエラー型(プロジェクトの実体に合わせてね)
export type AppError =
| { kind: "TodoNotFound"; id: string }
| { kind: "TodoAlreadyCompleted"; id: string }
| { kind: "Unexpected"; message: string };
export function appErrorToProblem(err: AppError, instance?: string): ProblemDetails {
switch (err.kind) {
case "TodoNotFound":
return problem({
type: "https://example.com/problems/todo-not-found",
title: "Todo Not Found",
status: 404,
detail: `Todo が見つからないよ (id=${err.id})`,
instance,
});
case "TodoAlreadyCompleted":
return problem({
type: "https://example.com/problems/todo-already-completed",
title: "Todo Already Completed",
status: 409,
detail: `すでに完了してるよ (id=${err.id})`,
instance,
});
default:
return problem({
type: "https://example.com/problems/unexpected",
title: "Unexpected Error",
status: 500,
detail: err.message,
instance,
});
}
}
12) ルートで使う:Controllerは“薄く”👩🍳✨
フレームワークは何でもいいけど、例として Fastify で書くね(TSとの相性も良い)。 Fastifyは Type Providers など型推論の仕組みもあるよ〜📌 (Fastify)
// src/adapters/http/todo.routes.ts
import type { FastifyInstance } from "fastify";
import { toAddTodoInput, toCompleteTodoInput, toListTodosInput, isZodError } from "./todo.mapper";
import { presentTodo, presentTodos } from "./todo.presenter";
import { zodToProblem } from "./zodProblem";
import { appErrorToProblem, type AppError } from "./appErrorMap";
export async function registerTodoRoutes(app: FastifyInstance, deps: {
addTodo: (input: { title: string }) => Promise<{ id: string; title: string; completed: boolean } | AppError>;
completeTodo: (input: { id: string }) => Promise<{ id: string; title: string; completed: boolean } | AppError>;
listTodos: (input: { completed?: boolean }) => Promise<Array<{ id: string; title: string; completed: boolean }> | AppError>;
}) {
app.post("/todos", async (req, reply) => {
const instance = "/todos";
try {
const input = toAddTodoInput(req.body);
const result = await deps.addTodo(input);
if (isAppError(result)) {
const p = appErrorToProblem(result, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
return reply.code(201).send(presentTodo(result));
} catch (e) {
if (isZodError(e)) {
const p = zodToProblem(e, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
const p = appErrorToProblem({ kind: "Unexpected", message: "なにか変なことが起きたよ…" }, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
});
app.post("/todos/:id/complete", async (req, reply) => {
const instance = "/todos/:id/complete";
try {
const input = toCompleteTodoInput(req.params);
const result = await deps.completeTodo(input);
if (isAppError(result)) {
const p = appErrorToProblem(result, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
return reply.code(200).send(presentTodo(result));
} catch (e) {
if (isZodError(e)) {
const p = zodToProblem(e, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
const p = appErrorToProblem({ kind: "Unexpected", message: "なにか変なことが起きたよ…" }, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
});
app.get("/todos", async (req, reply) => {
const instance = "/todos";
try {
const input = toListTodosInput(req.query);
const result = await deps.listTodos(input);
if (isAppError(result)) {
const p = appErrorToProblem(result, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
return reply.code(200).send(presentTodos(result));
} catch (e) {
if (isZodError(e)) {
const p = zodToProblem(e, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
const p = appErrorToProblem({ kind: "Unexpected", message: "なにか変なことが起きたよ…" }, instance);
return reply.code(p.status).type("application/problem+json").send(p);
}
});
}
function isAppError(v: unknown): v is AppError {
return typeof v === "object" && v !== null && "kind" in v;
}
ここでの美しさ😍✨
- 変換(Request→DTO)✅
- 例外・エラー翻訳(→ProblemDetails)✅
- 表示(Output→Response)✅
- ルール判断は中心へ ✅
13) 動作チェック(手で叩く)🧪🔨
例:ToDo追加
curl -X POST http://localhost:3000/todos ^
-H "Content-Type: application/json" ^
-d "{\"title\":\"Buy milk\"}"
タイトル空で叩くと、入口で 400 & application/problem+json が返ってくるはず🧯✨
(この “いつも同じ形” が超大事!)
14) AI活用:この章での“安全な頼り方”🤖💖
✅ 使ってOK(速くなる)
- 「このエンドポイントのZodスキーマ作って」🧪
- 「ProblemDetailsのerrors配列の整形案ちょうだい」🧯
- 「presenterのレスポンス例もっと増やして」📦
⚠️ ちょい注意(芯がブレやすい)
- 「UseCaseの責務をHTTPに寄せる提案」→ やめとく🙅♀️
- 「domainにrequest型を置こう」→ 全力で拒否🛡️🔥
AIに投げるテンプレ(コピペOK)📝✨
- 「HTTP adapter でやるべきなのは変換と呼び出しだけ。業務ルールは入れない。Request→InputDTO、OutputDTO→Response、Error→ProblemDetails の設計をレビューして」🤖✅
15) チェックリスト(合格ライン)✅🎀
-
request.body/params/queryを 直接 UseCaseに渡してない?🙅♀️ - バリデーションは入口で完結してる?🧪
- エラー時レスポンスが 毎回同じ形?📦
-
application/problem+jsonを使えてる?🧯 (RFCエディタ) - presenterで “見せ方” を固定できてる?✨
16) 自主課題(ミニ)📝💖
GET /todos?limit=10&offset=20を追加して、queryをDTOへ変換してみよ🔁- Zodのエラーに
errors: [{path,message}]を入れて返すの、もっと親切にしてみよ😊 TodoNotFoundのtypeを “自分のプロジェクトURL” に寄せて整理してみよ📌
次の章(HTTP導入③)で、「中心コードが1行も変わらない」快感を確認しに行こうね😊💕