TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する

TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する ゲーム開発、特に RPG やタクティカルゲームにおいて、スキルの「射程(レンジ)」や「効果範囲(AOE: Area of Effect)」の実装は非常に複雑になりがちです。単体攻撃、直線、周囲、円形、さらには自分自身を対象とするものまで、そのバリエーションは多岐にわたります。 これらを string 型の ID や、大量の if 文、あるいは複雑なクラス継承で管理しようとすると、いつか必ず「新しいレンジを追加したのに、判定処理を書き忘れた」あるいは「このスキルタイプには不要なはずのパラメータが混入している」といったバグに直面します。 TypeScript の Union 型(特に Discriminated Union / 判別可能な共用体) を活用すれば、こうしたロジックをコンパイルレベルで安全に保護し、設計意図をコードに焼き付けることができます。本記事では、架空のゲームを題材に、any や as を一切使わずに、メンテナンス性の高いスキルシステムを構築する手法を徹底解説します。 1. Union 型でスキル種別を表現する まず、スキルの「レンジ(範囲)」を型として定義します。ここでのポイントは、単に名前を列挙するのではなく、「そのレンジを計算するために最低限必要なデータ」をセットにすることです。 なぜこの設計にするのか(データ構造の純粋性) オブジェクト指向的な発想では、BaseRange クラスを継承して AreaRange クラスを作る、といった方法が取られがちです。しかし、ゲームデータ(特に JSON などでシリアライズされるデータ)を扱う場合、純粋なオブジェクト(POJO)として扱える方がシリアリアライズの相性が良く、ロジックを分離(疎結合)しやすくなります。 // 1. 各レンジの個別定義 type MeleeRange = { type: 'melee' }; // 隣接マス(1マス) type RangedRange = { type: 'ranged'; distance: number }; // 遠距離(単体指定)。最大射程が必要。 type LineRange = { type: 'line'; length: number; width?: number; // オプションで太さを持たせることも可能 }; // 直線。貫通距離が必要。 type AreaRange = { type: 'area'; radius: number; excludeCenter?: boolean }; // 円形または四角形範囲。半径が必要。 type SurroundRange = { type: 'surround' }; // 自分の周囲8マス。パラメータ不要。 type SelfRange = { type: 'self' }; // 自分自身。パラメータ不要。 // 2. これらを統合した Union 型(Discriminated Union) export type SkillRange = | MeleeRange | RangedRange | LineRange | AreaRange | SurroundRange | SelfRange; // 3. スキルの全体定義 export interface Skill { id: string; name: string; description: string; range: SkillRange; // 型安全なレンジ定義 baseDamage: number; scalingStat: 'STR' | 'INT' | 'DEX'; // どのステータスに依存するか manaCost: number; } このように type という「タグ(判別子)」を持たせることで、TypeScript は「type が 'area' なら radius プロパティが確実に存在する」と認識します。逆に、'melee' の時には radius にアクセスしようとするとコンパイルエラーになります。これにより、不必要なデータへの依存を完全に排除できます。 ...

March 28, 2026 · 4 min

ターン制戦闘をフィールドに統合する vs 専用画面に分ける:設計比較と判断基準

