第24章:総合ミニプロジェクト(統合)+実務チェックリスト 🎒🏁✅
よしっ、いよいよ最終ステージだよ〜!🔥😆 この章は「いままで作ったパーツ(VO/Entity/境界/Repo/Mapper/ACL/テスト)」を、**一本のアプリとして“つなげて動かす”**回です✨
0) 今日のゴール 🎯✨
- **ドメイン(Entity/VO)**が中心にいて、外側(DB/外部API/UI)があとから差し替えられる 🧠🔁
- 境界でガードして、変なデータを中に入れない 🚧🛡️
- 永続化はRepository+Mapperで分離して、DB都合でモデルを歪めない 💾🧼
- ACLで外部APIのクセを吸収して、ドメインを守る 🌉✨
- テストで守りを作って、仕様変更しても怖くない 🧪💪
1) ミニプロジェクト題材:ミニ注文(Order)🛒✨
「注文を作る → 送信 → 支払い → キャンセル」の超ミニ版だよ🍰 外部決済は“クセのあるレスポンス”が返ってくる想定にして、ACLも使うよ😉
使う主役たち(例)🎭
- Entity:Order
- VO:OrderId / Email / Money / Period / LineItem(VO扱いでOK)
- 境界DTO:CreateOrderRequest / AddItemRequest / PayOrderRequest
- Repository:OrderRepository(interface)
- Mapper:Order ↔ OrderRecord(永続化用の形)
- ACL:PaymentGatewayAdapter(外部決済の翻訳係)
2) “2026年いま”のツール感(バージョン目安)🧰✨
この章のサンプルは、いま一般的に組みやすい構成でいくよ〜😊
- TypeScript:5.9.3 が npm 上の最新版(2026-01 時点)✨ ([npm][1])
- Node.js:LTS に 24系があり、例として 24.13.0 (LTS) が出てるよ🔐 ([窓の杜][2])
- Vitest:v4.0.x が安定で使われてる(例:4.0.17)🧪 ([GitHub][3])
- ESLint:npm で 9.39.2 が最新として案内されてるよ🧹 ([npm][4])
- Prettier:3.8.0(2026-01-14のリリース)🪄 ([Prettier][5])
- typescript-eslint:8.53.1 が最新(parser / plugin)📌 ([npm][6])
(おまけ)TypeScript は「ネイティブ実装のプレビュー(TS 7)」みたいな動きも進んでるよ🚀 ([Microsoft Developer][7])
3) 全体の形(図解イメージ)🗺️✨

