第21章:エラーモデリング超入門(Result型っぽい考え)🚦😊
この章でできるようになること🎯✨
- 「例外まみれ😵💫」にならないように、エラーを整理して設計できるようになる🧺🧠
- ドメインエラー(ルール違反)と インフラエラー(DBや通信など)を分けられる🔀🧯
- Command処理(Load → Decide → Append)の **Decideを“Result返し”**にして、挙動を読みやすくできる📮✅
- テスト(Given-When-Then)で 成功・失敗がキレイに書けるようになる🧪🌸
1. なんで「エラーモデリング」が必要なの?🤔💭
イベントソーシングって「履歴(イベント)を積む」から、ルール違反や失敗が起きたときに…
- どこで失敗した?(ドメイン?保存?読み込み?)🌀
- ユーザーに何を見せる?🖥️
- どこまでリトライできる?🔁
がごちゃつきやすいの🥺💦
そこでこの章では、エラーをまず 2種類に分けるところから始めるよ〜😊🌷
2. エラーは2種類に分けよう✌️📦
A) ドメインエラー(ルール違反)🛡️🚫
「やっちゃダメ」系。たとえばカートなら🛒✨
- 在庫0なのに追加しようとした📦❌
- 数量が0以下になった🔢❌
- すでにCheckout済みなのに変更しようとした🧾🚫
✅ポイント:**“想定内”**の失敗(正しく止めたい)
B) インフラエラー(外の世界の失敗)🧯⚡
「保存できない」「読めない」系。
- EventStoreが落ちてる💥
- ネットワークが切れた📡❌
- JSONが壊れてた🧨
✅ポイント:**“想定外になりがち”**だけど、現実には起きる(丁寧に扱う)

