第19章:CQS 読みと書きを分ける 🔀👀
この章でできるようになること ✅✨
- CQS(Command/Query Separation)の考え方がわかる🧠✨
- 「参照なのに更新してた😱」みたいな事故を避けられる🚧
getOrderDetails(Query)とplaceOrder(Command)を 別の入口 にできる🚪🚪- 命名がスッキリして「この関数、何するの?」が一瞬でわかる👀💡
1. CQSってなに? ざっくり一言で 🥤✨
CQSはこういう約束だよ👇
- Query(読む):状態を変えない。情報を返す📖🔎
- Command(書く):状態を変える。基本は情報を返さない(返しても最小)✍️🔥
つまり…
「読むなら読むだけ」「書くなら書くだけ」 にして、コードを読みやすくする作戦✨
2. なんで分けるの? ありがちな地獄 😇🔥
あるある①:getなのに更新してた 🤯
getOrderDetails()の中で「最終閲覧日時」を更新しちゃう…- すると、読むだけのつもり で呼んだ画面表示が、裏でDB更新してたりする😱
✅ CQSだと「読む関数」は更新禁止ルールにできるので、こういう事故が減るよ🧯✨
あるある②:副作用が混ざってテストがツラい 🧪💥
- Queryに更新が混ざると、テストが「読んだだけなのに状態が変わる」になって混乱する😵💫
✅ Queryは “純粋に読み取るだけ” にすると、テストが超ラクになる💖
3. CQS と CQRS の違い まぎらわしいやつ整理 🧹✨
- CQS:関数・メソッドの設計ルール(小さめの話)🧩
- CQRS:読み書きでモデルやDBまで分けることもある(大きめの話)🏗️
この章はまず CQS(小さい方) を体に入れようね💪😊
4. 今回のミニECで「読み」と「書き」を分ける 🛒📦💳
目標🎯
- Query:
getOrderDetails(orderId)→ 画面表示用DTOを返す👀📄 - Command:
placeOrder(cmd)→ 注文を作って保存する✍️💾
5. フォルダ構成 迷子防止 🧭📁
第18章の構成をそのまま活かして、application配下を分けるよ👇
src/domain/...🧠(集約・VO・不変条件)src/application/commands/...✍️(Command=書くユースケース)src/application/queries/...🔎(Query=読むサービス)src/infrastructure/...🔌(DBなど。今はインメモリでもOK)src/tests/...🧪(テスト)
6. 命名ルール これだけで見通し爆上げ 💡✨
Queryっぽい動詞(読むだけ)📖
get/find/fetch/list/search
Commandっぽい動詞(書く)✍️
place/create/add/remove/cancel/pay/change
✅ 迷ったらこう考えると楽だよ👇 「呼んだだけで世界が変わる?」→ 変わるならCommand🔥
7. 手を動かす まず Query から作る 🔎✨
7-1. Queryの返り値は DTO にしよう 📦
Queryは 集約そのものを返さない のがコツだよ(返すと、外からいじれて事故りやすい😇)
// src/application/queries/dto/OrderDetailsDto.ts
export type OrderDetailsDto = {
orderId: string;
status: "Draft" | "Paid" | "Cancelled";
items: Array<{
productId: string;
unitPrice: number; // JPY
quantity: number;
lineTotal: number; // JPY
}>;
totalAmount: number; // JPY
version: number;
};
7-2. Query専用の入口 OrderQueryService 🔎🎀
ここでは「インメモリRepositoryから読んでDTOに整形する」だけにするよ😊
// src/application/queries/GetOrderDetailsQueryService.ts
import { OrderId } from "../../domain/order/OrderId";
import { OrderRepository } from "../../domain/order/OrderRepository";
import { OrderDetailsDto } from "./dto/OrderDetailsDto";
export class GetOrderDetailsQueryService {
constructor(private readonly orders: OrderRepository) {}
async execute(orderId: string): Promise<OrderDetailsDto | null> {
if (!orderId?.trim()) return null;
const id = OrderId.from(orderId.trim());
const order = await this.orders.findById(id);
if (!order) return null;
// ✅ ここは「読むだけ」:order.addItem みたいな変更は絶対しない!
const items = order.items.map((x) => ({
productId: x.productId,
unitPrice: x.unitPrice.amount,
quantity: x.quantity.value,
lineTotal: x.lineTotal.amount
}));
return {
orderId: order.id.value,
status: order.status,
items,
totalAmount: order.total().amount,
version: order.version
};
}
}
💡ポイント
- Queryは 状態を変えるメソッドを呼ばない(
addItemなどは呼ばない)🚫 - 返すのは DTOだけ(集約は外に出さない)📦✨
8. 次に Command placeOrder を作る ✍️🔥
placeOrder は「注文作成して、明細入れて、保存して、ID返す」みたいな 書く処理の入口 にするよ🛒💨
第18章で使った Result をそのまま使ってOK(第20章でさらに強化するやつ)😊
// src/application/commands/PlaceOrderUseCase.ts
import { Order } from "../../domain/order/Order";
import { OrderId } from "../../domain/order/OrderId";
import { OrderRepository } from "../../domain/order/OrderRepository";
import { OrderItem } from "../../domain/order/Order";
import { Money } from "../../domain/shared/Money";
import { Quantity } from "../../domain/shared/Quantity";
import { Result, Ok, Err } from "../shared/Result";
export type PlaceOrderCommand = {
orderId: string;
items: Array<{
productId: string;
unitPrice: number; // JPY
quantity: number;
}>;
};
export type PlaceOrderError =
| { type: "INVALID_INPUT"; message: string }
| { type: "ALREADY_EXISTS" }
| { type: "DOMAIN_ERROR"; code: string };
export type PlaceOrderOutput = {
orderId: string;
};
export class PlaceOrderUseCase {
constructor(private readonly orders: OrderRepository) {}
async execute(cmd: PlaceOrderCommand): Promise<Result<PlaceOrderOutput, PlaceOrderError>> {
// 0) 入力の最低限チェック🧼
if (!cmd.orderId?.trim()) return Err({ type: "INVALID_INPUT", message: "orderId is required" });
if (!cmd.items || cmd.items.length === 0) return Err({ type: "INVALID_INPUT", message: "items is required" });
const id = OrderId.from(cmd.orderId.trim());
// 1) 重複チェック🧯
const exists = await this.orders.findById(id);
if (exists) return Err({ type: "ALREADY_EXISTS" });
// 2) 集約生成👑
const order = Order.create(id);
// 3) 変更(ここが“書く”)✍️🔥
try {
for (const it of cmd.items) {
const item = new OrderItem(
it.productId.trim(),
Money.jpy(it.unitPrice),
new Quantity(it.quantity)
);
order.addItem(item); // ✅ 集約の操作は Command 側でだけ呼ぶイメージ✨
}
} catch (e) {
const code = e instanceof Error ? e.message : "UNKNOWN";
return Err({ type: "DOMAIN_ERROR", code });
}
// 4) 保存💾
await this.orders.save(order);
// 5) 返すのは最小(IDだけ)📌
return Ok({ orderId: order.id.value });
}
}
💡ポイント
- Commandは 集約を変更する(
addItemなど)🔥 - 返すのは 最小限(画面表示用の詳細は Query で取る)🔎✨
9. 使い方のイメージ Command → Query の順にする 🧭✨
「注文を作る」→「画面に詳細表示」みたいな流れ、よくあるよね😊
// 例:UI / API っぽいところ(ここは教材用の擬似コード)
const placeRes = await placeOrder.execute({
orderId: "ord-100",
items: [
{ productId: "p-1", unitPrice: 500, quantity: 2 },
{ productId: "p-2", unitPrice: 1200, quantity: 1 }
]
});
if (placeRes.ok) {
const details = await getOrderDetails.execute(placeRes.value.orderId);
console.log(details);
}
✅ これが気持ちいいところ😍
- 更新したら Command
- 表示したいなら Query って分かれるので、責務が迷子にならない 🧠✨
10. テストで「読みは読んだだけ」「書きは変わる」を確認 🧪💕
10-1. Queryは状態が変わらないことをチェック🔎✅
// src/tests/CqsQuery.test.ts
import { describe, it, expect } from "vitest";
import { InMemoryOrderRepository } from "./InMemoryOrderRepository";
import { PlaceOrderUseCase } from "../application/commands/PlaceOrderUseCase";
import { GetOrderDetailsQueryService } from "../application/queries/GetOrderDetailsQueryService";
describe("CQS Query", () => {
it("query does not change state", async () => {
const repo = new InMemoryOrderRepository();
const place = new PlaceOrderUseCase(repo);
const get = new GetOrderDetailsQueryService(repo);
await place.execute({
orderId: "ord-200",
items: [{ productId: "p-9", unitPrice: 1000, quantity: 1 }]
});
const a = await get.execute("ord-200");
const b = await get.execute("ord-200");
expect(a).toEqual(b); // 2回読んでも同じ(読むだけ)✨
});
});
11. よくあるミス集 ここだけは踏まないで😇🧨
ミス①:Queryの中で集約をいじる🚫
getOrderDetails()の中でorder.addItem(...)とかやっちゃう → それ、Queryじゃない 😭
✅ 対策: Queryは DTOを作って返すだけ にする📦✨
ミス②:Commandが画面表示用の巨大DTOを返す📦💥
- Commandが「あれもこれも」返し始めると、だんだん読みにくくなる😵
✅ 対策: Commandは IDなど最小 → 必要なら Query で取り直す🔎💡
ミス③:命名がふわっとして判定不能🤔
handleOrder()とかprocess()とか → 何するか読めない😇
✅ 対策: Command/Queryの動詞に寄せる(さっきの表)🗣️✨
12. AI活用 CopilotやCodexに投げると強いプロンプト 🤖✨
※開発環境は VS Code 前提で、GitHub の Copilot か OpenAI 系のAI拡張が使える想定でOKだよ🧠💕
① これCommand?Query?判定してもらう🔀
「この関数は Command / Query どっち?理由も。 もし混ざってたら分離案を出して」
② 命名をCQSっぽく整える🗣️
「このメソッド名が Query/Command として自然になるようにリネーム案を複数出して (get/find/list vs create/add/cancel/pay)」
③ Queryが副作用を持ってないかレビュー👀
「このQueryの実装に“状態変更”が混ざってないかレビューして。 混ざってたら、Command側へ移す提案をして」
13. まとめ 🧾✨
- CQS = 読むなら読め、書くなら書け 🔀👀
- Queryは 状態を変えない + DTOを返す 📦
- Commandは 状態を変える + 返すのは最小 ✍️
- これで「副作用が読めるコード」になって、設計が一段わかりやすくなるよ😊💖
次の第20章は、いよいよ エラーを仕様にする(Result型と例外境界)🚨📦 で、いま簡易にしてた DOMAIN_ERROR などをもっとキレイに整理していくよ〜✨