第80章:中間演習:Repository差し替え体験🔁🎮
(テーマ:**「保存先を変えても、ユースケースは同じでOK!」**を体で覚える✨)
この章でできるようになること🎯✨
- Repositoryを**「取り替え可能な部品」**として扱える🧩
- **InMemory → 永続化(ファイル保存)**に差し替えても、ユースケース側のコードをほぼ触らずに済む😎
- 「どこに何を書けばいいか」がブレなくなる(domain / app / infra の境界が固まる)🏰
- “差し替えられる”ことをテストで保証できる🧪✅
まず大事な一言🌸
Repositoryは 「保存の詳細を隠すフタ」 だよ📦 だから、ユースケース(Application Service)は 「保存して」「取り出して」って頼むだけでOK🙆♀️ どこに保存するか(DB/ファイル/メモリ)は infra が勝手に頑張る💪✨
最新事情メモ(2026)🧡🆕
- TypeScript の最新版は 5.9 系(npmの “latest” が 5.9 と明記)だよ🧡 (typescriptlang.org)
- TypeScript 6.0 / 7.0 に向けた「大きい変化」も進行中(6.0は橋渡し、7.0はネイティブ化の流れ)🚀 (Microsoft for Developers)
- Node.js は v24 が Active LTS、v25 は Current(用途により使い分け)🟢 (nodejs.org)
- ESLint は v10.0.0 が 2026-02-06 にリリースされてるよ🧹✨ (eslint.org)
- Vitest は 4.0 が出ていて、4.1 のβも動いてる感じ🧪🔥 (vitest.dev)
(※ここから先の教材コードは、TS 5.9 + Vitest + ESLint Flat Configあたりの前提で書くよ💡)
今日のゴール:Repositoryを2種類作って入れ替える🔁✨
作るものはこの2つ👇
- InMemoryOrderRepository(メモリ保存:速い・簡単)🧠⚡
- JsonFileOrderRepository(ファイル保存:永続化っぽい体験)💾📁
そして最後に… ✅ 同じユースケース(PlaceOrder / GetOrder)がそのまま動くのを確認する🎉
1) 前提:domain に「Repositoryのinterface」だけ置く📚
ここがDDDの肝だよ〜!🌸 domain は “保存の方法” を知らない 🙅♀️ 知っていいのは「こういう機能の保存箱が欲しい」だけ📦
例:OrderRepository(domain側)
// src/domain/order/OrderRepository.ts
import { Order } from "./Order";
import { OrderId } from "./OrderId";
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}
ポイント💡
interfaceは domain- 実装(Mapで持つ、ファイルに書く、DBに入れる)は infra
2) 実装①:InMemoryOrderRepository(まずはMap)🗺️✨
「Repositoryってこういう感じか〜」を最速で掴めるやつ!
// src/infra/repository/InMemoryOrderRepository.ts
import { OrderRepository } from "../../domain/order/OrderRepository";
import { Order } from "../../domain/order/Order";
import { OrderId } from "../../domain/order/OrderId";
export class InMemoryOrderRepository implements OrderRepository {
private readonly store = new Map<string, Order>();
async save(order: Order): Promise<void> {
this.store.set(order.id.value, order);
}
async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id.value) ?? null;
}
}
よくある落とし穴⚠️(ここで覚えると強い!)
-
**Orderがミュータブル(中身が書き換え可能)**だと、外から参照して勝手に変えられるかも😵💫
- 対策:Orderを不変寄りにする(理想)🧊
- もしくは保存時・取得時に「複製」する(現実策)🪞
3) 実装②:JsonFileOrderRepository(永続化の雰囲気💾)
次は「保存先を変える」を体験するよ〜!🎮✨ ここで重要になるのが “ドメインをそのままJSONにしない” って感覚🧠
3-1) 永続化用DTO(infra側)を作る📦
OrderはVOやメソッドを持ってるから、保存用は別の形にするのがキレイ✨
// src/infra/repository/OrderRecord.ts
export type OrderRecord = {
id: string;
status: "Draft" | "Confirmed" | "Paid" | "Fulfilled" | "Canceled";
lines: Array<{
menuItemId: string;
quantity: number;
unitPriceYen: number;
}>;
createdAtIso: string;
};
3-2) Mapper(Order ⇄ OrderRecord)を作る🔁
ここはAIに骨格を作ってもらって、最後は自分で“意味”を合わせるのが超おすすめ🤖🛠️
// src/infra/repository/orderMapper.ts
import { Order } from "../../domain/order/Order";
import { OrderId } from "../../domain/order/OrderId";
import { OrderRecord } from "./OrderRecord";
// ※ここはあなたの Order/VO 設計に合わせて調整してね✨
export function toRecord(order: Order): OrderRecord {
return {
id: order.id.value,
status: order.status,
lines: order.lines.map((l) => ({
menuItemId: l.menuItemId.value,
quantity: l.quantity.value,
unitPriceYen: l.unitPrice.yen,
})),
createdAtIso: order.createdAt.toISOString(),
};
}
export function fromRecord(rec: OrderRecord): Order {
return Order.reconstruct({
id: OrderId.fromString(rec.id),
status: rec.status,
lines: rec.lines.map((l) => ({
menuItemId: l.menuItemId,
quantity: l.quantity,
unitPriceYen: l.unitPriceYen,
})),
createdAtIso: rec.createdAtIso,
});
}
コツ💡
Order.create()は「新規作成ルート」Order.reconstruct()は「保存データから復元ルート」 こう分けると、生成ルールと復元が混ざらなくて超キレイ✨
3-3) JsonFileOrderRepository(ファイルに1注文=1ファイル)📁
「1注文ごとに orders/{id}.json」みたいにするとシンプルで分かりやすいよ🧡
// src/infra/repository/JsonFileOrderRepository.ts
import { promises as fs } from "node:fs";
import path from "node:path";
import { OrderRepository } from "../../domain/order/OrderRepository";
import { Order } from "../../domain/order/Order";
import { OrderId } from "../../domain/order/OrderId";
import { toRecord, fromRecord } from "./orderMapper";
import { OrderRecord } from "./OrderRecord";
export class JsonFileOrderRepository implements OrderRepository {
constructor(private readonly baseDir: string) {}
private filePath(id: string): string {
return path.join(this.baseDir, "orders", `${id}.json`);
}
async save(order: Order): Promise<void> {
const file = this.filePath(order.id.value);
await fs.mkdir(path.dirname(file), { recursive: true });
// ざっくり安全策:一旦 tmp に書いて rename(途中で落ちても壊れにくい)🛡️
const tmp = `${file}.tmp`;
const rec = toRecord(order);
await fs.writeFile(tmp, JSON.stringify(rec, null, 2), "utf-8");
await fs.rename(tmp, file);
}
async findById(id: OrderId): Promise<Order | null> {
const file = this.filePath(id.value);
try {
const json = await fs.readFile(file, "utf-8");
const rec = JSON.parse(json) as OrderRecord;
return fromRecord(rec);
} catch (e: any) {
if (e?.code === "ENOENT") return null;
throw e;
}
}
}
4) “差し替えできる”を感じる瞬間:Composition Rootで選ぶ🎛️✨
Repositoryは 注入するだけでOK!
// src/main.ts
import { InMemoryOrderRepository } from "./infra/repository/InMemoryOrderRepository";
import { JsonFileOrderRepository } from "./infra/repository/JsonFileOrderRepository";
import { PlaceOrderService } from "./app/PlaceOrderService"; // 例
import path from "node:path";
const repo =
process.env.REPO === "file"
? new JsonFileOrderRepository(path.join(process.cwd(), ".data"))
: new InMemoryOrderRepository();
const placeOrder = new PlaceOrderService(repo);
// ここから先は「いつものユースケース呼び出し」🎬
await placeOrder.execute({
customerId: "C001",
items: [{ menuItemId: "LATTE", quantity: 1 }],
});
🎉 ここが最高ポイント:PlaceOrderService は repo の種類を一切知らない! (だから取り替えられる!🔁✨)
5) 仕上げ:Repositoryの“契約テスト”を書く🧪✅
ここ、めちゃ気持ちいいよ…!🥹💓 同じテストを InMemory と File の両方に当てるの!
5-1) 契約テストの型(factoryを受け取る)
// test/orderRepository.contract.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { OrderRepository } from "../src/domain/order/OrderRepository";
import { InMemoryOrderRepository } from "../src/infra/repository/InMemoryOrderRepository";
import { JsonFileOrderRepository } from "../src/infra/repository/JsonFileOrderRepository";
import { promises as fs } from "node:fs";
import path from "node:path";
// あなたの Order の作り方に合わせてね✨
import { Order } from "../src/domain/order/Order";
import { OrderId } from "../src/domain/order/OrderId";
type RepoFactory = () => Promise<{ repo: OrderRepository; dispose: () => Promise<void> }>;
function contract(name: string, factory: RepoFactory) {
describe(name, () => {
let repo: OrderRepository;
let dispose: () => Promise<void>;
beforeEach(async () => {
const created = await factory();
repo = created.repo;
dispose = created.dispose;
});
afterEach(async () => {
await dispose();
});
it("saveしたものをfindByIdで取れる💾🔎", async () => {
const order = Order.createNew({
id: OrderId.new(),
customerId: "C001",
items: [{ menuItemId: "LATTE", quantity: 1, unitPriceYen: 500 }],
});
await repo.save(order);
const loaded = await repo.findById(order.id);
expect(loaded).not.toBeNull();
expect(loaded!.id.value).toBe(order.id.value);
});
it("存在しないIDはnullになる🙂↕️", async () => {
const loaded = await repo.findById(OrderId.fromString("NOPE"));
expect(loaded).toBeNull();
});
});
}
contract("InMemoryOrderRepository", async () => ({
repo: new InMemoryOrderRepository(),
dispose: async () => {},
}));
contract("JsonFileOrderRepository", async () => {
const dir = path.join(process.cwd(), ".tmp-test-data", crypto.randomUUID());
await fs.mkdir(dir, { recursive: true });
return {
repo: new JsonFileOrderRepository(dir),
dispose: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
});
ここでの学び🌸
- 「Repositoryはこの振る舞いを守ってね」という共通ルールがテストで固定される✅
- 新しいDB版Repositoryを作っても、このテストを通せば安心😌💕
6) AIの使いどころ(この章はめっちゃ相性いい🤖✨)
ここは GitHub Copilot / OpenAI Codex みたいなAI相棒が輝くよ〜🌟
おすすめプロンプト例👇(そのまま投げてOK)
- 「OrderをJSON保存するための OrderRecord DTO を提案して」📦
- 「Order ⇄ OrderRecord の mapper の雛形を作って(ただし domain に永続化知識を入れない)」🧠
- 「Repository契約テストを Vitest で書いて。factory で repo を差し替え可能にして」🧪
- 「このRepository実装の“境界違反”をレビューして(domainにIOが入ってないかチェックして)」🚧
よくある失敗パターン集😂⚠️(先に潰そ!)
- domain の Order が
fsを import してしまう(境界崩壊)💥 - 「保存形式(JSONの形)」を domain が知ってしまう(未来のDB移行で泣く)😭
- InMemory では通るのに File で落ちる(Dateや数値の変換ミス)⏰🧨
- テストが「片方のrepoにしか当たってない」(差し替え体験が半減)🥲
章末ミニ演習🎓✨(楽しくレベルアップ🎮)
演習A:保存形式をちょい進化💾✨
createdAtIsoを追加して「注文がいつ作られたか」も復元できるようにしてみよ⏰
演習B:Repositoryを“ラップ”してみる🎁
-
CachingOrderRepositoryを作って、findByIdの結果をメモリキャッシュする(中身は別repoに委譲)🧠⚡- これ、差し替え設計ができてると超簡単にできるよ!
演習C:契約テストを増やす🧪
- 「saveを2回したら上書きになる」など、チームで欲しい“契約”を足していこう✅✨
まとめ🎀
この章のゴールはこれだけ!👇 **「保存先を変えても、ユースケースが変わらない」**を体験すること🔁🎉
Repositoryをちゃんと分けられると…
- 設計が育つ🌱
- 実装が怖くなくなる🧪
- 変更に強くなる🛡️✨
次(第81章〜)の if地獄 を綺麗にさばく準備も、ここで整ったよ〜!🥳💕
必要なら、いまの第70章までの「カフェ注文コード」に合わせて、Order.reconstruct() の形(復元専用コンストラクタ)を“あなたのモデルにピッタリ”に組み直した版も書けるよ🧩✨