ポイントはこれ👇 ドメインは外側に依存しない(依存の向きが“外→内”)🎯
4) フォルダ構成(おすすめ)📁✨
src/
domain/
valueObjects/
Email.ts
Money.ts
OrderId.ts
Period.ts
entities/
Order.ts
errors/
DomainError.ts
types/
Result.ts
application/
dtos/
CreateOrderRequest.ts
PayOrderRequest.ts
usecases/
CreateOrder.ts
AddItem.ts
SubmitOrder.ts
PayOrder.ts
CancelOrder.ts
infrastructure/
persistence/
OrderRecord.ts
OrderMapper.ts
InMemoryOrderRepository.ts
JsonFileOrderRepository.ts
acl/
payment/
PaymentGatewayClient.ts
PaymentGatewayAdapter.ts
index.ts
tests/
domain/
application/
infrastructure/
5) 仕様(ミニ仕様書)🧾✨
注文のルール(例)🚦
- Order は
Draft / Submitted / Paid / Cancelledの状態を持つ PaidやCancelledになった注文は、アイテム追加できない- 合計金額は LineItem の合計(Money)
- 支払いは外部決済(PaymentGateway)に投げる
- 外部決済の返り値はクセがある(例:
"OK|pay_123|JPY|1200"みたいな文字列)→ ACL で翻訳する
6) 実装ミッション(順番どおりでOK)🎮✨
ミッション1:Result型(失敗を安全に返す)🎁⚠️
「例外投げる」より、初心者はまず Result でいくと迷子になりにくいよ☺️
// src/domain/types/Result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
✅チェック:if (!result.ok) return ... が自然に書ける?
AIプロンプト例🤖💬
- 「TypeScriptでResult型(Ok/Err)を初心者向けに。map/andThenも欲しい!」
ミッション2:VO(Email / Money / OrderId)💎🛡️
Email(生成時に検証)📧✅
// src/domain/valueObjects/Email.ts
import { Result, ok, err } from "../types/Result.js";
import { DomainError } from "../errors/DomainError.js";
export class Email {
private constructor(private readonly value: string) {}
static create(raw: string): Result<Email, DomainError> {
const v = raw.trim();
if (v.length === 0) return err(DomainError.invalid("email", "空はだめだよ🥺"));
// 超ざっくり(学習用)。本気運用は専用ライブラリや要件に合わせる
if (!v.includes("@")) return err(DomainError.invalid("email", "@ がないよ🥺"));
return ok(new Email(v));
}
toString(): string {
return this.value;
}
}
Money(計算はVOで守る)💰➕
// src/domain/valueObjects/Money.ts
import { DomainError } from "../errors/DomainError.js";
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: "JPY"
) {}
static jpy(amount: number): Money {
if (!Number.isInteger(amount)) throw new Error("Moneyは整数にしてね(学習用)");
if (amount < 0) throw new Error("マイナス禁止だよ🥺");
return new Money(amount, "JPY");
}
add(other: Money): Money {
this.assertSameCurrency(other);
return Money.jpy(this.amount + other.amount);
}
multiply(qty: number): Money {
if (!Number.isInteger(qty) || qty <= 0) throw new Error("qtyがおかしいよ🥺");
return Money.jpy(this.amount * qty);
}
equals(other: Money): boolean {
return this.currency === other.currency && this.amount === other.amount;
}
getAmount(): number {
return this.amount;
}
getCurrency(): "JPY" {
return this.currency;
}
private assertSameCurrency(other: Money) {
if (this.currency !== other.currency) {
throw new Error("通貨ちがうよ🥺");
}
}
}
OrderId(ただのstringを卒業)🪪✨
// src/domain/valueObjects/OrderId.ts
export class OrderId {
private constructor(private readonly value: string) {}
static create(raw: string): OrderId {
if (raw.trim().length === 0) throw new Error("OrderId空はだめ🥺");
return new OrderId(raw);
}
toString(): string {
return this.value;
}
}
✅チェック:VOは new で勝手に作れない?(create経由になってる?)
ミッション3:Entity(Order)+状態遷移 🚦🔄
// src/domain/entities/Order.ts
import { OrderId } from "../valueObjects/OrderId.js";
import { Email } from "../valueObjects/Email.js";
import { Money } from "../valueObjects/Money.js";
import { DomainError } from "../errors/DomainError.js";
import { Result, ok, err } from "../types/Result.js";
export type OrderStatus = "Draft" | "Submitted" | "Paid" | "Cancelled";
export type LineItem = {
sku: string;
unitPrice: Money;
qty: number;
};
export class Order {
private status: OrderStatus = "Draft";
private items: LineItem[] = [];
private constructor(
private readonly id: OrderId,
private readonly customerEmail: Email
) {}
static create(id: OrderId, email: Email): Order {
return new Order(id, email);
}
addItem(item: LineItem): Result<void, DomainError> {
if (this.status !== "Draft") {
return err(DomainError.rule("order.status", "Draftのときだけ追加できるよ🥺"));
}
if (item.qty <= 0) return err(DomainError.invalid("qty", "1以上ね🥺"));
if (item.sku.trim().length === 0) return err(DomainError.invalid("sku", "空はだめ🥺"));
this.items.push(item);
return ok(undefined);
}
submit(): Result<void, DomainError> {
if (this.status !== "Draft") return err(DomainError.rule("order.status", "Draftだけ送信OK🥺"));
if (this.items.length === 0) return err(DomainError.rule("order.items", "商品ゼロは送信できないよ🥺"));
this.status = "Submitted";
return ok(undefined);
}
markPaid(): Result<void, DomainError> {
if (this.status !== "Submitted") return err(DomainError.rule("order.status", "Submittedだけ支払いOK🥺"));
this.status = "Paid";
return ok(undefined);
}
cancel(): Result<void, DomainError> {
if (this.status === "Paid") return err(DomainError.rule("order.status", "Paidは取消できないよ🥺"));
this.status = "Cancelled";
return ok(undefined);
}
total(): Money {
return this.items.reduce((sum, it) => sum.add(it.unitPrice.multiply(it.qty)), Money.jpy(0));
}
// Mapper用に最低限のgetter(増やしすぎ注意!)
getId(): OrderId { return this.id; }
getEmail(): Email { return this.customerEmail; }
getStatus(): OrderStatus { return this.status; }
getItems(): LineItem[] { return [...this.items]; }
}
DomainError(エラーモデル)⚠️📌
// src/domain/errors/DomainError.ts
export class DomainError {
private constructor(
public readonly kind: "Invalid" | "Rule",
public readonly field: string,
public readonly message: string
) {}
static invalid(field: string, message: string): DomainError {
return new DomainError("Invalid", field, message);
}
static rule(field: string, message: string): DomainError {
return new DomainError("Rule", field, message);
}
}
✅チェック:状態遷移が if で散らばってない?(Orderの中に集まってる?)
ミッション4:Repository(interface)🧩🧱
// src/application/usecases/ports/OrderRepository.ts
import { Order } from "../../../domain/entities/Order.js";
import { OrderId } from "../../../domain/valueObjects/OrderId.js";
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
✅チェック:Usecaseは「DBの話」をしてない?
ミッション5:Mapper(Order ↔ Record)🔁💾
「永続化の形」と「ドメインの形」を切り分けるよ✨
// src/infrastructure/persistence/OrderRecord.ts
export type OrderRecord = {
id: string;
email: string;
status: "Draft" | "Submitted" | "Paid" | "Cancelled";
items: { sku: string; unitPrice: number; qty: number }[];
};
// src/infrastructure/persistence/OrderMapper.ts
import { Order, LineItem } from "../../domain/entities/Order.js";
import { OrderId } from "../../domain/valueObjects/OrderId.js";
import { Email } from "../../domain/valueObjects/Email.js";
import { Money } from "../../domain/valueObjects/Money.js";
import { OrderRecord } from "./OrderRecord.js";
export class OrderMapper {
static toRecord(order: Order): OrderRecord {
return {
id: order.getId().toString(),
email: order.getEmail().toString(),
status: order.getStatus(),
items: order.getItems().map(it => ({
sku: it.sku,
unitPrice: it.unitPrice.getAmount(),
qty: it.qty,
})),
};
}
static fromRecord(r: OrderRecord): Order {
const order = Order.create(OrderId.create(r.id), Email.create(r.email).ok ? Email.create(r.email).value : (()=>{throw new Error("invalid email in DB")})());
// status/items復元(学習用に簡略)
r.items.forEach(it => {
order.addItem({ sku: it.sku, unitPrice: Money.jpy(it.unitPrice), qty: it.qty });
});
// status復元(本気ならOrder側にrestore用factoryを作るのがキレイ)
(order as any).status = r.status;
return order;
}
}
💡ここ、学習ポイントだよ!
fromRecord() で Email.create の失敗が起きたら「DBに壊れたデータがいる」ってこと。
実務では、restore専用の安全な復元口(例:Order.restore(...))を作るのがおすすめ😊
ミッション6:Repository実装(InMemory → JsonFile)🧠➡️💾
InMemory(まず動かす)🧺✨
// src/infrastructure/persistence/InMemoryOrderRepository.ts
import { OrderRepository } from "../../application/usecases/ports/OrderRepository.js";
import { Order } from "../../domain/entities/Order.js";
import { OrderId } from "../../domain/valueObjects/OrderId.js";
export class InMemoryOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id.toString()) ?? null;
}
async save(order: Order): Promise<void> {
this.store.set(order.getId().toString(), order);
}
}
JsonFile(永続化っぽくする)📄💾
(fsで orders.json に保存するだけでOK。DBはまだ要らないよ😉)
ミッション7:ACL(外部決済の翻訳係)🌉💳✨
外部の返り値が **「変な文字列」**だとしても、 ドメイン側には きれいな型だけ渡したいよね🙂
// src/infrastructure/acl/payment/PaymentGatewayClient.ts
export type RawPaymentResponse = string; // 例: "OK|pay_123|JPY|1200"
export interface PaymentGatewayClient {
charge(orderId: string, amount: number, currency: string): Promise<RawPaymentResponse>;
}
// src/infrastructure/acl/payment/PaymentGatewayAdapter.ts
import { Money } from "../../../domain/valueObjects/Money.js";
export type PaymentResult =
| { ok: true; paymentId: string }
| { ok: false; reason: string };
export class PaymentGatewayAdapter {
static translate(raw: string, expected: Money): PaymentResult {
// "OK|pay_123|JPY|1200"
const [status, payId, cur, amt] = raw.split("|");
if (status !== "OK") return { ok: false, reason: "決済NG😢" };
if (cur !== expected.getCurrency()) return { ok: false, reason: "通貨が変だよ😢" };
if (Number(amt) !== expected.getAmount()) return { ok: false, reason: "金額が変だよ😢" };
return { ok: true, paymentId: payId };
}
}
✅チェック:Usecase/Domainが raw文字列の仕様を知らない?(Adapterだけが知ってる?)
ミッション8:Usecase(薄く、つなぐ)🧠🪄
例:PayOrder(支払い)
- Orderを取り出す
- 状態確認は Order に任せる
- 外部決済を呼ぶ(Client)
- 返り値を Adapter で翻訳
- OKなら
order.markPaid() - 保存
// src/application/usecases/PayOrder.ts
import { OrderRepository } from "./ports/OrderRepository.js";
import { OrderId } from "../../domain/valueObjects/OrderId.js";
import { Result, ok, err } from "../../domain/types/Result.js";
import { DomainError } from "../../domain/errors/DomainError.js";
import { PaymentGatewayClient } from "../../infrastructure/acl/payment/PaymentGatewayClient.js";
import { PaymentGatewayAdapter } from "../../infrastructure/acl/payment/PaymentGatewayAdapter.js";
export class PayOrder {
constructor(
private readonly repo: OrderRepository,
private readonly payment: PaymentGatewayClient
) {}
async execute(id: OrderId): Promise<Result<void, DomainError>> {
const order = await this.repo.findById(id);
if (!order) return err(DomainError.invalid("orderId", "注文が見つからないよ🥺"));
const total = order.total();
const raw = await this.payment.charge(id.toString(), total.getAmount(), total.getCurrency());
const translated = PaymentGatewayAdapter.translate(raw, total);
if (!translated.ok) {
return err(DomainError.rule("payment", translated.reason));
}
const r = order.markPaid();
if (!r.ok) return r;
await this.repo.save(order);
return ok(undefined);
}
}
```mermaid
sequenceDiagram
participant UseCase
participant Repo
participant Order
participant ACL
UseCase->>Repo: findById(id)
Repo-->>UseCase: Order (Rehydrated)
UseCase->>Order: total()
Order-->>UseCase: Money (JPY)
UseCase->>ACL: charge(money) 💳
ACL-->>UseCase: Result (Translated)
alt Payment OK
UseCase->>Order: markPaid() ✅
UseCase->>Repo: save(order) 💾
UseCase-->>Client: OK
else Payment Failed
UseCase-->>Client: Error
end
---
## 7) テスト戦略(最低限これだけで強い)🧪🍰
Vitest を前提に、テストは「薄皮ミルフィーユ」感覚でOK😊
* **VOテスト**:Email / Money の境界値(最重要)
* **Entityテスト**:状態遷移(Draft→Submitted→Paid…)
* **Mapperテスト**:toRecord/fromRecord が往復で壊れない
* **Usecaseテスト**:RepoとPaymentをスタブして PayOrder が正しく動く
(Vitest の4系が継続的に出てるよ🧪) ([GitHub][3])
---
## 8) 仕様変更を1個入れてみよう(ここが最終奥義)🪄🔁✨
### 仕様変更案(例)🎁
「クーポンで合計から100円引き(ただし合計が500円以上のときだけ)」
**影響範囲の理想**👇
* ルールは **Order(ドメイン)** に追加
* 外部決済は **合計が変わるだけ**(ACLは基本そのまま)
* 永続化は「クーポンコードを保存したい」なら Record/Mapper を少しだけ変更
* Usecaseは「DTOでクーポン受け取り→Orderへ渡す」くらい
💡つまり、**ドメイン中心**にしておくと「変更箇所が狭い」=勝ち🏆✨
---
## 9) 実務チェックリスト(これ持ってたら強い)📋✅✨
### A. Entity/VOの切り分け 👑💎
* [ ] 「追跡が必要」なものが Entity になってる?🪪
* [ ] 「値そのもの」なのに Entity にしてない?(不要なID生えてない?)🌱
* [ ] VOは **生成時に検証**してる?(無効値を作れない?)🚫
* [ ] Moneyみたいな計算は、散らばらずVOに寄ってる?💰
### B. 不変条件と更新口 🚪🛡️
* [ ] Entityの更新はメソッド経由で、勝手に書き換えできない?🔒
* [ ] 状態遷移の禁止がテストされてる?🚦🧪
* [ ] 例外とResultの使い分けが統一されてる?(ブレると地獄😇)
### C. 境界(DTO→ドメイン)🚧
* [ ] 外から来たデータは **境界で検証**してる?
* [ ] ドメインが “stringだらけ” になってない?(VOにしてる?)
### D. 永続化(Repository/Mapper)💾🧼
* [ ] Usecase/DomainがDBの都合(カラム名等)を知らない?
* [ ] Mapperが1か所にまとまってる?
* [ ] Record ↔ Domain の往復テストある?
### E. ACL(外部API)🌉
* [ ] 外部の変な命名/単位/欠損を **ACLで吸収**してる?
* [ ] ドメインが外部レスポンスの仕様を直接触ってない?
### F. テスト 🧪
* [ ] VOの境界値テスト(OK/NG)が揃ってる?
* [ ] 状態遷移テスト(許可/禁止)が揃ってる?
* [ ] 仕様変更1つ入れても、直す場所が少ない?(設計勝ち!)
### G. 依存関係(DIP)🧲
* [ ] ドメインが infrastructure を import してない?
* [ ] interface は内側、実装は外側にいる?
### H. セキュリティ(Windowsは特に意識)🔐🪟
* [ ] 依存パッケージ更新は、差分と出所を見てる?👀
* [ ] 過去に `eslint-config-prettier` 周辺でサプライチェーン攻撃があった(Windows影響ありの話題)ので、CIでの更新は慎重にね🧯 ([CSO Online][8])
---
## 10) 小テスト(サクッと確認)📝✨
1. OrderがPaidのとき addItem を禁止するのはどこ?
A. Controller / B. Usecase / C. Order(Entity)
2. 外部決済の `OK|pay_123|JPY|1200` を解釈するのはどこ?
A. Domain / B. ACL / C. Repository
3. `email: string` をそのままOrderに入れるのが危ない理由は?
(ヒント:無効値が侵入する)
4. Mapperが無いと何がつらい?(ヒント:DB都合が侵入)
✅答え(超短く)
1:C 2:B 3:無効値が入る/検証が散る 4:ドメインが汚れる/変更に弱い
---
## 11) AIプロンプト集(この章用)🤖🎀
* 「このOrderの状態遷移表を作って、禁止遷移も列挙して」🚦
* 「VO(Email/Money)の境界値テストをOK/NGで20個ずつ出して」🧪
* 「Repository interface が太すぎないかレビューして、分割案も」🧩
* 「外部APIレスポンスの危険点(欠損/単位/文字列)を洗い出して、ACL案」🌉
* 「仕様変更(クーポン)を入れたときの変更箇所をレイヤ別に教えて」🔁
---
## 12) 次に作るならどっち?😊🎀
迷ったら **「授業用本文(説明+図解イメージ+演習+小テスト+AIプロンプト集)」を先**がラクだよ〜📚✨
理由:本文ができると、ミニプロジェクト仕様書は“抜き出して整形するだけ”になりやすいから😉🪄
必要なら、この第24章の内容をベースに **「完成版のJsonFileOrderRepository」**と、**Vitestのテスト一式(そのままコピペで動く版)**まで一気に仕上げるよ🔥🧪💖
[1]: https://www.npmjs.com/package/typescript?utm_source=chatgpt.com "typescript"
[2]: https://forest.watch.impress.co.jp/docs/news/2077577.html?utm_source=chatgpt.com "「Node.js」のセキュリティリリースが年をまたいでようやく公開"
[3]: https://github.com/vitest-dev/vitest/releases?utm_source=chatgpt.com "Releases · vitest-dev/vitest"
[4]: https://www.npmjs.com/package/eslint?utm_source=chatgpt.com "eslint"
[5]: https://prettier.io/blog/2026/01/14/3.8.0?utm_source=chatgpt.com "Prettier 3.8: Support for Angular v21.1"
[6]: https://www.npmjs.com/package/%40typescript-eslint/parser?utm_source=chatgpt.com "typescript-eslint/parser"
[7]: https://developer.microsoft.com/blog/typescript-7-native-preview-in-visual-studio-2026?utm_source=chatgpt.com "TypeScript 7 native preview in Visual Studio 2026"
[8]: https://www.csoonline.com/article/4026380/prettier-eslint-npm-packages-hijacked-in-a-sophisticated-supply-chain-attack.html?utm_source=chatgpt.com "Prettier-ESLint npm packages hijacked in a sophisticated ..."