第27章:Portの入出力モデル(内側基準で決める)📦
この章はね、「Port(差し替え口)」に流れるデータの形をちゃんと決めて、 DB都合やHTTP都合が“内側”に侵入しないようにする回だよ〜!🧼🧠💕
0. 2026年の“今”ちょいメモ(最新情報)📰🆕
- 現時点の安定版は **TypeScript 5.9 系(GitHubのLatestタグは 5.9.3)**だよ 📌 (GitHub)
- そして TypeScript は 6.0 が 7.0 への橋渡し(最後のJSベース)、7.0 はネイティブ化(Go移植)の大きな流れに向かってるみたい 🏎️💨 (Microsoft for Developers)
- Node.js 側も v24 が Active LTS など、周辺もけっこう動いてるよ 🔧 (Node.js)
(この章の内容は、こういう変化があっても崩れにくい“芯の作り方”って感じ!🧸✨)
1. まず結論:Portの入出力は「内側の言葉」で固定する🧡🔒
Portの型は、こういう方針が正解だよ👇
✅ 内側の都合(業務の言葉)で決める ❌ 外側の都合(SQLのRow、ORM型、HTTPのRequest/Response)で決めない
つまり…
- Portに
TaskRow(snake_caseなDB行)を入れない🙅♀️ - Portに
RequestやResponse(Webの型)を入れない🙅♀️ - Portは「内側が欲しい形」だけを話す🗣️✨
2. 事故りがちな例(わざと悪い例)💥😇
たとえば、DB都合が混ざるとこうなる👇
// ❌ DBの都合がそのまま入ってきてる例(よくある…)
type TaskRow = {
task_id: number; // DB都合の型
title_text: string; // DB都合の命名
is_done: 0 | 1; // DB都合の表現
created_at: string; // DB都合(ISO文字列)
};
interface BadTaskRepository {
insert(row: TaskRow): Promise<void>;
findById(task_id: number): Promise<TaskRow | null>;
}
これ、何がイヤかというと…😵💫💦
- UseCase が
0|1をbooleanに直す羽目になる created_atをDateに直す処理が内側に散らばる- そのうち「SQLの列が増えた」だけで内側が壊れる
= “差し替え口”のはずが、内側をDBに縛りつける鎖になる 🔗😭
3. じゃあどう決める?3ステップでいこ!🧭✨
Step 1:境界をまたぐ“意味”を言葉にする🗣️
このミニTaskアプリなら、Portが扱うのはだいたいこれ👇
- 保存したい:Task(の情報)💾
- 取りたい:Task(の情報)🔍
- 一覧が欲しい:Taskの並び📋
Step 2:内側の型(Value Object / DTO)を作る🧱
ここが本章のキモ!💖 「内側が扱いやすい型」を用意するよ。
TaskId(ただのstringじゃなく、意味を持たせる)🆔TaskSnapshot(Portを通る“データだけ”の表現)📸
Step 3:Portのメソッドは“UseCaseから逆算”で最小にする✂️
Portは増やすほど複雑になるから、今のUseCaseに必要な分だけね😊✨
4. Entityで返す?DTOで返す?どっちがいいの?🤔💭