ターン制戦闘をフィールドに統合する vs 専用画面に分ける:設計比較と判断基準 ターン制RPGを開発する際、エンジニアが最初に直面する大きな設計判断の一つが「戦闘をどこで行うか」です。具体的には、不思議のダンジョンのように**フィールド上でそのまま戦う(フィールド統合型)のか、ドラゴンクエストのように専用の戦闘画面に遷移する(専用画面型)**のか、という選択です。 この選択は単なるビジュアルの違いに留まらず、状態管理(State Management)、当たり判定、AIの実装、そしてスケーラビリティに決定的な影響を与えます。本稿では、TypeScriptを用いた架空のゲームの実装例を交えながら、両アプローチの設計思想と判断基準を深く掘り下げます。 1. 2つのアプローチの比較 まずは、それぞれの特性をトレードオフの観点から整理します。 項目 フィールド統合型 (Seamless) 専用画面型 (Isolated) UXの印象 テンポが良い、空間の繋がりを感じる 演出が豪華、戦略に集中できる 状態管理 非常に複雑(フィールド+戦闘の混合) 比較的単純(画面ごとにStateを全入れ替え) 位置情報の意味 極めて重要(射程、視線、逃走経路) 抽象的(前衛・後衛、ターゲット選択) AIの実装コスト 高い(地形考慮、パス検索が必要) 低い(コマンド選択アルゴリズムに集中) 拡張性 難しい(新しいギミックが戦闘に影響する) 容易(戦闘専用の特殊ルールを作りやすい) 2. フィールド統合型の実装:空間と時間の同期 フィールド統合型(ローグライク方式)では、「移動」と「攻撃」が同じタイムライン上で扱われます。 なぜその設計にするか プレイヤーが「一歩動く」ことと「剣を振る」ことが同等のコスト(1ターン)を持つため、戦略が空間的になります。壁を背にする、通路に誘い込むといった地形利用が自然にゲームプレイに組み込まれるのが最大のメリットです。 アクション設計の例 TypeScriptでのアクション定義は以下のようになります。 type FieldAction = | { type: 'FIELD_PLAYER_MOVE'; direction: Vector2 } | { type: 'FIELD_PLAYER_ATTACK'; targetId: string } | { type: 'FIELD_MONSTER_TURN_START' } | { type: 'FIELD_DAMAGE_ENTITY'; entityId: string; amount: number } | { type: 'FIELD_KILL_MONSTER'; monsterId: string }; interface FieldState { player: Player; monsters: Record<string, Monster>; tiles: TileMap; turnOwner: 'PLAYER' | 'MONSTER'; animations: AnimationQueue; } 実装のポイント この形式では、Reducer が非常に巨大になりがちです。なぜなら、「移動した結果、トラップを踏み、そのダメージでHPが0になり、死亡処理が走る」という一連の連鎖(Side Effects)を、同一のグリッド座標系で計算しなければならないからです。 const fieldReducer = (state: FieldState, action: FieldAction): FieldState => { switch (action.type) { case 'FIELD_PLAYER_ATTACK': const monster = state.monsters[action.targetId]; if (!monster) return state; // 距離計算が必須 const dist = calculateDistance(state.player.pos, monster.pos); if (dist > state.player.range) return state; return { ...state, // 戦闘結果を直接フィールドの状態に反映 monsters: { ...state.monsters, [action.targetId]: { ...monster, hp: monster.hp - state.player.atk } }, animations: [...state.animations, { type: 'SLASH', pos: monster.pos }] }; // ... } }; 3. 専用画面型の実装:コンテキストの分離 専用画面型(エンカウント方式)では、戦闘が開始された瞬間にフィールドのコンテキストがシリアライズされ、独立した「戦闘エンジン」に制御が移ります。 ...

March 28, 2026 · 2 min

React で作る堅牢なゲームセーブシステム:localStorage と Reducer を疎結合に保つ設計

React で作る堅牢なゲームセーブシステム:localStorage と Reducer を疎結合に保つ設計 ゲーム開発において、プレイヤーの進行状況を保存する「セーブ / ロード」は最も重要な機能の一つです。Web ブラウザで動作する React アプリケーションの場合、最も手軽な保存先は localStorage です。 しかし、単純に localStorage.setItem をコードのあちこちに散りばめてしまうと、テスタビリティが低下し、データ構造の変更(スキーマ変更)に弱いシステムになってしまいます。 本記事では、架空の RPG 『React Odyssey』を例に、SavePort インターフェースと Reducer の初期化関数を組み合わせた、クリーンで堅牢なセーブ / ロードの実装方法を解説します。 1. 概要:なぜ「直接 localStorage」を避けるのか React の useReducer を使ったゲーム開発では、ゲームの状態(State)は一つの大きなオブジェクトとして管理されます。これを JSON.stringify して localStorage に保存するのは簡単です。 しかし、以下の理由から、ロジックの中に直接 localStorage を書くべきではありません。 副作用の分離: Reducer は純粋関数であるべきです。セーブ処理(副作用)を Reducer の中に入れることはできません。 環境非依存: 将来的に保存先を IndexedDB やクラウド(Firebase 等)に変更したくなったとき、コードを大幅に書き換える必要が出てきます。 テストのしやすさ: localStorage が存在しない Node.js 環境(Vitest 等)でロジックのテストを行う際、モック化が容易である必要があります。 これらを解決するために、Dependency Inversion Principle(依存性逆転の原則) に基づいた設計を採用します。 2. SavePort 設計:抽象化の定義 まずは、ゲームエンジン側が必要とする「セーブ機能」のインターフェースを定義します。これを SavePort と呼びます。 // domain/save/save-port.ts /** * 保存されるデータの構造(スキーマ) * ゲームの現在の状態に加え、メタ情報を付与する */ export interface SaveData { version: number; // セーブデータのバージョン timestamp: string; // 保存日時 state: GameState; // 実際のゲーム状態 } /** * セーブ/ロードに関する抽象インターフェース */ export interface SavePort { /** データを保存する */ save(data: SaveData): Promise<void>; /** データを読み込む。存在しない場合は null を返す */ load(): Promise<SaveData | null>; /** セーブデータが存在するか確認する */ exists(): Promise<boolean>; /** セーブデータを削除する */ clear(): Promise<void>; } なぜインターフェースにするのか? ゲームのメインロジック(UseCase)は、この SavePort を通じて読み書きを行います。この時点では「どうやって保存するか」は知らなくてよく、「保存できる」という事実だけに依存させます。 ...

March 28, 2026 · 4 min

Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ

Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ 概要 現代のフロントエンド開発において、Redux や React の useReducer を通じて「Reducer パターン」は広く浸透しました。しかし、このパターンを本格的なゲーム開発、特に複雑なロジックが絡み合う RPG やシミュレーションゲームに適用しようとすると、多くの開発者が「副作用」の壁にぶつかります。 「ダメージ計算に乱数を使いたい」「ゲーム内の経過時間を管理したい」「マスターデータを参照したい」……。 これらの要素を素直に実装すると、Reducer の外側にある状態や関数に依存してしまい、純粋関数としての美しさとテストのしやすさが失われてしまいます。 本記事では、すべての副作用を引数として注入し、gameReducer(state, action, masters, random) という純粋関数のみでゲームの全ロジックを完結させる「副作用ゼロ」のアーキテクチャについて解説します。 課題:なぜゲーム開発で Reducer は敬遠されるのか 一般的な Web アプリケーションの Reducer は単純です。「ボタンを押したらフラグを反転させる」「入力された文字列を状態に保存する」といった操作がメインだからです。 しかし、ゲームでは以下のような「非決定的な要素」や「巨大な静的データ」が頻繁に登場します。 1. 乱数 (Randomness) の扱い クリティカルヒットの判定、モンスターのドロップアイテム、マップの自動生成など、ゲームは乱数の塊です。Reducer の中で Math.random() を呼んだ瞬間、その関数は「同じ入力に対して同じ出力を返す」という純粋性を失います。 2. 静的データ (Master Data) の参照 モンスターのステータス表、アイテムの定義、スキル効果など、ゲームには膨大な「変わらないデータ」があります。これを Reducer の外にあるグローバル変数から参照すると、テスト時にそのグローバル変数の状態も気にしなければならなくなります。 3. 時刻 (Time) と経過の管理 「3時間経過したらスタミナが回復する」「夜になるとモンスターが強くなる」といった時間依存の処理です。new Date() を Reducer で使うのは、乱数と同様に純粋性を破壊します。 4. 非同期処理 (Async/Fetch) サーバーから最新のイベント情報を取得したり、セーブデータをロードしたりする処理です。Reducer は同期的に実行される必要があるため、非同期処理をそのまま中に書くことはできません。 OOP(オブジェクト指向)との比較 クラスベースの OOP では、player.attack(monster) のようにメソッドを呼び出します。これは直感的ですが、内部で this.hp -= damage のように状態を直接書き換えます(ミュータブル)。 小規模なら良いですが、プロジェクトが大きくなり「攻撃時にスキルが発動し、その効果で回復し、さらにログに記録し……」と連鎖が始まると、どこで何が起きたかを追跡するのが不可能になります。 設計:副作用を「引数」に封じ込める 副作用を排除するための解決策はシンプルです。「必要なものはすべて外から渡す」、つまり依存性の注入 (DI) です。 ...

March 28, 2026 · 4 min

React + Canvas 2D API で作るターン制ローグライク:論理と描画を切り離す設計

React + Canvas 2D API で作るターン制ローグライク:論理と描画を切り離す設計 React でゲームを作る際、多くの開発者が最初に直面するのが「DOM で描画するか、Canvas で描画するか」という選択です。特に数千枚のタイルや多数のユニットが登場するローグライクゲームでは、DOM 要素の管理はすぐにパフォーマンスの限界に達します。 本稿では、React の強力な状態管理(useReducer)と、Canvas 2D API の命令的な描画を組み合わせ、滑らかなアニメーションを実現しつつ堅牢なゲームロジックを維持する設計手法について解説します。 1. 概要:なぜ React と Canvas を組み合わせるのか React は「宣言的」な UI 構築に長けていますが、毎秒 60 回の頻度で数千の DOM 要素を更新するような動的な描画には向いていません。一方で、Canvas は「命令的」であり、ピクセル単位での高速な描画が可能ですが、状態と描画の同期を自分で行う必要があります。 この二つの「いいとこ取り」をするのが、「ロジックは React(useReducer)で、描画は Canvas で」 という役割分担です。 本記事で構築するアーキテクチャ Core Logic (useReducer): ゲームの「真実の状態(State of Truth)」を管理。ターン単位で離散的に変化する座標などを扱う。 Render Loop (requestAnimationFrame): Canvas 上で毎フレーム実行される描画処理。 Interpolation (補完): 離散的な論理座標を、滑らかな描画座標へと変換するローカル状態管理。 2. 設計判断:論理座標と描画座標の分離 ローグライクゲームは基本的に「ターン制」です。プレイヤーが右に移動したとき、内部データ(Core State)では x: 10 から x: 11 へと一瞬で書き換わります。しかし、これをそのまま描画すると、キャラクターがワープしたように見えてしまいます。 滑らかな移動(アニメーション)を実現するためには、以下の二種類の状態を明確に分ける必要があります。 なぜ分離が必要か 種類 管理場所 特徴 役割 論理座標 (Logical Position) useReducer (Global) 整数(タイル単位)。 当たり判定、AI、クエスト進行など。 描画座標 (Visual Position) Canvas 内の Actor クラス (Local) 小数点を含むピクセル単位。 滑らかな移動、揺れ、エフェクト。 判断理由: Core State にアニメーションの「途中経過(x: 10.2 など)」を持たせてしまうと、ゲームロジックが描画の都合に汚染されます。例えば、「まだ移動アニメーション中だから攻撃はできない」といった判定をロジック層で書く必要が出てき、コードが複雑化します。ロジック層は常に「今は(論理的に)どこにいるか」だけを知っていれば良いのです。 ...

March 28, 2026 · 4 min

乱数を interface で抽象化してゲームロジックをテスタブルにする

乱数を interface で抽象化してゲームロジックをテスタブルにする 概要 ゲーム開発において、「運」の要素は面白さを生む不可欠なスパイスです。クリティカルヒット、レアアイテムのドロップ、ダンジョンの自動生成など、多くの場面で乱数が使われます。 しかし、プログラミングの文脈において、乱数は「不確実性」そのものであり、ユニットテストの天敵です。本記事では、TypeScript を用いて乱数をインターフェースで抽象化し、テスト時に決定論的な(結果が予測可能な)挙動をさせる設計パターンについて解説します。 課題:Math.random() がもたらす「テスト不能」という病 もっとも素浦に実装すると、ゲームロジックの中で直接 Math.random() を呼び出すことになります。 // 直接 Math.random() を使う例 export class CombatService { calculateDamage(baseDamage: number, critRate: number): number { // 運が悪ければテストが落ちる if (Math.random() < critRate) { return baseDamage * 2; } return baseDamage; } } このコードをテストしようとすると、以下の問題に直面します。 非決定的 (Non-deterministic) なテスト: 同じ入力に対して、実行するたびに結果が変わる可能性があります。 境界値のテストが困難: 「クリティカル率 5% のとき、0.049 を引いたらクリティカル、0.051 を引いたら通常攻撃」という境界値の検証が運任せになります。 モックの乱立: vi.spyOn(Math, 'random') などでグローバルなオブジェクトを書き換える手法もありますが、並列テストで干渉したり、クリーンアップを忘れると他のテストに影響を与えたりと、脆いテストになりがちです。 設計:Port / Adapter パターンによる抽象化 この問題を解決するために、Dependency Inversion Principle (依存性逆転の原則) を適用します。 ロジックが「具体的な乱数生成器」に依存するのではなく、抽象的な「乱数提供インターフェース」に依存するように設計を変更します。 1. Port (Interface) の定義 ロジックが必要とする「乱数を得るための窓口」を定義します。 2. Adapter (Implementation) の実装 Production Adapter: 本番環境では Math.random() を使う。 Test Adapter: テスト環境では、事前に定義した値を返したり、シード値に基づいた再現性のある乱数を返す。 この設計にすることで、ロジック側は「誰がどうやって乱数を作っているか」を気にせず、「乱数がもらえること」だけを約束された状態になります。 ...

March 28, 2026 · 3 min

TypeScript npm workspaces でゲームロジックを UI から完全分離する

TypeScript npm workspaces でゲームロジックを UI から完全分離する 概要 モダンなフロントエンド開発、特にゲーム開発において、「ロジック」と「表示(UI)」の分離は永遠の課題です。React や Vue などのフレームワークにロジックが密結合してしまうと、テストが困難になり、将来的に別のプラットフォーム(例えば Web から React Native や CLI ツールへ)に展開する際の大きな障害となります。 本記事では、TypeScript npm workspaces を活用して、ゲームロジックを独立したパッケージ (packages/core) として切り出し、React UI (apps/client) から完全に分離する設計手法を解説します。また、外部 I/O や非決定的な処理(乱数など)を抽象化する Port / Adapter パターンについても触れます。 課題:なぜロジックが UI に染み出すのか? 多くのプロジェクトでは、気づかないうちにロジックが React コンポーネントや Hooks の中に漏れ出していきます。 // 密結合な例 const PlayerStats = () => { const [hp, setHp] = useState(100); const handleAttack = () => { // UI の中で計算ロジックが動いている const damage = Math.floor(Math.random() * 10) + 5; setHp(prev => Math.max(0, prev - damage)); }; return <button onClick={handleAttack}>攻撃を受ける</button>; }; このような設計には以下の課題があります: テストの困難さ: Math.random() が直接使われているため、結果が不安定でユニットテストが書きにくい。 再利用性の欠如: この「ダメージ計算ロジック」を、サーバーサイドや別の UI フレームワークで使い回すことができない。 依存の混入: ロジックを動かすために React の実行環境(レンダリングサイクル)が必要になる。 設計:npm workspaces による物理的隔離 ロジックを「物理的に」隔離するために、以下の monorepo 構成を採用します。 ...

March 28, 2026 · 3 min

AI コーディングエージェントを飼い慣らす:最強の「AGENTS.md」の書き方

AI コーディングエージェントを飼い慣らす:最強の「AGENTS.md」の書き方 近年、Gemini、Claude、Aider といった「自律型 AI コーディングエージェント」が開発現場に浸透しつつあります。彼らは指示一つでファイル構成を理解し、コードを書き、テストを実行して修正まで行います。 しかし、エージェントを使い始めた多くのエンジニアが直面するのが、**「AI エージェントの暴走」**です。 型定義を面倒くさがって any を連発する プロジェクト独自のディレクトリ構造を無視して勝手に utils/ を作る vitest --watch などの終了しないコマンドを叩いてフリーズする 指示していないリファクタリングを始めて関係ないファイルを壊す これらの問題を防ぎ、エージェントを「熟練のペアプロ相手」に変える魔法のファイル、それが AGENTS.md です。本記事では、AI エージェントに守らせるべきルールを定義する AGENTS.md の書き方と、その設計思想を徹底解説します。 なぜ AGENTS.md が必要なのか AI エージェントは、人間が数年かけて培った「プロジェクトの阿吽の呼吸」を知りません。 エージェントに与えられるコンテキスト(ファイル内容や履歴)は有限であり、その中で彼らは「最も確率的に正しそうな推論」を行います。その結果、彼らはしばしば**「最も楽な道(=技術的負債を生む道)」**を選んでしまいます。 AGENTS.md をプロジェクトのルートに配置する理由は、**「エージェントの推論空間に強制的な制約(ガードレール)を設けるため」**です。 なぜその設計にしたか:外部メモリとしての役割 エージェントは毎回ゼロベースで思考するわけではありませんが、多くのファイルを見すぎることで逆に重要なルールを見失う(ロスト・イン・ザ・ミドル現象)ことがあります。AGENTS.md という明確なルールブックを定義し、エージェントの起動時やタスク開始時に必ず読み込ませることで、思考のブレを最小限に抑えることができます。 AGENTS.md に書くべき内容の 4 つの分類 効果的な AGENTS.md は、以下の 4 つのセクションで構成するのがベストプラティスです。 禁止事項 (Prohibitions): 致命的なエラーや環境のハングを防ぐ 命名規則・コーディング基準 (Standards): コードの品質を一定に保つ アーキテクチャ原則 (Architecture): システムの整合性を維持する テスト・検証方針 (Testing): 修正の正しさを担保する それぞれのセクションについて、具体的な記述例を見ていきましょう。 1. 禁止事項 (Prohibitions) エージェントが最もやりがちな「環境破壊」を防ぐための最重要セクションです。 ルール 理由(なぜその設計にするか) インタラクティブコマンドの禁止 npm init や git commit (メッセージ入力待ち) など、ユーザー入力を待つコマンドは CLI エージェントをフリーズさせます。 Watch Mode の禁止 vitest --watch 等はプロセスが終了しないため、エージェントが「完了」を検知できなくなります。 any 型の原則禁止 AI は型の整合性を取るのが面倒になると any で逃げようとします。これは長期的な保守性を著しく低下させます。 勝手な依存関係の追加禁止 package.json を書き換えて新しいライブラリを入れる行為は、セキュリティやビルドサイズに影響するため、人間の許可を必須にします。 実例コード ## 禁止事項 - **コマンド実行**: - `vi`, `nano`, `top` などのインタラクティブなコマンドは実行しない。 - `npm start`, `vitest --watch` などの終了しないプロセスは背景実行 (`&`) するか、単発実行モードを使用すること。 - **TypeScript**: - `any` 型の使用は厳禁。どうしても必要な場合はコメントで理由を明記すること。 - **Git**: - ユーザーの明示的な指示なしに `git commit` や `git push` を行わない。 2. 命名規則・コーディング基準 (Standards) プロジェクトの「見た目」と「一貫性」を守るためのルールです。ここを疎かにすると、エージェントは自分の得意なスタイル(多くの場合、学習データで最も多いスタイル)で書き始めてしまいます。 ...

March 28, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その6

はじめに Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。 ここで一度立ち止まって、なぜこの設計になったのかを振り返る。 正直に言う。最初の要件はこうだった。 「クリーンアーキテクチャっぽく、テスタブルにしたい」 それだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。 そしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。 途中でこう思った。 「俺いる必要なくね?」 この記事はその問いに向き合いながら、設計を改めて言語化したものだ。 このシリーズ自体がTDDだった 書き終えてから気づいたことがある。 このシリーズのサイクルはこうだった。 AIに実装させる ↓ 動かす・読む・会話する(認識のズレを検出) ↓ ズレを言語化してAIにフィードバック ↓ 納得したら記事にする ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。 Red → 認識がズレていると感じる Green → 会話して納得する Refactor → 記事として言語化する 人間がテストケースになっていた。 TDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。 誤っていた認識たち 振り返ると、理解がズレていた箇所がいくつかあった。 「PolicyはVOと1-1になる」と思っていた 最初、ReadingStatusPolicyがReadingStatusに対応しているのを見て、PolicyはVOと対になるものだと思っていた。 違う。今回たまたま1-1になっているだけだ。 Policyの本質は複数のEntityやVOをまたいだ条件判定だ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。 VOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。 「Domainは外部を知らない」という捉え方が逆だった 「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。 外部がDomainを知っている。 向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。 Route Handler ↓ Service ↓ Repository interface ↓ Domain(Entity / ValueObject / Policy) この向きがわかって初めて、Serviceの役割も見えた。 Serviceが何をするのかわかっていなかった 依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。 答えはシンプルだった。フローだけ持つ接着剤。 async startReading(id: string): Promise<Book> { const book = await this.repo.findById(id); // 取得 if (!book) throw new NotFoundError(...); book.changeStatus(ReadingStatus.Reading); // Entityのルールに従う await this.repo.save(book); // 保存 return book; } ServiceはDomainのルールを自分で判定しない。 canTransitionを自分で呼ばない。book.changeStatus()に委ねるだけだ。 判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。 ...

March 5, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2

前回のおさらい その1では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。 今回はいよいよ Service 層に入る。ここが設計の核心だ。 複数のクラスをまたぐフローを、DI(依存性の注入)を使って DB から切り離す。 今回追加したもの src/ domain/ entity/ User.ts # 追加 Review.ts # 追加 valueobject/ UserName.ts # 追加 ReviewComment.ts # 追加 repository/ BookRepository.ts # 追加(Interface) service/ BookShelfService.ts # 追加 User と Review を追加する まず Entity の準備。今回は単純なので ValueObject から作る。 UserName export class UserName { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("UserNameは空にできない"); } if (value.length > 50) { throw new Error("UserNameは50文字以内"); } } } trim() してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。 ReviewComment export class ReviewComment { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("ReviewCommentは空にできない"); } if (value.length > 1000) { throw new Error("ReviewCommentは1000文字以内"); } } } User Entity import { UserName } from "../valueobject/UserName"; export class User { constructor( public readonly id: string, public readonly name: UserName, ) {} } User 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば changeXxx() メソッドが生える設計だが、今は id と name を持つだけでいい。 ...

March 4, 2026 · 4 min