第21章:ISPの分割テク(役割ごとに薄く)🧻✨
〜Read/Write分離・Query/Command分離で「依存を軽く」するよ〜😊🌸

1. この章でできるようになること 🎯✨
- 「でっかい interface(全部入り)」を見て、分割の軸を思いつけるようになる🧠💡
- Read/Write 分離(読むだけ / 書くだけ)を、Repositoryで自然にできる📚✍️
- Query/Command 分離(取得 / 更新)を、UseCaseの設計に落とし込める🔁✨
- 「依存する側(呼ぶ側)」が、必要最小限だけ依存するようにできる🪶😊
2. まずは超ざっくり:ISPって何がうれしいの?🧸✨
ISP(Interface Segregation Principle)は、ひとことで言うと…
使わない機能まで依存させない(持たせない)✂️💖
ありがちな地獄🔥
Repositoryなのに、読む・書く・検索・削除・集計・CSV出力・キャッシュ…ぜんぶ入ってる😵💫- 「読むだけ」の機能なのに、
save()とかdelete()にも依存してる → テストでモックしなきゃいけないメソッドが増えてしんどい🥲🧪 - 実装クラスも「全部実装しなきゃ」でつらい😭
ISPのゴール🌈
- 依存が軽い → 変更に強い💪✨
- テストが楽 → 最小のモックでOK✅
- 実装が楽 → 必要な契約だけ守ればいい🎯
3. 分割テク①:Read/Write 分離(読む用・書く用)📚✍️✨
これは超強い定番!Repositoryで特に効くよ😊
ダメ例:全部入り Repository(肥大化)📛
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findRecent(limit: number): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}
この OrderRepository に、読むだけの画面も、書くだけの処理も、ぜんぶ依存しちゃう😵💫
✅ 分割例:読み取り用 / 更新用に分ける 🧻✨
export interface OrderReader {
findById(id: string): Promise<Order | null>;
findRecent(limit: number): Promise<Order[]>;
exists(id: string): Promise<boolean>;
}
export interface OrderWriter {
save(order: Order): Promise<void>;
delete(id: string): Promise<void>;
}
これで、たとえば「注文履歴を見る」UseCaseは OrderReader だけでOK📚✨
「注文確定」UseCaseは OrderWriter を使う(必要なら Reader も使う)✍️✨
⭐ “依存する側” を薄くする(ここが主役!)👑✨
注文履歴(読むだけ)UseCase
export class GetRecentOrdersUseCase {
constructor(private readonly orderReader: OrderReader) {}
async execute(limit: number) {
return this.orderReader.findRecent(limit);
}
}
読むだけなのに save() をモック…みたいなのが消える!🎉
テストもシンプル🧪✨
4. 分割テク②:Query / Command 分離(取得と更新を混ぜない)🔁✨
ここは「設計の考え方」を一段レベルアップさせるやつ〜😊🌟
CQS(Command–Query Separation)って考え方🧠
- Query:状態を変えずに「情報を返す」
- Command:状態を変える(基本「返さない」か、返しても “結果” だけ) この考え方が有名だよ📘✨ (ウィキペディア)
さらに大きな設計として CQRS(読み取りと更新をシステム的に分ける)もあるよ〜🧩✨ (Kurrent - event-native data platform) (この章では “小さめに” 使う感じでOK😊)
✅ まずはメソッドを「混ぜない」練習🧸✨
やりがち(Queryなのに更新しちゃう)📛
// ❌ 履歴を取るだけのはずが、ついでに閲覧回数を更新してる…!
async getOrderAndIncrementViewCount(id: string): Promise<Order | null> {
const order = await this.repo.findById(id);
if (order) {
order.viewCount++;
await this.repo.save(order);
}
return order;
}
これ、呼ぶ側からすると「取得」のつもりなのに副作用がある😵💫 バグの温床〜〜〜😭💥
✅ 分ける(Query / Command)✨
// Query: 取得だけ
async getOrder(id: string): Promise<Order | null> {
return this.orderReader.findById(id);
}
// Command: 更新だけ
async incrementOrderViewCount(id: string): Promise<void> {
const order = await this.orderReader.findById(id);
if (!order) return;
order.viewCount++;
await this.orderWriter.save(order);
}
呼ぶ側も意図が明確で、テストも分けやすい🧪✨ 「取得しただけなのにDBが更新される」みたいな事故が減るよ🚑💖
5. 分割テク③:用途(ユースケース)で分ける 🎯✨
「読む/書く」以外でも、利用者(クライアント)ごとに分けるのが強いよ😊
例:注文の “集計” だけ欲しい人📊
履歴画面が欲しいのは「注文一覧」だけじゃなくて、「売上集計」もあるかも!
export interface OrderStatsReader {
sumTotalPriceByDay(date: string): Promise<number>;
countOrdersByDay(date: string): Promise<number>;
}
OrderReader と OrderStatsReader を分けると、
一覧画面に集計メソッドが混ざって太るのを防げるよ🧻✨
6. TypeScriptならではの “薄くする道具” 🧰✨
6-1. Pick / Omit で「既存interfaceから切り出す」✂️
「いきなり全部書き直すのめんどい🥲」って時の味方!
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findRecent(limit: number): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}
export type OrderReader = Pick<OrderRepository, "findById" | "findRecent" | "exists">;
export type OrderWriter = Pick<OrderRepository, "save" | "delete">;
移行がめちゃスムーズになるよ🚀✨
6-2. 実装クラスは「複数interfaceを実装してOK」🤝
export class InMemoryOrderRepository implements OrderReader, OrderWriter {
private readonly map = new Map<string, Order>();
async findById(id: string) {
return this.map.get(id) ?? null;
}
async findRecent(limit: number) {
return Array.from(this.map.values()).slice(0, limit);
}
async exists(id: string) {
return this.map.has(id);
}
async save(order: Order) {
this.map.set(order.id, order);
}
async delete(id: string) {
this.map.delete(id);
}
}
UseCase側は薄い interface だけ見てるから、差し替えも楽ちん🎭✨
6-3. satisfies で「余計に型を潰さずに」契約チェック✅✨
satisfies は「型の条件を満たしてるか」確認しつつ、値の具体性も保ちやすい便利機能だよ🧠💎 (betterstack.com)
const orderReaderMock = {
findById: async (_id: string) => null,
findRecent: async (_limit: number) => [],
exists: async (_id: string) => false,
} satisfies OrderReader;
モック作る時にかなり気持ちいい😊✨
7. ハンズオン:Campus Caféで Repository を分割しよう☕️📦✨
Step 0:状況(Before)😵💫
OrderRepositoryが肥大化GetRecentOrdersUseCaseがsave/deleteまで依存してる
Step 1:Reader/Writer を作る✂️
OrderReaderとOrderWriterを定義- まずは
Pickで切り出しでもOK✨
Step 2:UseCaseのコンストラクタを差し替える🔁
- 履歴系UseCase →
OrderReaderだけ受け取る - 注文確定UseCase →
OrderWriter(必要ならReaderも)
Step 3:テストが軽くなったのを体験する🧪🎉
- 履歴UseCaseのテスト:読む系だけモック
- 注文確定UseCase:書く系だけモック or インメモリ実装でOK
8. よくある失敗あるある⚠️(ここ大事!)
8-1. 分けすぎて迷子🌀
- interface が細かすぎて、どれ使うの?状態に → **“クライアント視点”**で割るとちょうどよくなるよ😊
8-2. 「とりあえず IRepository」みたいに抽象が雑😇
- 抽象名が広すぎると、すぐ肥大化する
→
OrderReader / OrderWriterみたいに、用途が名前に出るのが◎✨
8-3. Queryなのに副作用(更新)が混ざる💥
- 取得しただけで状態が変わるのは、追跡が地獄 → CQS意識で分けると事故減るよ (ウィキペディア)
9. AI活用(Copilot / Codex系)で爆速にするコツ🤖✨
使えるお願いテンプレ📝
- 「このinterface、クライアントごとに分割案を出して。Read/WriteとQuery/Commandも候補に入れて」
- 「分割後の命名案と、UseCase側の依存の置き換えも提案して」
- 「分割しすぎを避けるための、最小構成案もください」
最後は人間がチェック👀✨
- 「誰が使うの?」(クライアントは誰?)
- 「その人に不要なメソッド混ざってない?」 この2つだけでも品質上がるよ😊🌸
10. まとめ(今日の持ち帰り)🎁✨
-
ISPのコツは “必要な人に必要な分だけ” 🧻💖
-
強い分割軸はこれ👇
- Read / Write 分離📚✍️
- Query / Command 分離🔁✨(CQSの考え方 (ウィキペディア))
- 用途(ユースケース)分離🎯
-
TypeScriptでは
Pick/Omitやsatisfiesがめっちゃ便利🧰💎 (betterstack.com)
次章の予告👀✨(第22章)
次はこの分割テクを、**通知まわり(Email/アプリ通知/ログ)**に当てて、 「必要な通知だけ依存する」設計にしていくよ〜🔔📦💖
準備はOK?😊🎉