ここ、迷いポイントだから、判断基準を置いとくね👇
A案:Portの入出力=Entity(Taskそのもの)🧡
✅ 良いところ
- UseCaseがそのまま使える(変換少ない)
- “ルールの塊”をそのまま扱える
⚠️ 気になるところ
- 永続化Adapterが Entity を組み立てる必要がある
- 「DBの形とズレる」ほどMapperが増える(でも本来それはOK!)
B案:Portの入出力=専用DTO(TaskSnapshot)📸
✅ 良いところ
- Portを通るものが「データだけ」になる(分かりやすい)
- 永続化・JSON化・テストがラク
⚠️ 気になるところ
- UseCase側で Entity 化(rehydrate)する手間がある
👉 この教材では **B案(TaskSnapshot)**を推すよ! 「Portの境界で“データの形”を守る」感覚が、いちばん身につきやすいから🧸✨
5. 実装してみよ!:Portの入出力モデル設計(完成形)🛠️💕
5-1. TaskId(意味付きID)を作る🆔✨
declare const taskIdBrand: unique symbol;
export type TaskId = string & { readonly [taskIdBrand]: "TaskId" };
export function toTaskId(value: string): TaskId {
// ここで形式チェックしたければしてOK(最小なら省略でもOK)
return value as TaskId;
}
ポイント💡
TaskIdをただのstringにしないことで、取り違え事故が減るよ〜!🧯✨
5-2. Portを通るDTO:TaskSnapshot 📸
export type TaskSnapshot = Readonly<{
id: TaskId;
title: string;
completed: boolean;
createdAt: Date;
completedAt: Date | null;
}>;
ポイント💡
Readonlyにして「境界を超えたデータは勝手に書き換えない」ルールを強制📌✨Dateを使うのは内側都合(DB側は文字列でも数値でも、外側で変換すればOK)⏰
5-3. Entityは“ルール担当”、Snapshotは“持ち運び担当”🎒
import { TaskId, TaskSnapshot } from "./task-types";
export class Task {
private constructor(
private readonly _id: TaskId,
private _title: string,
private _completed: boolean,
private readonly _createdAt: Date,
private _completedAt: Date | null,
) {}
static create(args: { id: TaskId; title: string; now: Date }): Task {
const title = args.title.trim();
if (title.length === 0) throw new Error("InvalidTitle"); // ここは後の章でドメインエラー化してね⚠️
return new Task(args.id, title, false, args.now, null);
}
static rehydrate(snapshot: TaskSnapshot): Task {
return new Task(
snapshot.id,
snapshot.title,
snapshot.completed,
snapshot.createdAt,
snapshot.completedAt,
);
}
complete(now: Date): void {
if (this._completed) return; // 二重完了は無視(方針は自由)
this._completed = true;
this._completedAt = now;
}
toSnapshot(): TaskSnapshot {
return {
id: this._id,
title: this._title,
completed: this._completed,
createdAt: this._createdAt,
completedAt: this._completedAt,
} as const;
}
}
ここが気持ちいいポイント😍✨
- Entityがルールを守る(タイトル空は禁止、とか)
- PortはSnapshotだけを運ぶ(DBの都合は持ち込まない)
5-4. Port(Repository)の入出力をSnapshotで固定する🔌📦
import { TaskId, TaskSnapshot } from "../entities/task-types";
export interface TaskRepository {
save(task: TaskSnapshot): Promise<void>;
findById(id: TaskId): Promise<TaskSnapshot | null>;
listAll(): Promise<readonly TaskSnapshot[]>;
}
✅ これで「UseCase ↔ Repository」の会話は 内側語彙だけになるよ✨ (DBがSQLiteでも、別の何かでも関係なし👍)
5-5. satisfies で「実装がPortを満たす」保証をつける🛡️
satisfies は「形を満たしてるか検査しつつ、型推論をなるべく壊さない」用途で便利だよ🧡 (TypeScript)
import { TaskRepository } from "../ports/task-repository";
import { TaskId, TaskSnapshot } from "../entities/task-types";
export const InMemoryTaskRepository = class {
private store = new Map<TaskId, TaskSnapshot>();
async save(task: TaskSnapshot): Promise<void> {
this.store.set(task.id, task);
}
async findById(id: TaskId): Promise<TaskSnapshot | null> {
return this.store.get(id) ?? null;
}
async listAll(): Promise<readonly TaskSnapshot[]> {
return Array.from(this.store.values());
}
} satisfies new () => TaskRepository;
ポイント💡
- 「implements」でも良いけど、
satisfiesを混ぜるとチェックが気持ちよく効くことがあるよ🧁✨ - ここは好みでOK!
6. 仕上げ:Portの入出力が“外側都合”になってないかチェック✅🧼
チェックリスト置いとくね!📝💕
- Portの型に
Row/Model/ ORM生成型が混ざってない? -
snake_caseなプロパティ名が混ざってない? -
0|1みたいなDB表現が混ざってない? - HTTPの
Request/Responseが混ざってない? - 日付・IDが「外側の表現」のまま入ってない?(変換は外側で!)
7. ミニ理解チェック問題🎓💖
TaskRepositoryがTaskRow(DB行)を返す設計の、いちばんの問題は何?😵💫TaskSnapshotをReadonlyにするメリットは?🔒- Entity と Snapshot を分けると、どこに「変換」が集まって嬉しい?🧹✨
8. 提出物(この章のゴール)📦🎁
TaskId(意味付きID)🆔TaskSnapshot(Portを通る内側DTO)📸TaskRepositoryの型(入出力がSnapshot)🔌- InMemoryの実装(Portを満たす)🧺✅
9. AI相棒に投げるプロンプト(コピペOK)🤖✨
- 「この
TaskRepositoryの入出力に、外側都合(DB/HTTP/ORM)が混ざってないか指摘して。混ざってたら“内側語彙”に直して提案して」 - 「
TaskSnapshotの項目、最小化できる?(UseCaseから逆算して不要なら削って)」 - 「EntityとSnapshotを分けた時の、変換責務(どこに置くべきか)をこの構成で整理して」
次の章(第28章)は、今作ったPortが「技術用語っぽく汚れてないか」を命名・責務で磨く回だよ🧼✨ 続けていこ〜!💪💖