3. 今日の主役:Result型っぽいやつを作ろう🎁✨
例外(throw)を乱発すると、処理の流れが見えにくくなることがあるのね🥺 そこで「成功 or 失敗」を 値として返すスタイルを作るよ😊
Resultの形(いちばんシンプル)🧸💡
// src/shared/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 });
これで、戻り値を見るだけで 「成功ルート🙂 / 失敗ルート🙅♀️」が一発でわかるようになるよ〜✨
4. Decideは「例外を投げない」が基本(おすすめ)📮🧠
イベントソーシングの中核は Decide(新しいイベントを決める)だよね✅ ここは ドメインのルールの場所だから、
- ルール違反 → ドメインエラーをResultで返す🚫
- OK → 新イベントを返す🎉
にすると、めちゃ読みやすいの🌸
5. 例:カートのドメインエラーを定義しよう🛒🧷
「種類が増えても破綻しない」ように、ユニオン型で定義するのが超おすすめ😊✨
// src/domain/errors.ts
export type CartDomainError =
| { type: "CartAlreadyCheckedOut" }
| { type: "InvalidQuantity"; quantity: number }
| { type: "OutOfStock"; sku: string };
export const formatCartDomainError = (e: CartDomainError): string => {
switch (e.type) {
case "CartAlreadyCheckedOut":
return "すでに購入手続き済みのカートは変更できません🥺";
case "InvalidQuantity":
return `数量が変だよ🥺 quantity=${e.quantity}`;
case "OutOfStock":
return `在庫がないよ🥺 sku=${e.sku}`;
default: {
// 型の漏れを潰すやつ(exhaustive check)🔒
const _exhaustive: never = e;
return _exhaustive;
}
}
};
defaultのneverは「ケース漏れ」をコンパイルで気づけるテクだよ🔒✨ (最近のTypeScriptは型まわりがどんどん強くなってて、こういう“漏れ防止”が効きやすいよ〜🧠)(TypeScript)
6. Decide(ルールチェック→イベント生成)を書いてみよう🎬✨
ここでは例として、AddItem コマンドを処理する想定にするね😊
// src/domain/cart.ts
import { Result, ok, err } from "../shared/result";
import { CartDomainError } from "./errors";
export type CartState = {
checkedOut: boolean;
items: Record<string, number>; // sku -> qty
};
export type CartEvent =
| { type: "ItemAdded"; sku: string; quantity: number }
| { type: "CheckedOut" };
export const initialCartState = (): CartState => ({
checkedOut: false,
items: {},
});
export const applyCartEvent = (s: CartState, e: CartEvent): CartState => {
switch (e.type) {
case "ItemAdded": {
const current = s.items[e.sku] ?? 0;
return { ...s, items: { ...s.items, [e.sku]: current + e.quantity } };
}
case "CheckedOut":
return { ...s, checkedOut: true };
default: {
const _exhaustive: never = e;
return _exhaustive;
}
}
};
// Decide:状態 + コマンド → 新イベント or ドメインエラー
export const decideAddItem = (
state: CartState,
command: { sku: string; quantity: number },
deps: { isInStock: (sku: string) => boolean }
): Result<CartEvent[], CartDomainError> => {
if (state.checkedOut) return err({ type: "CartAlreadyCheckedOut" });
if (command.quantity <= 0) {
return err({ type: "InvalidQuantity", quantity: command.quantity });
}
if (!deps.isInStock(command.sku)) {
return err({ type: "OutOfStock", sku: command.sku });
}
return ok([{ type: "ItemAdded", sku: command.sku, quantity: command.quantity }]);
};
✅ここが気持ちいいポイント😍
- Decideが throwしない → 失敗が「普通の分岐」になる🌿
- 「ドメインエラーだけ返す」→ 責任範囲がハッキリ🧠✨
7. インフラエラーも“型”で分けよう🧯📦
EventStoreの読み書きは失敗する可能性があるから、ここもResultにしちゃうと安全😊
// src/infra/eventStoreErrors.ts
export type EventStoreError =
| { type: "ConnectionFailed" }
| { type: "SerializationFailed"; reason: string }
| { type: "ConcurrencyConflict"; expectedVersion: number; actualVersion: number };
8. アプリ層で「ドメイン vs インフラ」を合流させる🌉✨
画面やAPIから見ると「失敗」だけど、意味が違うよね🥺 だから「AppError」を作って、最後にまとめるのがやりやすいよ😊
// src/app/appError.ts
import { CartDomainError } from "../domain/errors";
import { EventStoreError } from "../infra/eventStoreErrors";
export type AppError =
| { kind: "domain"; error: CartDomainError }
| { kind: "infra"; error: EventStoreError };
9. Command処理(Load → Decide → Append)に組み込む🧩✅
「第16〜17章の型」に、Resultをスッと差し込む感じだよ😊
// src/app/handleAddItem.ts
import { Result, ok, err } from "../shared/result";
import { CartEvent, applyCartEvent, initialCartState, decideAddItem } from "../domain/cart";
import { AppError } from "./appError";
import { EventStoreError } from "../infra/eventStoreErrors";
export type EventStore = {
readStream: (streamId: string) => Result<{ events: CartEvent[]; version: number }, EventStoreError>;
appendToStream: (
streamId: string,
expectedVersion: number,
newEvents: CartEvent[]
) => Result<void, EventStoreError>;
};
export const handleAddItem = (
store: EventStore,
streamId: string,
command: { sku: string; quantity: number },
deps: { isInStock: (sku: string) => boolean }
): Result<void, AppError> => {
// Load
const loaded = store.readStream(streamId);
if (!loaded.ok) return err({ kind: "infra", error: loaded.error });
const { events, version } = loaded.value;
// Rehydrate(復元)
const state = events.reduce(applyCartEvent, initialCartState());
// Decide
const decided = decideAddItem(state, command, deps);
if (!decided.ok) return err({ kind: "domain", error: decided.error });
// Append
const saved = store.appendToStream(streamId, version, decided.value);
if (!saved.ok) return err({ kind: "infra", error: saved.error });
return ok(undefined);
};
ここまで来ると、 ✅「どの種類の失敗か」 ✅「どこで失敗したか」 がコードだけで読めるようになるよ〜📖✨
10. ミニ演習(超たいせつ)📝🌸
演習A:エラーを3つ作って分類してみよう🗂️✨
題材はなんでもOK🙆♀️
- ドメインエラーを3つ(ルール違反)🛡️
- インフラエラーを3つ(外の失敗)🧯
👉コツ:**「ユーザーが直せる?」**で分けると迷いにくいよ😊
演習B:Decideを“Result返し”に直してみよう🔧✨
すでに例外を投げてるDecideがあったら、
- throw →
return err(...) - success →
return ok([...])に変えてみてね🌸
11. テスト(Given-When-Then)で「成功/失敗」を気持ちよく🧪💖
ここでは decideAddItem をテストするよ😊
(最近のVitestは4系中心でドキュメントも更新されてるよ📚✨)(Vitest)
// src/domain/cart.test.ts
import { describe, it, expect } from "vitest";
import { initialCartState, applyCartEvent, decideAddItem } from "./cart";
describe("decideAddItem", () => {
it("Given: empty cart, When: add item, Then: ItemAdded event", () => {
const state = initialCartState();
const r = decideAddItem(
state,
{ sku: "A-001", quantity: 2 },
{ isInStock: () => true }
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value).toEqual([{ type: "ItemAdded", sku: "A-001", quantity: 2 }]);
}
});
it("Given: checked out cart, When: add item, Then: domain error", () => {
const state = applyCartEvent(initialCartState(), { type: "CheckedOut" });
const r = decideAddItem(
state,
{ sku: "A-001", quantity: 1 },
{ isInStock: () => true }
);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.type).toBe("CartAlreadyCheckedOut");
}
});
});
12. 例外(throw)はいつ使うの?🔥🚫➡️✅
Resultで全部やればOK!…と言いたいけど、実際は「例外が向く場面」もあるよ😊
✅ throwが向く(=バグっぽい・想定外)
- “絶対に起きないはず”の状態(Apply漏れで不整合とか)🧨
- プログラマーのミス(nullのはずがないのにnull)😵
✅ Resultが向く(=想定内)
- ルール違反(ドメインエラー)🛡️
- 外部I/O失敗(インフラエラー)🧯
この線引きをすると、運用がラクになるよ〜🌸✨
13. AI活用(コピペで使えるプロンプト)🤖💬✨
プロンプト①:ドメインエラー案を作る🛡️
題材:ショッピングカート
やりたいこと:イベントソーシングのDecideで返すドメインエラーを設計したい
条件:
- 例外は投げず Result で返す
- ユーザーが直せる失敗に限定
出力:
- エラー名(英語のtype名)
- 意味(日本語)
- 起きる条件(箇条書き)
を5つ
プロンプト②:Result型のユーティリティを足す🧰
TypeScriptでResult型(ok/err)を使っています。
map / mapError / andThen(flatMap)を追加してください。
条件:
- ジェネリクスを丁寧に
- 例外は投げない
- 使用例も1つ
※ AI拡張は便利だけど、**「返ってきた型が読みやすいか」**だけは必ず人間がチェックね👀💖 (TypeScriptはnpm上で5.9系が継続していて、型の表現力もどんどん増えてるから、ここは丁寧にやると後が楽だよ〜🧠✨)(NPM)
14. まとめ🎀✨
- エラーはまず ドメイン / インフラ に分ける🗂️
- Decideは Resultで返すと読みやすい📮✅
- アプリ層で
AppErrorに合流させると、画面やAPIが作りやすい🌉 - テストは「成功/失敗」がスッキリ書けて最高🧪💖
次の章では、同時更新で起きる「競合」⚔️を体験していくよ〜😵✨