はじめに
なぜ 2026 年に、2018 年出版の本を再読するのでしょうか。正直に言えば、『Architecture Modernization』の翻訳作業で DDD の概念が頻出し、「分かったつもり」の理解では訳せなくなったからです。初読から 7 年。関数型の視点で DDD を説明する本書を、今度こそ腹落ちさせたかった。
読む動機
『Domain Modeling Made Functional』は、DDD と関数型プログラミングを組み合わせたアプローチを解説する書籍です。
著者の Scott Wlaschin は、F# コミュニティで知られる人物で、「Railway Oriented Programming」などの概念を広めたことでも有名です。著者のサイトでは、本書の内容を補完する講演資料や記事が公開されています。
実は本書を読むのは三度目です。初読は 2019 年頃でした。普通にめちゃくちゃ面白い本だと思いました。ただ、当時の主要言語は Lua、Python、Bash、Go だったため、それでどう活かすかを考えていました。関数型の概念は理解したつもりでしたが、実務にどう活かすかまでは考えが及びませんでした。
影響を受けて『すごい Haskell たのしく学ぼう!』(通称、すごい H 本)を読んで、改めてプログラミングが楽しいと思っていたような気がします。実務でもこう考えるべきだ、という意識が変わりました。
二度目は日本語版が出たときです。日本語で読めることで感謝の小躍りをしていました。最高の翻訳だと思います。
で、今回、改めて読み直した理由は 3 つあります。
1 つ目は、DDD をきちんと学び直す必要があったことです。きっかけは『Architecture Modernization』の翻訳作業でした。レガシーシステムのモダナイゼーションを扱うこの本では、DDD の概念—特に Bounded Context や Strategic Design—が頻繁に登場します。翻訳しながら、自分の DDD 理解が表面的であることに気づきました。
エリック・エヴァンスの原典もあらためて読みましたが、オブジェクト指向の文脈で説明される DDD には、どこか違和感がありました。Aggregate の境界、Entity の同一性、Value Object の不変性—これらの概念は、関数型の視点で見ると自然に理解できるのではないか。そう思い、本書を手に取りました。
2 つ目は、Rust でドメインモデリングをどう実践するか考えていたことです。Rust は関数型言語ではありませんが、代数的データ型やパターンマッチングを持っています。F# で書かれた本書のコードは、Rust に翻訳できるはずです。その翻訳作業を通じて、両言語の違いと共通点を理解したいと思いました。
3 つ目は、AI エージェント時代における型システムの意味を考えたかったことです。コーディングエージェントが実用レベルに達した 2026 年、「型で不可能を作る」という設計思想の価値が高まっています。AI はドキュメントを読み飛ばすことがあります。しかし、型で定義された制約は無視できません。コンパイルが通らないからです。型は「お願い」ではありません。「壁」です。
読む前の状態
DDD については、実務で何度か適用した経験があります。Bounded Context の設計、Aggregate の境界決め、Event Storming のファシリテーション。しかし、「なぜそう設計するのか」を言語化できていませんでした。経験則で判断している部分が多かったのです。
もしあなたも「DDD は使っているけど、なぜそう設計するのかうまく説明できない」と感じているなら、本書は役に立つかもしれません。
関数型プログラミングについては、Haskell を少し触った程度でした。モナドは「文脈を持つ計算」くらいの理解です。Rust の Option と Result は日常的に使っていますが、それが関数型の概念とどうつながるのか、深く考えたことはありませんでした。
本書を読んで得た最大の洞察を先に述べておきます。
関数型プログラミングの本質は、状態は例外的な存在であり、ほとんどの処理は状態を使うことなく記述できるということです。
私たちはプログラミングを学ぶとき、まず変数への代入を覚えます。x = 1。x = x + 1。状態を変更することがプログラミングの基本だと教わります。しかし冷静に考えると、ビジネスロジックの大半は「入力を受け取り、計算し、出力を返す」で書けます。状態の変更が必要になるのは、データベースに保存するときや外部 API を呼ぶとき—つまりシステムの境界を越えるときだけです。
しかし同時に、状態のトランザクション(状態遷移)は現実のビジネスでは避けられません。注文は「未検証」から「検証済み」に変わります。申請は「提出」から「承認」に変わります。この状態遷移をどう表現するか。
本書が示す答えは、Transformation-Oriented Programming です。核心は「元のオブジェクトを変更しない」ことです。UnvalidatedOrder を validate で変換して ValidatedOrder を得ます。このとき、元の UnvalidatedOrder には一切触れません。新しい ValidatedOrder を作るだけです。order.validate() ではなく validate(order) -> ValidatedOrder。この発想の転換が、関数型ドメインモデリングの核心です。
AI コーディングについては、Claude Code や Cursor を日常的に使っています。便利ですが、生成されるコードの品質にはばらつきがあります。特に、ドメイン固有の制約を理解させるのが難しいです。型定義があると精度が上がるという感覚はありましたが、理論的に説明できませんでした。
この感想文のアプローチ
本感想文では、2 つの視点を持って読んでいます。
言語の視点: F# で書かれた本書のコードを、Rust でどう表現するか。第 2 章で F# と Rust の対応関係を整理し、第 4 章以降は Rust のみで実装を示します。F# にあって Rust にない機能(カリー化、Units of Measure、computation expressions)については、Rust での代替手段を提示しています。
時代の視点: 2018 年に書かれた DDD の概念を、2026 年の AI エージェント時代にどう再解釈するか。本書の「Make Illegal States Unrepresentable(不正な状態を表現不可能にする)」という原則は、AI が破れない制約を作る技術として読み直せます。型で「不可能」を定義すれば、AI はその不可能を実装できません。この視点で本書を読み解きます。
想定読者
この感想文は、以下のような読者を想定しています。
書籍を読むのにF# の知識は不要です。書籍を読むとそもそも丁寧に教えてくれるの不要なのですが本稿では Rust で提示します。
Rust が何も分からない人向けにも、コードが出てくるたびに一通り説明しながら進めます。「型」「関数」「構造体」といった基本的な言葉の意味から丁寧に解説するので、プログラミング経験が浅くても読み進められるはずです。Rust を体系的に学びたい場合は、公式ドキュメントの日本語版も参照してください。
実践的なコード例で学びたい場合は、Rust by Example も有用です。
では、本編に入りましょう。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。
1. Introducing Domain-Driven Design
本章は DDD(Domain-Driven Design、ドメイン駆動設計)の概要を紹介する章です。DDD とは、Eric Evans が 2003 年に提唱したソフトウェア設計手法です。「ビジネスドメインの専門家と開発者が共通の言語でモデルを構築し、そのモデルをコードに直接反映させる」というアプローチで、本章ではコード例は登場せず、DDD の概念に焦点を当てます。
開発者の仕事はコードを書くことではない
第 1 章の冒頭で、著者は「開発者の仕事はコードを書くことだと思うかもしれないが、私は反対だ」と述べています。開発者の仕事は「ソフトウェアを通じて問題を解決すること」であり、コーディングはその一側面に過ぎません。
2026 年の今、この主張はさらに重みを増しています。コーディングエージェントが「どう作るか」を担えるようになりました。しかし「何を作るか」を決めるのは、依然として人間の仕事です。
共有モデルの重要性
本章の核心は「共有モデル」の概念です。ドメインエキスパート、開発チーム、そしてソースコードが同じモデルを共有すべきだという主張です。
従来の DDD では、開発者がドメインエキスパートから知識を獲得し、それをコードに翻訳していました。翻訳の過程で歪みが生じるリスクがありました。だからこそ、全員が同じモデルを理解し、同じ言葉で話すことが重要です。
Event Storming
Event Storming というワークショップ手法が紹介されています。ドメインエキスパートと開発者が一緒に、ビジネスで起こる「イベント」を付箋に書き出して壁に貼っていきます。「Order form received」「Order placed」「Order shipped」。
Event Storming には複数のスコープがあります。本書が扱うのは「プロセスレベル」—特定のワークフローを詳細に分析するものです。「Big Picture」レベルでは、組織全体のドメイン構造を俯瞰します。
本章で Ollie が説明したような「顧客は既に商品コードを知っている」「一度に 200〜300 アイテムを注文する」といったドメイン固有の知識は、人間が引き出さなければなりません。ドメインエキスパートは「当たり前」を知っています。その「当たり前」を私たちは知りません。
AI エージェント時代においても、この作業は完全には自動化できません。AI はコードベースを読めますが、「なぜそう設計したか」「どんなビジネス制約があるか」は読み取れません。Event Storming で引き出された暗黙知を、CLAUDE.md や設計ドキュメントに言語化する。この作業の価値は、むしろ高まっています。ちなみに2024年発売の『Architecture Modernization』でも同手法が紹介されています。
Bounded Context
DDD では「Bounded Context(境界づけられたコンテキスト)」という概念を使って、ドメインを分割します。Bounded Context とは、特定のドメインモデルが適用される明確な境界のことです。同じ「顧客」という言葉でも、販売部門と配送部門では意味が異なることがあります。Bounded Context を分けることで、各コンテキスト内では用語の意味が一貫します。
本章の例では、注文処理、配送、請求という 3 つの Bounded Context が登場します。
Bounded Context は、コードの境界だけでなく、チームの境界にも影響します。1 つの Context を 1 つのチームが担当するのが理想です。境界が曖昧だと、チーム間の調整コストが増大します。
明確に境界が定義された Bounded Context は、変更の影響範囲を限定できます。
Ubiquitous Language
DDD では「Ubiquitous Language(ユビキタス言語)」という概念があります。これは、ドメインエキスパートと開発者がコミュニケーションに使う共通の語彙であり、そのままコード上の命名にも使われます。
OrderFactory、OrderManager、OrderHelper といった技術的な命名は、ドメインエキスパートには意味不明だと著者は述べています。一方、PlaceOrder、ValidateOrder、PriceOrder といったドメインに基づく命名なら、誰もがその意図を理解しやすいです。
DDDは過剰か
本章の内容を踏まえつつ、批判的な視点も必要です。
DDD は、複雑なビジネスドメインを扱う場合に有効とされています。しかし、「まず動くものを作り、後からリファクタリングする」というアプローチが、短期間でのリリースには向いている場合もあります。
一方で、事前の設計なしに作られたコードは、しばしば一貫性を欠きます。同じ概念に異なる名前を使ったり、似たロジックを複数箇所に重複させたりします。
私自身の経験を振り返ると、DDD を「一度きりの設計作業」として捉えていた頃は失敗が多かったです。あるプロジェクトで Event Storming を実施し、5 つの Bounded Context を特定しました。しかし実装を進めると、そのうち 2 つは同じ Context に統合すべきだと気づきました。別の 1 つは 3 つに分割すべきでした。最初の設計の精度は 6 割程度だったのです。
この経験から学んだのは、DDD は「段階的に洗練させる」ものだということです。最初から理想的なモデルを目指すのではなく、実装を通じて境界の妥当性を検証し、継続的に見直します。大規模な変革は「ビッグバン」ではなく「段階的な改善」で進める方が成功率が高い。DDD も例外ではありません。
2. Understanding the Domain
本章では、ドメインエキスパートへのインタビューを通じてドメインを理解するプロセスが解説されます。コード例が本格的に登場する前に、本書が採用する「関数型プログラミング」というアプローチと、その核心について整理しておきます。
なぜ「関数型」ドメインモデリングなのか
本書のタイトルは「Domain Modeling Made Functional」です。DDD と関数型プログラミングを組み合わせています。なぜでしょうか。
関数型プログラミングを学んで獲得する概念は、突き詰めると 1 つのことに集約されます。
状態は例外的な存在であり、ほとんどの処理は状態を使うことなく記述できる。
これが関数型の核心です。
状態は「境界を越えるとき」だけ必要
私たちは普段、プログラムを「状態を変更するもの」として捉えがちです。しかし、ビジネスロジックの大半は「入力を受け取り、何かを計算し、出力を返す」という形式で書けます。
- 注文明細と単価から合計金額を計算する → 状態不要
- 住所文字列をパースして構造化データにする → 状態不要
- 商品コードが有効かどうか検証する → 状態不要
状態が「必要」になるのは、システムの境界を越えるときだけです。データベースに保存するとき、外部 API を呼び出すとき、ファイルに書き込むとき。
この事実に気づくと、設計の発想が変わります。状態を「デフォルト」ではなく「例外」として扱います。
しかし状態遷移は避けられない
同時に、状態のトランザクションは現実のシステムでは避けられません。注文は「未検証」から「検証済み」に変わります。ビジネスの世界は状態遷移で満ちています。
問題は、この状態遷移をどう表現するかです。
オブジェクト指向の答えは「オブジェクトが状態を持ち、メソッドが状態を変更する」でした。
// オブジェクト指向的なアプローチ(問題あり) struct Order { status: OrderStatus, customer_info: Option<CustomerInfo>, validated_at: Option<DateTime>, amount: Option<Decimal>, } impl Order { fn validate(&mut self) { self.status = OrderStatus::Validated; self.validated_at = Some(now()); } }
この設計の問題は、状態の「今」しか見えないこと、そして Option フィールドの組み合わせ爆発です。validated_at が Some で amount が None の状態は正しいのでしょうか?整合性を開発者が頭の中で管理し続けなければなりません。
Transformation-Oriented Programmingという答え
本書が示す答えは、Transformation-Oriented Programmingです。著者の言葉を借りれば、「ビジネスプロセスはデータを何らかの形で変換する—入力を受け取り、何かを行い、出力を返す」。核心は「元のオブジェクトを変更しない」ことです。
状態ごとに異なる型を作ります。UnvalidatedOrder は「未検証の注文」を表す型です。ValidatedOrder は「検証済みの注文」を表す型です。これらは別の型であり、別の構造を持ちます。そして、validate 関数は UnvalidatedOrder を受け取り、新しい ValidatedOrder を返します。元の UnvalidatedOrder には触れません。
pub struct UnvalidatedOrder { pub order_id: String, pub customer_info: String, pub shipping_address: String, } pub struct ValidatedOrder { pub order_id: OrderId, pub customer_info: CustomerInfo, pub shipping_address: Address, } fn validate(order: UnvalidatedOrder) -> Result<ValidatedOrder, ValidationError> { // 元のUnvalidatedOrderは変更されない }
重要なのは、元の UnvalidatedOrder は変更されないということです。validate 関数は新しい ValidatedOrder を「作る」だけです。状態を変えるな。新しい値を作れ。
UnvalidatedOrder → validate → ValidatedOrder → price → PricedOrder
これは「パイプライン」です。データがパイプを流れていきます。各関数は入力を受け取り、出力を返します。それだけです。
なぜこのアプローチが強力なのか
- 状態の追跡が不要: 型を見れば分かります。
ValidatedOrderを持っているなら、それは「検証済みの注文」です - 並行処理での競合がない: 元のデータを変更しないから、複数のスレッドが同時に処理しても問題ありません
- テストが簡単: 入力を与えて、出力を確認します。モックも不要です
そして何より、ビジネスプロセスが本質的に「入力を受け取り、何かを行い、出力を返す」ものだから相性が良いのです。「見積書」が「発注書」になります。「申請書」が「承認済み申請書」になります。ビジネスの人々は、無意識のうちにこのモデルで考えています。
F#という選択とRustでの実践
本書の実装言語は F#です。著者が F#を選んだ理由は、「実用的な関数型言語」として設計されており、.NET エコシステムの資産を活用できるからです。
本感想文は F#ではなく Rust で実装を示します。私が Rust を選んだ理由は、現在の私にとって主要言語であること、そして所有権システムによる状態遷移の明示化に興味があったからです。Rust は「関数型言語」ではありませんが、関数型の重要な特徴を備えています。
- 代数的データ型: struct と enum で、F#のレコード型と判別共用体を表現できます
- イミュータビリティ: デフォルトで変数は不変です
- パターンマッチング: 網羅的なパターンマッチを強制します
- Option/Result: 欠損値とエラーを型で表現します
Rust構文の基礎
ここで、本感想文で使う Rust の基本を整理しておきます。詳しくは公式ドキュメントを参照してください。
まず「型」とは何でしょうか。型とは「値の種類」のことです。数値、文字列、日付、注文情報—これらは全て異なる「種類」の値であり、それぞれに型があります。型があると、「文字列を数値で割る」といった意味のない操作をコンパイラ(プログラムを機械語に変換するソフトウェア)が事前に検出してくれます。
struct(構造体): 複数の値をまとめて 1 つの「もの」として扱う仕組みです。例えば「注文」は「注文 ID」と「顧客情報」と「配送先」を持ちます。これらをまとめて Order という 1 つの型にできます。
pub struct Order { pub id: OrderId, // フィールド(構成要素) pub customer_info: String, }
pub は「public(公開)」の略で、外部からアクセスできることを意味します。
enum(列挙型):「A か B か C のどれか」を表す型です。例えば注文の状態は「未処理」か「処理済み」か「発送済み」のいずれかです。
enum OrderStatus { Pending, // 未処理 Validated, // 検証済み Shipped, // 発送済み }
関数: 入力を受け取り、何かの処理をして、出力を返すものです。fn で定義します。
fn add(a: i32, b: i32) -> i32 { a + b }
i32 は 32 ビット整数という型です。-> i32 は「i32 型の値を返す」という意味です。
impl: 型に「できること」(メソッド)を追加します。
impl Order { fn total(&self) -> Money { /* ... */ } }
&self は「自分自身を参照する」という意味です。これで order.total() のように呼び出せます。
Option<T>:「値があるかもしれないし、ないかもしれない」を表す型です。Some(値) なら値がある、None なら値がありません。
Result<T, E>:「成功か失敗か」を表す型です。Ok(値) なら成功、Err(エラー) なら失敗です。
所有権: Rust の最も特徴的な概念です。値は常に 1 つの変数だけが「持っている」のです。関数に渡すと、その値の所有権が移動し、元の変数では使えなくなります。これが「古い状態を誤って使う」ミスを防いでくれます。詳しくは公式ドキュメントを参照してください。
F#と Rust で異なる部分—ガベージコレクション vs 所有権、パイプライン演算子、computation expressions—については、該当箇所で必要になったときに具体的に説明します。
所有権の概念は、一見すると制約に見えます。しかし、ドメインモデリングにおいては「状態遷移」を明確にする利点があります。
fn validate(order: UnvalidatedOrder) -> Result<ValidatedOrder, ValidationError> { // UnvalidatedOrderの所有権がこの関数に移動 // 呼び出し元ではUnvalidatedOrderは使えなくなる // → 検証前の注文を誤って使うことがない Ok(ValidatedOrder { /* ... */ }) }
F#では同じ order 変数を後から参照できてしまいますが、Rust では所有権の移動により「古い状態へのアクセス」がコンパイルエラーになります。これは Transformation-Oriented Programming の考え方をさらに強化しています。
ドメインエキスパートへのインタビュー
第 2 章は、ドメインエキスパート(Ollie)へのインタビューから始まります。
インタビューの冒頭で、著者は典型的な e コマースモデルを想定していました。しかし Ollie の回答は違いました。「顧客は既に商品コードを知っている。一度に 200〜300 アイテムを注文することもある」。
Widgets 社のドメインは「一般的」ではありません。B2B で、顧客はエキスパートで、商品コードを直接入力します。この固有の要件は、人間がドメインエキスパートから引き出さなければなりません。
データベース駆動設計への衝動
本章で参考になったのは、「データベース駆動設計と戦う」というセクションです。
注文フォームを見ると、多くの開発者はすぐにテーブル設計を始めたくなります。著者はこれを「間違い」と断言しています。DDD では、ドメインが設計を駆動するのであって、データベーススキーマが駆動するのではありません。
永続化の無知(Persistence Ignorance)は重要な原則です。まずドメインの概念とワークフローを整理し、永続化は後から考えます。
テキストベースのドメイン文書化
本章では、ドメインを文書化するためのシンプルな記法が紹介されています。
data Order =
CustomerInfo
AND ShippingAddress
AND BillingAddress
AND list of OrderLines
AND AmountToBill
この擬似コードは、Rust の構造体定義にほぼそのまま翻訳できます。「AND」は struct のフィールド、「OR」は enum のバリアントになります。ドメインエキスパートと開発者の両方が読める、共通言語として機能します。
オーダーのライフサイクルと状態の型
本章の後半で、注文には複数のフェーズがあることが明らかになります。
- UnvalidatedOrder: 届いたばかりの状態
- ValidatedOrder: 検証済みの状態
- PricedOrder: 価格が計算された状態
data UnvalidatedOrder =
UnvalidatedCustomerInfo
AND UnvalidatedShippingAddress
AND list of UnvalidatedOrderLine
data ValidatedOrder =
ValidatedCustomerInfo
AND ValidatedShippingAddress
AND list of ValidatedOrderLine
この「状態ごとに別の型を定義する」パターンは、Rust では構造体として実装します。状態遷移は関数のシグネチャとして型付けされ、コンパイラが不正な状態遷移を検出してくれます。
ワークフローの分解
最終的に、注文処理ワークフローは以下のステップに分解されます。
substep "ValidateOrder" =
input: UnvalidatedOrder
output: ValidatedOrder OR ValidationError
dependencies: CheckProductCodeExists, CheckAddressExists
substep "PriceOrder" =
input: ValidatedOrder
output: PricedOrder
dependencies: GetProductPrice
ワークフローを小さなステップに分解することで、各ステップが独立してテスト可能になります。入力・出力・依存関係が明確に定義されていれば、実装も容易になります。
3. A Functional Architecture
本章は、関数型アーキテクチャの原則を解説します。Bounded Context、イベント駆動通信、Onion Architecture。これらの概念は言語に依存しません。
アーキテクチャを考えるタイミング
第 3 章の冒頭で、著者は矛盾したことを述べています。「この段階でアーキテクチャについて考えすぎるべきではない。まだシステムを理解していないからだ」。しかし同時に「大まかな実装方針を持っておくのは良いことだ」とも言います。
著者の「walking skeleton(動く骨格)」というアプローチは有効です。まず最小限の構造を設計し、その骨格に沿ってコードを書いていきます。
Bounded Contextと自律性
Bounded Context をソフトウェアコンポーネントとしてどう実装するか。モノリス内のモジュール、独立したアセンブリ、マイクロサービス。いくつかの選択肢があります。
著者は「最初はモノリスとして構築し、スケールや独立デプロイが求められる段階で分離する」ことを勧めています。マイクロサービスを夢見て最初から分割し、サービス間通信の地獄に落ちた経験がある人には、身に染みる助言でしょう。私もその一人です。最初から理想的なマイクロサービスを目指すと、サービス間の境界を間違えたときの修正コストが膨大になります。まずモノリス内でモジュールを分離し、境界が安定してからサービスに切り出す。これを最初から知っていれば、いくつかの深夜対応は避けられたかもしれません。
イベントによるContext間通信
Bounded Context 間の通信は、イベントを介して行われます。Place-Order ワークフローが OrderPlaced イベントを発行し、Shipping コンテキストがそれを受け取って ShipOrder コマンドを生成します。
この非同期・疎結合のパターンは、変更の影響範囲を限定できます。各 Context が独立したイベントの発行者・購読者として定義されていれば、一方の変更が他方に波及しにくくなります。
DTOと信頼境界
本章で重要な概念が登場します。Domain Object と Data Transfer Object (DTO) の区別です。
Domain Object は、Bounded Context 内部でのみ使用されます。DTO は、Context 間の通信やシリアライズのために設計されます。同じ「Order」でも、内部で使う Order と、外部に公開する OrderDTO は別物です。
さらに、Bounded Context の境界は「信頼境界」として機能します。外部からのデータは信頼できません。内部に入る前にバリデーションが必要です。
Context間の契約関係
Context 間の契約関係について 3 つのパターンが紹介されます。
- Shared Kernel: 両チームが共同で契約を所有
- Customer/Supplier: 下流の Context が契約を定義
- Conformist: 上流の Context の契約に従う
これらの関係は、技術的な問題であると同時に組織的な問題でもあります。
Onion Architecture と I/O の分離
本章の後半では、コードの構造について議論されます。Onion Architecture では、ドメインが中心にあり、I/O は外周に配置されます。依存関係は常に内側に向かいます。純粋なコアを、不純な殻で包みます。
「I/Oはワークフローの端でのみ行う。ワークフロー内部は純粋な関数で構成する」
この原則は、第 2 章で述べた「状態は例外的」という考え方と直結します。ワークフロー内部は「入力を受け取り、何かを行い、出力を返す」純粋な関数だけで構成されます。データベースアクセスやファイル I/O は、ワークフローの開始時か終了時にのみ行います。
この構造により、ドメインロジックはテスト容易で予測可能になります。少なくとも、理論上は。
4. Understanding Types
本章から、コード例が本格的に登場します。第 2 章で整理した F#と Rust の対応関係に基づき、以降は Rust のみで実装を示します。
型とは「可能な値の集合」である
著者の「型」の定義はシンプルです。「関数の入力や出力として使える値の集合に付けた名前」。i16 は-32768 から+32767 までの数値の集合、String は全ての文字列の集合です。
この定義を読んで、自分がいかに型を「コンパイラを満足させるためのもの」として捉えていたか気づかされました。型は思考のツールです。
ANDとORによる型の合成—代数的データ型
本章の核心は、型の合成方法です。著者は 2 つの方法を示します。これらは「代数的データ型(Algebraic Data Types)」と呼ばれ、関数型プログラミングの基礎概念です。F#では「レコード型」と「判別共用体」、Rust では struct と enum で表現できます。
AND型(struct / 積型): 複数の値を組み合わせます。
struct FruitSalad { apple: AppleVariety, banana: BananaVariety, cherries: CherryVariety, }
FruitSalad を作るには、apple と banana と cherries の全てが必要です。
OR型(enum / 和型): 複数の選択肢から 1 つを選びます。
enum FruitSnack { Apple(AppleVariety), Banana(BananaVariety), Cherries(CherryVariety), }
FruitSnack は、Apple か Banana か Cherries のいずれか 1 つです。
たった 2 つの概念で複雑なドメインを表現できます。AND と OR という論理演算で型を組み立てます。
Simple Types—newtype patternの威力
本章で一番「これが使える」と思ったのは、Simple Types の話です。
プリミティブ型をそのまま使うのは危険です。CustomerId も OrderId も i32 だとしたら、間違って OrderId を CustomerId として渡してもコンパイルが通ってしまいます。
Rust では、newtype patternでこの問題を解決します。newtype pattern とは、既存の型を新しい型でラップすることで、型レベルで区別をつけるイディオムです。F#では「単一ケース判別共用体」、Rust では「タプル構造体」で表現します。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct CustomerId(i32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct OrderId(i32);
#[derive(...)] について説明します。これは Rust の「属性マクロ」で、型に機能を自動で追加する仕組みです。詳しくは公式ドキュメントを参照してください。
「トレイト」とは、型が持つべき「能力」や「振る舞い」の定義です。例えば「比較できる」「コピーできる」「文字列として表示できる」といった能力がトレイトとして定義されています。
#[derive(Debug, Clone)] と書くと、その型に Debug と Clone という能力が自動的に追加されます。手で書くと何十行にもなるコードを、一行で済ませられます。
よく使うトレイトを整理しておきます。
| トレイト | 意味 | 使いどころ |
|---|---|---|
Debug |
中身を表示できる | println!("{:?}", x) でデバッグ出力 |
Clone |
複製を作れる | .clone() で明示的にコピー |
Copy |
自動で複製される | 代入や関数呼び出しで自動コピー(小さな値向け) |
PartialEq |
比較できる | == で等しいか判定 |
Eq |
反射律を満たす比較 | HashMapのキーに使うとき必要 |
Hash |
ハッシュ値を計算できる | HashMapのキーに使うとき必要 |
内部的には同じ i32 ですが、型システム上は別の型です。CustomerId を期待する関数に OrderId を渡すとコンパイルエラーになります。
ここで重要なのは、Clippy のような静的解析ツールでもこの種のバグは検出できないということです。Clippy は Rust の公式リンター(コード品質チェックツール)で、700 以上の lint ルールを持ちます。cargo clippy コマンドで実行でき、コードの問題点を警告してくれます。
しかし、Clippy にも限界があります。
// Clippyでは検出できない fn process(customer_id: i32, order_id: i32) { /* ... */ } process(order_id, customer_id); // バグ!でもコンパイルは通る // 型で防ぐ fn process(customer_id: CustomerId, order_id: OrderId) { /* ... */ } // process(order_id, customer_id); // コンパイルエラー!
Clippy は構文的な問題—if x { "a" } else { "a" } のような両方のブランチが同じ処理、u32 >= 0 のような常に true になる比較—は検出できます。しかし、「この i32 は顧客 ID を表し、あの i32 は注文 ID を表す」というドメインの知識は持っていません。newtype pattern は、Clippy が検出できないバグを型システムで防ぎます。
AI コーディングエージェントも同様です。「customer_id と order_id を間違えないように」という指示は、自然言語では曖昧です。しかし CustomerId と OrderId という別の型が定義されていれば、AI が生成したコードでも型の取り違えはコンパイル時に検出されます。
Option型とResult型
F#と Rust は、欠損値を Option で、エラーを Result で表現します。これらは関数型プログラミングにおける標準的なエラーハンドリング手法で、null や例外を使わずに「値がないかもしれない」「失敗するかもしれない」ことを型で表現します。
struct PersonalName { first_name: String, middle_initial: Option<String>, // 省略可能 last_name: String, }
Option<T> は Some(T) か None のいずれかです。null を使わずに「値がないかもしれない」ことを型で表現します。
エラーハンドリングには Result<T, E> を使います。? 演算子で、エラー時に早期リターンできます。
型によるドメイン表現
本章で紹介されている支払い方法のモデリング例は、型がドキュメントとして機能することを示しています。
enum PaymentMethod { Cash, Check(CheckNumber), Card(CreditCardInfo), } struct Payment { amount: PaymentAmount, currency: Currency, method: PaymentMethod, }
約 25 行で、支払いドメインの構造が明確に表現されています。このコードは、ドメインエキスパートにも読めます。
型システムは思考のツールである
本章を通じて感じたのは、型システムは「コンパイラのため」ではなく「思考のため」にあるということです。
「動的型付け言語でも同じことができるのでは?」という疑問があるかもしれません。確かに、Python や Ruby でもドメインモデリングはできます。しかし、型がないと「どんな値が入りうるか」を頭の中で追跡し続けなければなりません。静的型付けでは、その追跡をコンパイラに委ねられます。
AND 型と OR 型という単純な組み合わせで、複雑なドメインを表現できます。型定義という明確な仕様があれば、実装時の迷いが減ります。型システムは、ドメインの構造を可視化するツールです。
5. Domain Modeling with Types
本章では、前章で学んだ型システムの概念を使って、実際にドメインモデルを構築します。
コードがドキュメントになる
第 5 章の冒頭で、著者は挑戦的な問いを投げかけます。「ソースコードを直接ドキュメントとして使い、UML 図のような別の成果物を不要にできるか?」
正直、最初は懐疑的でした。しかし本章を読み進めるうちに、著者の意図が分かってきました。
擬似コードからRustへ
第 2 章で作成した擬似コードを、Rust の型に変換します。
data Order =
CustomerInfo
AND ShippingAddress
AND BillingAddress
AND list of OrderLines
AND AmountToBill
これが Rust では以下のようになります。
pub struct Order { pub id: OrderId, pub customer_id: CustomerId, pub shipping_address: ShippingAddress, pub billing_address: BillingAddress, pub order_lines: Vec<OrderLine>, pub amount_to_bill: BillingAmount, }
ほぼ一対一の変換です。擬似コードと Rust コードを並べて見ると、ドメインの構造がそのまま型に反映されていることが分かります。
Value ObjectとEntity
DDD では、オブジェクトを「Value Object」と「Entity」に分類します。
Value Object: 同じ値を持てば同一とみなします。
#[derive(Debug, Clone, PartialEq, Eq)] pub struct PersonalName { pub first_name: String, pub middle_initial: Option<String>, pub last_name: String, }
Entity: 固有の ID を持ち、内容が変わっても同一性を保ちます。
impl PartialEq for Contact { fn eq(&self, other: &Self) -> bool { self.contact_id == other.contact_id // IDのみで比較 } }
Aggregate—一貫性の境界
本章で最も重要な概念は「Aggregate」です。Order と OrderLine の関係を考えます。OrderLine の価格を変更したとき、Order の合計金額も更新します。両者は常に一貫した状態を保ちます。
DDD では、こうした関連オブジェクトの集合を「Aggregate」と呼び、最上位のオブジェクトを「Aggregate Root」と呼びます。
immutable なパターンでは、OrderLine を変更するには Order 全体を作り直します。これは一見非効率に見えますが、一貫性を強制する効果があります。OrderLine だけを変更して、Order の合計金額を更新し忘れる、というバグが起こりにくくなります。
Aggregate参照—IDのみを保持する
Order に Customer 情報を含める場合、Customer オブジェクト全体ではなく、CustomerId だけを保持すべきです。
pub struct Order { pub id: OrderId, pub customer_id: CustomerId, // Customer全体ではなく、IDのみ pub order_lines: Vec<OrderLine>, }
この設計は、immutability と相性が良いです。Customer の電話番号が変わっても、Order を更新する必要がありません。
型でドメインを表現する—最終形
本章の最後に、完全なドメインモデルの例が示されます。
#[derive(Debug, Clone, PartialEq, Eq)] pub enum ProductCode { Widget(WidgetCode), Gizmo(GizmoCode), } #[derive(Debug, Clone, Copy, PartialEq)] pub enum OrderQuantity { Unit(UnitQuantity), Kilos(KilogramQuantity), }
このコードは、第 2 章の擬似コードとほぼ同じ構造を持ちます。struct、enum、match の意味さえ分かれば読めます。match については Rust 公式ドキュメントを参照してください。
String は沈黙します。EmailAddress は語ります。著者の主張—「型でドメインを表現すれば、コードがドキュメントになる」—は正しいと思います。
6. Integrity and Consistency in the Domain
本章は、ドメイン内のデータが常に「信頼できる状態」であることを保証する方法を解説します。
Smart Constructor—制約を強制する
本章で最も実用的だったのは、Smart Constructor(スマートコンストラクタ)のパターンです。Smart Constructor とは、値の生成時にバリデーションを行い、不正な値の生成を防ぐコンストラクタのことです。通常のコンストラクタと異なり、Result を返して生成の失敗を表現できます。
例えば、UnitQuantity は 1 から 1000 の間の値でなければなりません。この制約をコメントで書くだけでは不十分です。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnitQuantity(i32); impl UnitQuantity { pub fn new(value: i32) -> Result<Self, String> { if value < 1 { Err("UnitQuantity must be at least 1".to_string()) } else if value > 1000 { Err("UnitQuantity must be at most 1000".to_string()) } else { Ok(UnitQuantity(value)) } } pub fn value(&self) -> i32 { self.0 } }
フィールドを pub にしなければ、外部から直接 UnitQuantity(500) と書けません。必ず UnitQuantity::new(500) を経由します。
NonEmptyList—空のリストを許さない
「注文には少なくとも 1 つの注文行がなければならない」という要件を、型で強制できるでしょうか。
#[derive(Debug, Clone, PartialEq)] pub struct NonEmptyList<T> { pub first: T, pub rest: Vec<T>, }
from_vec は Option を返します。空のベクターからは NonEmptyList を作れません。この「作れない」という事実が型で表現されています。
Make Illegal States Unrepresentable
本章で最も重要な原則は「不正な状態を表現不可能にする」です。
メールアドレスの例が分かりやすいです。「検証済み」と「未検証」のメールアドレスがあるとき、フラグで区別する設計は危険です。詳しくは Rust 公式ドキュメントの enum 解説を参照してください。
// 良い例:別の型として定義 pub struct VerifiedEmailAddress(String); pub enum CustomerEmail { Unverified(EmailAddress), Verified(VerifiedEmailAddress), }
VerifiedEmailAddress のコンストラクタを private にして、検証サービスからしか作れないようにします。これで、検証を経ずに Verified 状態を作ることが物理的に不可能になります。
fn send_password_reset(email: VerifiedEmailAddress) -> Result<(), SendError> { // この関数にEmailAddressを渡すとコンパイルエラー }
連絡先情報の例—OR型の活用
「顧客にはメールアドレスか住所のどちらか、または両方が必要」という要件を型で表現します。
pub enum ContactInfo { EmailOnly(EmailContactInfo), AddressOnly(PostalContactInfo), EmailAndAddress(BothContactMethods), }
3 つのケースしかありません。「メールも住所もない」という状態は表現できません。
「不可能を作る」という設計思想
本章の核心は「Make Illegal States Unrepresentable(不正な状態を表現不可能にする)」です。この原則を言い換えれば、型で「不可能」を作るということになります。
この原則を読んだとき、過去に遭遇したバグが走馬灯のように思い出されました。is_active = true なのに deleted_at が設定されている。status = "paid" なのに payment_id が null。フラグと Option の組み合わせ爆発で、「あり得ない」状態が本番データベースに存在していた。深夜に呼び出されて、整合性を手作業で修正した夜のことを、今でも覚えています。
あのバグは、型で防げたのです。似たような経験をしたことがある人は、少なくないのではないでしょうか。
NonEmptyListを使えば、空の注文は作れないVerifiedEmailAddressを使えば、未検証メールへのパスワードリセットは書けない- Smart Constructor を使えば、範囲外の値は存在できない
「できない」「書けない」「存在できない」—これらは制限ではなく、設計上の保証です。バリデーションは「お願い」。型は「物理法則」。
Clippy のような静的解析ツールでも、ドメインロジックの問題は検出できません。例えば、is_priced: bool と amount: Option<f64> を持つ構造体を考えます。is_priced = true なのに amount = None という矛盾した状態は、Clippy には「正しい Rust コード」に見えます。ビジネスルールを知らないからです。しかし、PricedOrder { amount: Money } と UnpricedOrder を別の型として定義すれば、この矛盾は表現できなくなります。Clippy が検出できない問題を、型システムが防ぎます。
AI エージェント時代において、この「不可能を作る」設計思想の価値は高まっています。AI は自然言語のドキュメントを読み飛ばすことがあります。しかし、型で「不可能」が定義されていれば、AI はその制約を破るコードを物理的に書けません。
7. Modeling Workflows as Pipelines
本章は、ワークフローをパイプラインとしてモデリングする方法を解説します。ビジネスプロセスを「変換の連鎖」として捉えるアプローチです。
ワークフローはパイプラインである
本章の冒頭で、著者は注文処理ワークフローを次のように要約しています。
workflow "Place Order" =
input: UnvalidatedOrder
output: OrderPlaced AND BillableOrderPlaced AND OrderAcknowledgmentSent
// step 1: ValidateOrder
// step 2: PriceOrder
// step 3: AcknowledgeOrder
// step 4: create and return events
各ステップは「入力を受け取り、変換し、出力を返す」関数です。これらを連結するとパイプラインになります。
第 2 章で述べた「状態は例外的、ほとんどの処理は状態なしで書ける」という原則が、ここで具現化されます。ワークフロー全体を見ると「状態遷移」に見えますが、各ステップを見ると「入力を受け取り、何かを行い、出力を返す」純粋な関数でしかありません。
状態マシンとしてのOrder
Order を単一の型として設計すると、フラグだらけになります。
// 悪い設計 struct Order { order_id: OrderId, is_validated: bool, is_priced: bool, amount_to_bill: Option<Decimal>, // pricedの時だけ存在 }
本書のアプローチは、各状態を別の型として定義することです。
pub struct UnvalidatedOrder { /* ... */ } pub struct ValidatedOrder { /* ... */ } pub struct PricedOrder { // ... pub amount_to_bill: BillingAmount, // この状態でのみ存在 }
PricedOrder には amount_to_bill があります。ValidatedOrder にはありません。フラグは不要で、「どの型か」が状態を表します。
AI にコードを書かせるとき、この設計は強力なガードレールになります。「検証をスキップして価格計算に進んでください」と指示しても、price() 関数が ValidatedOrder を要求する以上、AI は UnvalidatedOrder を渡すコードを書けません。型が不正な状態遷移を物理的に阻止します。
依存性を型で表現する
各ステップの依存性を型シグネチャで表現します。
type CheckProductCodeExists = fn(&ProductCode) -> bool; type CheckAddressExists = fn(&UnvalidatedAddress) -> Result<CheckedAddress, AddressValidationError>; type ValidateOrder = fn( CheckProductCodeExists, // 依存性1 CheckAddressExists, // 依存性2 UnvalidatedOrder, // 入力 ) -> Result<ValidatedOrder, ValidationError>;
依存性が関数の引数として明示されます。インターフェース全体ではなく、必要な関数だけを渡します。最小限の依存性です。
エフェクトの文書化
関数の「エフェクト(効果)」を型で文書化します。
- Result: エラーを返す可能性がある
- Async: 非同期 I/O を行う
- Option: 値が存在しない可能性がある
async fn check_address_exists( address: &UnvalidatedAddress, ) -> Result<CheckedAddress, AddressValidationError> { // 外部サービスへのHTTPリクエスト }
関数シグネチャを見れば、「この関数は非同期で、エラーを返す可能性がある」と分かります。
Transformation-Oriented Programmingの実践
具体的な例で考えます。オブジェクト指向的に書くと、こうなります。
impl Order { fn validate(&mut self, checker: &ProductChecker) -> Result<(), ValidationError> { // 状態を変更 self.is_validated = true; self.validated_at = Some(now()); Ok(()) } }
関数型で書き直すと、こうなります。
fn validate( order: UnvalidatedOrder, checker: &ProductChecker, ) -> Result<ValidatedOrder, ValidationError> { // 新しい値を作る(元のorderは変更しない) Ok(ValidatedOrder { order_id: OrderId::new(order.order_id)?, customer_info: validate_customer(order.customer_info)?, lines: validated_lines, }) }
違いは何でしょうか。
- コンパイル時チェック:
price()にUnvalidatedOrderを渡すとコンパイルエラー - 状態の整合性が型で保証:
ValidatedOrderにはis_validatedフラグがそもそも存在しない - テストが独立:
validate()とprice()を別々にテストできる
この単純さが強力なのだ
UnvalidatedOrder を ValidatedOrder に変換します。ValidatedOrder を PricedOrder に変換します。元のオブジェクトは触りません。新しいオブジェクトを作ります。それだけです。
- 状態の変更を追跡する必要がない(変更しないから)
- 並行処理でも競合しない(元のデータを変更しないから)
- テストが簡単(入力と出力を比較するだけ)
- デバッグが楽(各ステップの入出力をログに残せば、全経路が追える)
関数型プログラミングの入門書を読むと、モナドだの圏論だの、難しい概念が出てきます。しかし、実務で最も重要なのは、本書が示すTransformation-Oriented Programmingです。核心は 3 つです。
- 状態を型で表現する(
UnvalidatedOrderとValidatedOrderは別の型) - 状態遷移を関数で表現する(
validate(order) -> ValidatedOrder) - 元のオブジェクトを変更しない(新しい値を作るだけ)
変えるな。作れ。
この章を読み終えたとき、「これなら実務で使える」と確信しました。モナドや圏論を理解する必要はありません。「状態ごとに型を分ける」「元のオブジェクトを変更しない」。この 2 つだけで、設計の質は劇的に変わります。難しい理論ではなく、明日から使える実践知。本書の価値はここにあります。
8. Understanding Functions
本章は、関数型プログラミングの基礎を解説します。実装に入る前の準備として、関数の扱い方を整理しています。
関数型プログラミングとは
著者の定義はシンプルです。「関数型プログラミングとは、関数が本当に重要なものとしてプログラミングすること」。
オブジェクト指向では、オブジェクトがあらゆる場所で使われます。関数型では、関数があらゆる場所で使われます。依存性を注入するときは関数を渡します。コードを再利用するときは関数を合成します。
関数は「モノ」である
関数型プログラミングの核心は、関数が第一級の値であることです。変数に代入できます。リストに入れられます。引数として渡せます。戻り値として返せます。
let add1 = |x: i32| x + 1; let square = |x: i32| x * x; let functions: Vec<fn(i32) -> i32> = vec![add1, square]; for f in &functions { println!("{}", f(5)); }
関数をリストに入れて、ループで回しています。
高階関数
関数を引数に取る関数、または関数を返す関数を「高階関数」と呼びます。
fn eval_with_5_then_add_2<F>(f: F) -> i32 where F: Fn(i32) -> i32, { f(5) + 2 }
Rust では、関数を受け取る引数の型を Fn、FnMut、FnOnce トレイトで指定します。F#ではこの区別はありません。
クロージャとは
クロージャは「名前のない関数」です。通常の関数は fn name(...) と名前をつけて定義しますが、クロージャは |引数| 式 という形式でその場で作れます。詳しくは公式ドキュメントを参照してください。
let add1 = |x| x + 1; // 引数xを受け取り、x + 1を返す let result = add1(5); // 6
クロージャの特徴は、周囲の変数を「捕まえる」(キャプチャする)ことができる点です。
let multiplier = 3; let multiply = |x| x * multiplier; // multiplierを捕まえている let result = multiply(5); // 15
Fnトレイトの使い分け
関数を引数として受け取るとき、Rust では 3 種類のトレイトを使い分けます。これは「捕まえた変数をどう扱うか」で決まります。
| トレイト | 捕まえ方 | 呼び出し回数 |
|---|---|---|
Fn |
読み取るだけ | 何度でも |
FnMut |
変更する | 何度でも |
FnOnce |
消費する(使い切る) | 一度だけ |
最初は気にしすぎなくてよいです。コンパイラがエラーで教えてくれます。
カリー化と部分適用
F#では、すべての関数が自動的に「カリー化」されます。Rust にはカリー化が組み込まれていません。同じことを実現するには、明示的にクロージャを返します。
fn adder_generator(number_to_add: i32) -> impl Fn(i32) -> i32 { move |x| number_to_add + x } let add5 = adder_generator(5); let result = add5(3); // 8
部分適用は、依存性注入に活用できます。
let validate = |order| validate_order( check_product_code_exists, check_address_exists, order, ); let result = validate(unvalidated_order);
Total Functions(全域関数)
数学の関数は、すべての入力に対して出力が定義されます。12 を引数で割る関数を考えます。n = 0 のとき、何を返すべきでしょうか。
解決策は 2 つあります。入力を制限するか、出力を拡張するかです。
// 入力を制限 fn twelve_divided_by(n: NonZeroI32) -> i32 { 12 / n.0 } // 出力を拡張 fn twelve_divided_by(n: i32) -> Option<i32> { if n == 0 { None } else { Some(12 / n) } }
どちらの場合も、型シグネチャが正直になります。型シグネチャは嘘をつきません。コメントは嘘をつきます。
AI にコードを書かせるとき、この「正直な型シグネチャ」は重要です。AI は型シグネチャを見て、関数の契約を理解します。Option<i32> を返す関数なら、AI は None のケースを考慮したコードを生成します。しかし「0 を渡したら None を返します」というコメントは、読み飛ばされる可能性があります。
関数合成
F#にはパイプライン演算子 |> があります。Rust にはありません。代わりにメソッドチェーンや、関数を直接呼び出します。
let result: Vec<_> = (1..10) .map(|x| x + 1) .map(|x| x * x) .collect();
イテレータのアダプタは、パイプラインに近い書き方ができます。
Rustで関数型プログラミングを実践するために
本章を読んで、F#と Rust の違いを改めて認識しました。
- カリー化: F#は自動、Rust は手動
- パイプライン演算子: F#にはある、Rust にはない
- クロージャの所有権: F#は考慮不要、Rust は
moveやFn/FnMut/FnOnceを意識
これらの違いはありますが、関数型プログラミングの本質—関数を組み合わせてシステムを構築する—は Rust でも実践できます(が本当に最適か?という問いは投げないでくれ…本稿のアプローチと違いすぎる)。
9. Implementation: Composing a Pipeline
本章から、いよいよ実装に入ります。これまで型で設計してきたワークフローを、実際のコードに落とし込みます。
パイプラインの理想形
著者が示す理想のコードは驚くほどシンプルです。
let placeOrder unvalidatedOrder = unvalidatedOrder |> validateOrder |> priceOrder |> acknowledgeOrder |> createEvents
4 行で注文処理全体が表現されています。これが関数型アプローチの目指す姿です。
しかし現実には、関数の出力と次の関数の入力が一致しません。依存性をどこかで解決しなければなりません。本章はその「ギャップ」を埋める方法を解説します。
型シグネチャによる実装のガイド
本章で印象的だったのは、「型シグネチャを先に定義し、それに従って実装する」というアプローチです。
type ValidateOrder = fn( check_product_code: fn(&ProductCode) -> bool, check_address: fn(&UnvalidatedAddress) -> CheckedAddress, order: UnvalidatedOrder, ) -> ValidatedOrder;
型シグネチャが「契約」として機能します。引数の型、戻り値の型が全て決まっているので、実装者は「この契約を満たすコードを書く」だけでよいです。
依存性注入の関数型アプローチ
オブジェクト指向では、インターフェースを定義し、コンストラクタで注入します。関数型では、依存性を関数の引数として渡します。DI コンテナ?関数を渡せ。
fn validate_order( check_product_code: impl Fn(&ProductCode) -> bool, check_address: impl Fn(&UnvalidatedAddress) -> CheckedAddress, order: UnvalidatedOrder, ) -> ValidatedOrder { // check_product_code を使う }
インターフェース全体ではなく、必要な関数だけを渡します。
ワークフロー全体の組み立て
各ステップを組み立ててワークフロー全体を作ります。
pub fn place_order( // 依存性 check_product_code_exists: impl Fn(&ProductCode) -> bool, check_address_exists: impl Fn(&UnvalidatedAddress) -> CheckedAddress, get_product_price: impl Fn(&ProductCode) -> Price, // 入力 unvalidated_order: UnvalidatedOrder, ) -> Vec<PlaceOrderEvent> { let validated = validate_order( &check_product_code_exists, &check_address_exists, unvalidated_order, ); let priced = price_order(&get_product_price, validated); let acknowledgment = acknowledge_order(&priced); create_events(&priced, acknowledgment) }
F#のパイプライン演算子 |> がないので、変数に束縛しながら連鎖させます。本章では Result を使わず簡略化しており、次章で Result を導入します。
10. Implementation: Working with Errors
本章は、エラーハンドリングの関数型アプローチを解説します。「Railway Oriented Programming」と呼ばれるパターンを学びます。
エラーの三分類
著者はエラーを 3 つに分類します。
Domain Errors: ビジネスプロセスの一部として予期されるエラー。商品コードが無効、注文が請求で拒否される、など。
Panics: システムを未知の状態にするエラー。メモリ不足、ゼロ除算など。
Infrastructure Errors: ネットワークタイムアウト、認証失敗など。
この分類は実務でも有用です。「このエラーはドメインエキスパートに相談すべきか」という問いに答えられます。
ドメインエラーを型で表現する
#[derive(Debug, Clone)] pub enum PlaceOrderError { ValidationError(String), ProductOutOfStock(ProductCode), RemoteServiceError(RemoteServiceError), }
エラーを enum で定義することで、どんなエラーが起こりうるか、型定義を見れば分かります。
Railway Oriented Programming
著者が提唱する解決策が「Railway Oriented Programming(鉄道指向プログラミング)」です。著者自身による詳細な解説は以下を参照してください。
Result を返す関数は「分岐するレール」として可視化できます。成功すれば上のレールに、失敗すれば下のレールに進みます。一度失敗パスに入ると、残りのステップはバイパスされます。
[validateOrder] → [priceOrder] → [acknowledgeOrder] → 成功
↓ ↓ ↓
─────────────────────────────────────────────────────→ 失敗
Rustでの実装
Rust では ? 演算子が同じことをより簡潔に表現します。
fn validate_order(order: UnvalidatedOrder) -> Result<ValidatedOrder, ValidationError> { let order_id = OrderId::create(&order.order_id)?; let customer_info = to_customer_info(&order.customer_info)?; let shipping_address = check_address(&order.shipping_address)?; Ok(ValidatedOrder { order_id, customer_info, shipping_address, ... }) }
? 演算子は、Ok ならアンラップし、Err なら早期リターンします。
?演算子の仕組み
? は「失敗したら即座に関数から抜ける」という処理を一文字で書ける記号です。詳しくは公式ドキュメントを参照してください。
Result は「成功(Ok)か失敗(Err)か」を表す型でした。? をつけると、成功なら中身を取り出し、失敗ならその場で関数を終了して呼び出し元にエラーを返します。
// ?を使った書き方 let order_id = OrderId::create(&order.order_id)?; // これは以下と同じ意味 let order_id = match OrderId::create(&order.order_id) { Ok(v) => v, // 成功したら中身を取り出す Err(e) => return Err(e), // 失敗したら即座にエラーを返す };
? を使うには、関数の戻り値が Result である必要があります。
エラー型の変換
複数のステップを連結するとき、エラー型を統一します。
#[derive(Debug)] pub enum PlaceOrderError { Validation(ValidationError), Pricing(PricingError), } impl From<ValidationError> for PlaceOrderError { fn from(e: ValidationError) -> Self { PlaceOrderError::Validation(e) } }
Fromトレイトによる型変換
From は「ある型から別の型への変換方法」を定義するトレイトです。
例えば「ValidationError を PlaceOrderError に変換する方法」を定義しておくと、? 演算子が自動的にエラー型を変換してくれます。
// 「ValidationErrorからPlaceOrderErrorへの変換方法」を定義 impl From<ValidationError> for PlaceOrderError { fn from(e: ValidationError) -> Self { PlaceOrderError::Validation(e) } }
これを定義しておくと、validate_order が ValidationError を返しても、? が自動的に PlaceOrderError に変換してくれます。異なるエラー型を返す関数を連結できるようになります。
11. Serialization
本章は、ドメインオブジェクトを JSON や XML などの形式に変換する方法を解説します。Bounded Context の境界を越えるとき、内部のドメイン型をそのまま使うことはできません。
また、AI時代にはこのようなことも想定されます。
永続化とシリアライゼーションの区別
著者は 2 つの概念を区別します。
Persistence(永続化): プロセスの終了後も状態が残ること。 Serialization(シリアライゼーション): ドメイン固有の表現を、永続化可能な表現に変換すること。
本章はシリアライゼーションに焦点を当て、次章で永続化を扱います。
DTOによる変換—ドメインの境界防御
ドメイン型は複雑です。ネストした型、制約付きの型、選択肢を持つ型。これらを直接シリアライズするのは難しいです。
解決策は、Data Transfer Object(DTO)を中間層として使うことです。
なぜDTOを使うのか?
- ドメイン型は制約を持つ:
String50は 50 文字以下という制約があります。JSON の"name"フィールドは任意の長さです。直接マッピングできません。 - 内部実装の変更から外部を守る: ドメイン型のフィールド名を変えても、DTO が同じなら外部 API は影響を受けません。
- 検証の境界を明確にする: 外部からの入力は「信頼できない」です。DTO からドメイン型への変換時に検証することで、ドメイン内は常に「信頼できる」状態を保ちます。
Domain型 → DTO → JSON(シリアライズ) JSON → DTO → Domain型(デシリアライズ)
Rust では、ドメイン型と DTO 型を別々に定義します。ドメイン型は制約を持ち、DTO はプリミティブ型のみを使います。
/// 制約付きの文字列型(50文字以下) #[derive(Debug, Clone, PartialEq, Eq)] pub struct String50(String); impl String50 { pub fn create(s: &str) -> Result<Self, ValidationError> { if s.is_empty() { Err(ValidationError::Empty("String50 cannot be empty".into())) } else if s.len() > 50 { Err(ValidationError::TooLong("String50 must be 50 chars or less".into())) } else { Ok(String50(s.to_string())) } } pub fn value(&self) -> &str { &self.0 } } /// ドメイン型(制約付き) #[derive(Debug, Clone, PartialEq, Eq)] pub struct Person { pub first_name: String50, pub last_name: String50, } /// DTO(シリアライズ用・プリミティブのみ) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PersonDto { pub first_name: String, pub last_name: String, }
変換には From と TryFrom を使います。ドメイン型から DTO への変換は常に成功しますが、DTO からドメイン型への変換は失敗しうります。
/// ドメイン型 → DTO(常に成功) impl From<&Person> for PersonDto { fn from(person: &Person) -> Self { PersonDto { first_name: person.first_name.value().to_string(), last_name: person.last_name.value().to_string(), } } } /// DTO → ドメイン型(失敗する可能性あり) impl TryFrom<PersonDto> for Person { type Error = ValidationError; fn try_from(dto: PersonDto) -> Result<Self, Self::Error> { let first_name = String50::create(&dto.first_name)?; let last_name = String50::create(&dto.last_name)?; Ok(Person { first_name, last_name }) } }
TryFrom を使うことで、変換が失敗する可能性を型で表現しています。これは「Parse, don't validate」の実践です。入力を単に「正しいかどうか」検証するのではなく、より型安全な形式に変換(パース)することで、型レベルで正しさを保証します。
DTOは契約である
著者が強調するのは、DTO は「Bounded Context 間の契約」だということです。
他の Context が発行したイベントを受信するとき、そのフォーマットに依存します。フォーマットを変更すると、依存する Context に影響が及びます。
だから、シリアライズのフォーマットは慎重に設計すべきです。serde の #[derive(Serialize)] を安易に使うと、内部実装の変更が契約の破壊につながります。
選択肢型(enum)のシリアライズ
OR 型(enum)のシリアライズは注意が必要です。JSON には enum の概念がありません。Rust では serde の属性でタグ付け方式を指定します。serde については公式ドキュメントを参照してください。
/// 支払い方法のDTO - タグ付きenum #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum PaymentMethodDto { Cash, Check { check_number: String }, Card { card_number: String, expiry: String }, }
#[serde(tag = "type")] を指定すると、以下のような JSON が生成されます。
{"type":"Cash"} {"type":"Check","check_number":"12345"} {"type":"Card","card_number":"4111...","expiry":"12/25"}
タグ付きの方が明示的で、新しいケースを追加しやすいです。
ラウンドトリップの検証
シリアライズとデシリアライズは対になります。ラウンドトリップ(往復)テストで、データが失われないことを確認します。
pub fn serialize_order(order: &Order) -> Result<String, serde_json::Error> { let dto = OrderDto::from(order); serde_json::to_string_pretty(&dto) } pub fn deserialize_order(json: &str) -> Result<Order, String> { let dto: OrderDto = serde_json::from_str(json).map_err(|e| e.to_string())?; Order::try_from(dto).map_err(|e| format!("{:?}", e)) }
本章と中心テーマのつながり
DTO パターンは、本書のTransformation-Oriented Programmingと関連します。
外部からの入力(JSON)は「未検証の値」です。DTO からドメイン型への変換は、UnvalidatedOrder から ValidatedOrder への変換と同じパターンです。信頼できない入力を、信頼できるドメイン型に「変換」します。外部を信頼するな。まず変換せよ。
12. Persistence
本章は、ドメインモデルをデータベースに永続化する方法を解説します。DDD の原則に従いながら、現実のインフラと向き合います。
永続化の原則
本章の冒頭で、著者は 3 つの原則を示します。
- 永続化を端に押し出す(Push persistence to the edges): ワークフローの内部では I/O を行わない
- コマンドとクエリを分離する(CQRS): 更新操作と読み取り操作を分ける
- Bounded Contextは自分のデータストアを所有する: 他の Context のデータベースに直接アクセスしない
これらの原則は、「状態は例外的」という関数型の考え方と合致します。
永続化を端に押し出す
著者は、「ドメインロジックと I/O が混在したコード」と「分離したコード」を対比しています。
なぜI/Oを境界に押し出すのか?
- テストが容易になる: 純粋なドメインロジックは、データベース接続なしでテストできます。入力を与えて出力を確認するだけです。
- 推論が容易になる: 副作用がない関数は、同じ入力に対して常に同じ出力を返します。状態を追跡する必要がありません。
- 並行処理が安全になる: 共有状態を変更しないため、競合が発生しません。
- 変更に強くなる: データベースを変更しても、ドメインロジックは影響を受けません。逆も同様です。
まず、ドメイン型を定義します。これらの型はデータベースのことを知りません。
/// 未払いの請求書 #[derive(Debug, Clone, PartialEq)] pub struct UnpaidInvoice { pub invoice_id: InvoiceId, pub amount_due: Money, } /// 支払い済みの請求書 #[derive(Debug, Clone, PartialEq)] pub struct PaidInvoice { pub invoice_id: InvoiceId, pub amount_paid: Money, } /// 支払い処理の結果 #[derive(Debug, Clone, PartialEq)] pub enum InvoicePaymentResult { FullyPaid(PaidInvoice), PartiallyPaid(UnpaidInvoice), }
次に、純粋なドメインロジックを定義します。この関数は I/O を行いません。
/// 支払いを適用する - 純粋関数、I/Oなし pub fn apply_payment(invoice: UnpaidInvoice, payment: Payment) -> InvoicePaymentResult { let remaining = invoice.amount_due.0 - payment.amount.0; if remaining <= 0.0 { InvoicePaymentResult::FullyPaid(PaidInvoice { invoice_id: invoice.invoice_id, amount_paid: Money(invoice.amount_due.0), }) } else { InvoicePaymentResult::PartiallyPaid(UnpaidInvoice { invoice_id: invoice.invoice_id, amount_due: Money(remaining), }) } }
最後に、コマンドハンドラで I/O を境界に押し出します。パターンは「Load → Pure Logic → Save」です。
/// コマンドハンドラ - I/Oは境界で行う pub fn pay_invoice_handler<R: InvoiceRepository>( repo: &R, command: PayInvoiceCommand, ) -> Result<InvoicePaymentResult, PayInvoiceError> { // 1. Load(I/O - 開始時) let invoice = repo .load(&command.invoice_id) .ok_or(PayInvoiceError::InvoiceNotFound(command.invoice_id))?; // 2. 純粋なドメインロジック(I/Oなし) let result = apply_payment(invoice, command.payment); // 3. Save(I/O - 終了時) match &result { InvoicePaymentResult::FullyPaid(paid) => repo.save_paid(paid), InvoicePaymentResult::PartiallyPaid(unpaid) => repo.save_unpaid(unpaid), } Ok(result) }
Repositoryパターン
Rust では Trait を使って Repository を抽象化します。これにより、テスト時にモックを注入できます。
なぜTraitで抽象化するのか?
- テスト容易性: 本番では PostgreSQL、テストではインメモリ実装を注入できます。
- 実装の交換可能性: データベースを変更しても、ドメインロジックは影響を受けません。
- 依存性の逆転: ドメインが永続化の詳細に依存しません。依存の方向が逆になります。
トレイトとは
トレイトは「この型は〇〇ができる」という能力の定義です。例えば「データを読み込める」「データを保存できる」という能力を定義します。詳しくは公式ドキュメントを参照してください。
/// Repositoryトレイト - 永続化操作を抽象化 pub trait InvoiceRepository { fn load(&self, id: &InvoiceId) -> Option<UnpaidInvoice>; fn save_unpaid(&self, invoice: &UnpaidInvoice); fn save_paid(&self, invoice: &PaidInvoice); }
trait で能力を定義し、impl Trait for Type で「この型はこの能力を持つ」と宣言します。
ジェネリクスとは
ジェネリクスは「型を後で決める」仕組みです。<R: InvoiceRepository> は「InvoiceRepository という能力を持つ何かの型 R」という意味です。
// Rは「InvoiceRepositoryという能力を持つ何か」 fn pay_invoice_handler<R: InvoiceRepository>(repo: &R, ...) { ... }
これで同じ関数を、異なる実装で使い回せます。
// 本番ではPostgreSQLを使う pay_invoice_handler(&postgres_repo, command); // テストではメモリ上の仮実装を使う pay_invoice_handler(&in_memory_repo, command);
テスト用のインメモリ実装を作れば、実際のデータベースなしでドメインロジックをテストできます。
Persistence Ignorance(永続化の無知)
第 2 章で「データベース駆動設計と戦う」と述べたことの実践がここにあります。
ドメインモデルは、自分がどこに保存されるか知りません。知る必要もありません。Order 型はデータベースのことを知りません。永続化の詳細は、ワークフローの「端」で処理されます。
この設計により、ドメインロジックの変更が永続化コードに影響しません。逆に、データベースを変更してもドメインロジックは変わりません。
NoSQLとRDBの選択
本章では、NoSQL(ドキュメント DB)と RDB(リレーショナル DB)の両方のアプローチを解説しています。
NoSQL: Aggregate をそのままドキュメントとして保存できます。DDD との相性が良いです。 RDB: OR 型(enum)のマッピングが難しいです。インピーダンスミスマッチ(オブジェクトモデルとリレーショナルモデルの構造的な不一致)が発生します。
-- OR型のRDBへのマッピング(判別カラム) CREATE TABLE order_lines ( quantity_type VARCHAR(10), -- 'Unit' or 'Kilos' unit_quantity INT NULL, kilogram_quantity DECIMAL NULL );
どちらも完璧ではありません。「永続化は境界で行う」という原則を守ることで、純粋なドメインロジックと不純な I/O 処理を分離しやすくなります。
13. Evolving a Design and Keeping It Clean
本章は、本書の締めくくりとして、設計の進化と保守性について解説します。要件は変わります。ドメインモデルも変わります。その変化にどう対応するでしょうか。
変化への対応
著者は、DDD は「一度きりの静的なプロセス」ではないと強調します。要件が変われば、まずドメインモデルを見直します。実装をパッチするのではなく、モデルから考え直します。
変更例: 配送料の追加
配送料計算をワークフローに組み込むには、新しいステップを追加します。まず、新しい型を定義します。
なぜ既存の型を変更せず、新しい型を作るのか?
- 型の名前がドキュメントになる:
PricedOrderWithShippingという名前だけで、「価格計算済みで配送情報も持つ注文」だと分かります。 - 段階を明示できる:
PricedOrderとPricedOrderWithShippingは別の段階だと型で表現できます。 - コンパイラが変更を追跡する: 型が変わると、関連する箇所すべてでコンパイルエラーが発生します。見落としがありません。
/// 配送方法 #[derive(Debug, Clone, PartialEq, Eq)] pub enum ShippingMethod { Standard, Express, Overnight, } /// 配送情報 #[derive(Debug, Clone, PartialEq)] pub struct ShippingInfo { pub method: ShippingMethod, pub cost: Money, } /// 配送情報付きの価格計算済み注文 - 新しい型 #[derive(Debug, Clone, PartialEq)] pub struct PricedOrderWithShipping { pub order_id: OrderId, pub items: Vec<PricedOrderLine>, pub amount_to_bill: Money, pub shipping_info: ShippingInfo, }
次に、新しいパイプラインステップを定義します。
/// 新しいパイプラインステップ: 配送情報を追加 pub fn add_shipping_info(order: PricedOrder) -> PricedOrderWithShipping { // シンプルなロジック: $100以上は送料無料 let shipping = if order.amount_to_bill.0 > 100.0 { ShippingInfo { method: ShippingMethod::Standard, cost: Money(0.0), } } else { ShippingInfo { method: ShippingMethod::Standard, cost: Money(5.99), } }; PricedOrderWithShipping { order_id: order.order_id, items: order.items, amount_to_bill: order.amount_to_bill, shipping_info: shipping, } }
既存のコードを変更するのではなく、新しいステップを挿入します。
validateOrder → priceOrder → addShippingInfo → acknowledgeOrder → createEvents
この「ステップの追加」というアプローチは、多くの機能追加に応用できます。ロギング、パフォーマンスメトリクス、認可チェック、監査。各ステップが独立していて、型が合っていれば、安全に追加・削除できます。
VIP顧客の対応—入力をモデル化せよ
著者は重要な指摘をしています。「ビジネスルールの出力(送料無料フラグ)ではなく、入力(VIP ステータス)をモデル化せよ」。
なぜ「出力」ではなく「入力」をモデル化するのか?
- ルールが変わっても型は変わらない:「VIP は送料無料」→「VIP は送料 50%オフ」とルールが変わっても、
CustomerStatus型自体は変更不要です。関数だけ変えればよいです。 - 原因をモデル化する:「送料無料かどうか」は結果(派生情報)です。原因は「VIP かどうか」です。原因をモデル化すれば、結果はいつでも計算できます。
- 柔軟性が高い: VIP ステータスは送料以外にも使えます(優先サポート、限定商品へのアクセス等)。出力をハードコードすると、その柔軟性を失います。
/// 顧客ステータス - ビジネスルールの「入力」をモデル化 #[derive(Debug, Clone, PartialEq, Eq)] pub enum CustomerStatus { Normal, Vip, } /// 顧客 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Customer { pub customer_id: String, pub name: String, pub status: CustomerStatus, }
ビジネスルールは、入力(CustomerStatus)に基づいて決定を下します。
/// 顧客ステータスに基づいて配送を計算 pub fn calculate_shipping_for_customer(order: &OrderWithCustomer) -> ShippingInfo { match order.customer.status { CustomerStatus::Vip => ShippingInfo { method: ShippingMethod::Express, cost: Money(0.0), // VIPは無料のエクスプレス配送 }, CustomerStatus::Normal => { if order.amount_to_bill.0 > 100.0 { ShippingInfo { method: ShippingMethod::Standard, cost: Money(0.0), } } else { ShippingInfo { method: ShippingMethod::Standard, cost: Money(5.99), } } } } }
ビジネスルールが変わっても(例: VIP は送料無料→VIP は送料 50%オフ)、calculate_shipping_for_customer 関数を変更するだけでよいです。ドメインモデル自体(CustomerStatus)は変更する必要がありません。
型の変更と波及効果
本章の核心は、「型の変更がコンパイラによって追跡される」ことです。
// 変更前 pub struct PricedOrder { /* ... */ } // 変更後(配送情報を追加) pub struct PricedOrderWithShipping { // ... pub shipping_info: ShippingInfo, // 新しいフィールド }
PricedOrder と PricedOrderWithShipping は異なる型です。PricedOrder を期待していたコードに PricedOrderWithShipping を渡すとコンパイルエラーになります。
// これはコンパイルエラー! fn process(order: PricedOrder) { /* ... */ } process(priced_order_with_shipping); // 型が違う
動的型付け言語では、このような変更は「実行時エラー」として発見されます。静的型付けでは、「コンパイル時エラー」として発見されます。コンパイラがリファクタリングアシスタントとして機能します。
関数型 DDD の核心は、「型でドメインを表現する」ことです。AND 型(struct)と OR 型(enum)でドメインの構造を表現します。状態遷移を別の型として定義します。制約を Smart Constructor で強制します。不正な状態を表現不可能にします。フラグを立てるな。型を作れ。これらの原則は、F#でも Rust でも適用できます。
おわりに
読む前の三つの悩みへの回答
「はじめに」で述べた 3 つの読む動機に、本書がどう応えたかを振り返ります。
1. DDDを学び直す必要があった → 関数型の視点でDDDが腹落ちした
DDD の概念—Aggregate、Entity、Value Object—は、オブジェクト指向の文脈で説明されると抽象的に感じていました。本書は、これらを「型」という具体的な道具で表現する方法を示しました。
Value Object は単なる newtype です。struct OrderId(String) と書けば、それが Value Object です。Aggregate の境界は、型の境界で表現できます。ValidatedOrder と UnvalidatedOrder が別の型なら、それが境界です。
「なぜそう設計するのか」を言語化できるようになりました。「この型を別にするのは、状態が違うから」「この値を newtype にするのは、ドメイン上の意味が違うから」。経験則ではなく、型システムに基づいた説明ができます。
2. Rustでドメインモデリングを実践したかった → F#の概念はRustで十分(ではないかもしれないが)表現できる
F#にあって Rust にない機能—パイプライン演算子、computation expressions、Units of Measure—は、確かにあります。しかし、本書の核心である「Make Illegal States Unrepresentable」は、Rust で十分に実践できたと思います。
むしろ、Rust の所有権システムは F#にない利点を提供します。状態遷移を「所有権の移動」として表現できます。validate(order: UnvalidatedOrder) -> ValidatedOrder と書けば、検証前の注文は使えなくなります。F#では GC があるため、古い変数への参照が残る可能性がありますが、Rust では型システムがそれを防ぎます。
3. AIエージェント時代における型システムの意味を考えたかった → 型は「AIが破れない制約」
本書を読む前は、「型があると AI の生成精度が上がる」という感覚がありましたが、理論的に説明できませんでした。本書を読んで、その理由が明確になりました。
型で定義された制約は、物理的に破れません。NonEmptyList<OrderLine> と定義すれば、AI は空の注文を返すコードを書けません。コンパイルが通らないからです。「このフィールドは必須です」というコメントは無視できますが、型は無視できません。
これは「AI が守るべきルール」ではなく「AI が破れない壁」です。
読む前と読んだ後
Before(読む前)
- DDD の設計はある種の経験則で判断していました
- Rust の Option/Result は便利ですが、関数型との繋がりを考えていませんでした
- 型があると AI の精度が上がる「気がする」程度の理解でした
After(読んだ後)
- DDD の概念を型システムの言葉で説明できるようになりました
- Transformation-Oriented Programming(元のオブジェクトを変更せず、新しい値を作る)という原則を内在化しました
- 型を「人間のためのドキュメント」かつ「AI が破れない制約」として設計できるようになりました
Transformation-Oriented Programming
関数型プログラミングを学んで獲得する最も重要な概念は、実はシンプルです。
状態は例外的な存在であり、ほとんどの処理は状態を使うことなく記述できる。
本書を読み終えて、この一文の重みを改めて感じています。
私たちはプログラミングを学ぶとき、まず「変数に値を代入する」ことから始めます。x = 1。x = x + 1。状態を変更することが、プログラミングの基本だと教わります。
しかし、よく考えてみると、ビジネスロジックの大半は「入力を受け取り、変換し、出力を返す」で書けます。
- 注文明細から合計金額を計算する → 入力と出力だけ
- 住所をパースする → 入力と出力だけ
- 商品コードを検証する → 入力と出力だけ
状態の変更は不要です。副作用も不要です。ほとんどのビジネスロジックは、数学の関数のように書けます。
では、状態が必要になるのはいつでしょうか。データベースに保存するとき。外部 API を呼ぶとき。ファイルに書き込むとき。つまり、システムの境界を越えるときだけです。
この気づきが、設計の発想を変えます。状態を「デフォルト」ではなく「例外」として扱います。
しかし状態遷移は避けられない
ビジネスの世界は状態遷移で満ちています。注文は「未検証」から「検証済み」になります。カートは「空」から「商品あり」になります。申請は「提出済み」から「承認済み」になります。
これは無視できません。問題は、この状態遷移をどう表現するかです。
オブジェクト指向の答えは「オブジェクトが状態を持ち、メソッドが状態を変更する」でした。order.validate() を呼ぶと、order の内部状態が変わります。
この設計は、状態の追跡を難しくします。order は今どの状態なのか。どの経路を通ってここに至ったのか。フラグの組み合わせは正しいのか。常に頭の中で管理し続けなければなりません。
本書が示す答えは、Transformation-Oriented Programmingです。著者の言葉を借りれば、「ビジネスプロセスはデータを何らかの形で変換する—入力を受け取り、何かを行い、出力を返す」。
重要なのは、元のオブジェクトを変更しないことです。UnvalidatedOrder という型があります。validate という関数を適用すると、ValidatedOrder という新しい値が生まれます。このとき、元の UnvalidatedOrder には一切触れません。新しい値を作るだけです。
UnvalidatedOrder → validate → ValidatedOrder → price → PricedOrder
状態を「変更」するのではなく、「入力を受け取り、何かを行い、出力を返す」。元のオブジェクトには触れません。これが本書の核心です。
この発想の転換がもたらすもの
このアプローチを採用すると、いくつかの問題が消えます。
状態の追跡が不要になる。 ValidatedOrder を持っているなら、それは「検証済みの注文」です。フラグを見る必要がありません。型がすべてを語ります。
並行処理が安全になる。 元のデータを変更しないから、競合が起きません。
テストが簡単になる。 入力を与えて、出力を確認します。それだけです。
デバッグが楽になる。 各ステップの入出力をログに残せば、全経路が追えます。
そして何より、ビジネスの言葉とコードが一致する。「見積書」が「発注書」になります。Estimate が Order になります。ビジネスの人々が頭の中で考えているモデルが、そのままコードになります。
型は思考のツールである
本書を読む前、型システムは「コンパイラを満足させるためのもの」だと思っていました。IDE の補完が効きます。リファクタリングが安全になります。その程度の認識でした。
本書を読んで、型は「思考のツール」だと認識を改めました。
AND と OR という 2 つの組み合わせで、複雑なドメインを表現できます。struct は「これとこれが両方必要」、enum は「これかこれのどちらか」。この単純な組み合わせが、ドメインの構造を可視化します。
型を書くことは、ドメインを理解することです。型を読むことは、ドメインを学ぶことです。少なくとも、私はそう感じるようになりました。
型で「不可能」を作る
本書の内容を 2026 年の視点で読み直して、最大の発見がありました。
「Make Illegal States Unrepresentable(不正な状態を表現不可能にする)」—この原則は、人間の開発者のミスを防ぐためのものとして紹介されています。しかし 2026 年現在、同じ原則がAIの出力を自動検証するフィルタとして機能しています。
型で「不可能」を定義すると、AI が生成したコードのうち、その制約に違反するものはコンパイル時に除外されます。
NonEmptyList<OrderLine>と定義すれば、AI が空の注文を返すコードを書いてもコンパイルエラーで検出されるVerifiedEmailAddressを要求すれば、AI が未検証メールへの送信を実装してもコンパイルが通らないUnvalidatedOrder → ValidatedOrderという型シグネチャがあれば、AI が検証をスキップするコードはコンパイルエラーになる
これは「AI に正しいコードを書かせる」のではなく、「AI が書いた誤ったコードを検出する」メカニズムです。AI の精度向上ではなく、フィルタリング機構として機能します。
先日、Claude Code に「Order を作成する関数を書いて」と指示しました。生成されたコードは Vec<OrderLine> を返していました。しかし私のコードベースでは NonEmptyList<OrderLine> を使っています。コンパイルエラーが発生し、AI は「空の注文」を作るコードを出力しましたが、それが本番に混入することはありませんでした。一方、別のプロジェクトでは「このフィールドは必須」とコメントに書いただけでした。AI はそのコメントを無視して Option を返すコードを生成し、後から問題が発覚しました。
型で定義された制約は、コンパイル時に検証されます。コメントは検証されません。この違いが重要です。
「何を作るか」を決める能力
本書の第 1 章で、著者は「開発者の仕事はコードを書くことではなく、ソフトウェアを通じて問題を解決すること」と述べています。2018 年に書かれたこの言葉は、2026 年の今、さらに重みを増しています。
「何を作るか」という問いを分解してみます。
- ビジネス要件の理解: ドメインエキスパートとの対話、暗黙知の引き出し
- 技術的制約の把握: 既存システムとの整合性、パフォーマンス要件、チームのスキルセット
- 両者のトレードオフ判断: 「正解がない」状況での意思決定
現時点で AI が得意なのは 2 番目です。ドキュメントやコードベースを読み、技術的な制約を分析できます。一方、1 番目と 3 番目は人間の仕事です。ドメインエキスパートとの対話で暗黙知を引き出すこと、そして「どちらも正しい」状況でトレードオフを判断すること。これらは AI に委譲できません。
この構造を理解すれば、「人間の仕事」を「暗黙知の言語化」と「トレードオフ判断」に絞り込めます。本書で学んだドメインモデリングの技術は、まさにこの「暗黙知の言語化」を支援するものです。型で表現されたドメインモデルがあれば、AI は「どう作るか」を高い精度で実行できます。
働き方の逆転—AIエージェント時代の開発スタイル
本書を読みながら、自分の働き方が根本的に変わったことを実感しました。
以前のモデルでは、人間がコードを書き、AI は相談相手でした。Stack Overflow の代わり、ドキュメント検索の高速化。補助的な存在です。
現在のモデルでは、AI が運転席に座り、人間は助手席でナビゲーションをしています。AI にプランを練らせ、レビューし、実装させ、またレビューする。この流れが定着しました。
AI は「分身」的な存在になりました。明確な指示とコンテキストを与えれば、疲労知らずで作業してくれる相棒です。Claude Opus 4.5 以降、この感覚は決定的になりました。
プログラミングのシンタックスを書く機会は明らかに減りました。では、ソフトウェアエンジニアの役割はどう変化したのでしょうか。
1. アーキテクチャの指針決定
AI は「どう作るか」を実行できますが、「なぜそう作るか」は決められません。Bounded Context の境界をどこに引くか、技術選定のトレードオフ、パフォーマンスと保守性のバランス。これらは人間が判断します。
2. コードベースから読み取れないコンテキストの整理・提供
AI はコードに書かれていないことを知りません。なぜこの設計にしたか、本番環境でのみ発生する問題、チームの暗黙のコーディング規約、ビジネス上の制約。これらを言語化し、CLAUDE.md やコメントに落とし込む能力が求められます。
3. 期待する挙動を自動・継続的に検証する枠組みの整備
AI が書いたコードは「動く」かもしれませんが、「正しい」とは限りません。型による制約、プロパティベーステスト、E2E テスト、本番監視。これらの枠組みを整備し、AI の出力を検証し続けるのは人間の仕事です。
本書の「Make Illegal States Unrepresentable」は、まさにこの 3 番目の観点で価値を発揮します。型で制約を定義すれば、AI の出力を自動的に検証できます。コンパイルが通れば、少なくとも型レベルの正しさは保証されます。
コードを手で書くという作業は、実は思考の外在化プロセスでもありました。書きながら考えていた。この機会が減ったとき、思考の質をどう担保するか。正直、まだ答えが出ていません。本書のようなドメインモデリングの訓練はその答えの 1 つかもしれませんが、それで十分かどうかは分かりません。分からないまま、AI と協業し続けています。
Architecture Modernization との接続
本書を読みながら、Nick Tune 著『Architecture Modernization』の内容が何度も頭をよぎりました。現在、この本の翻訳に携わっています。
『Domain Modeling Made Functional』は「新規開発」の文脈で DDD を説明しています。しかし現実の多くのプロジェクトは「既存システムの改善」です。レガシーシステムをどう分析し、背景情報からBounded Context をどう切り出し、段階的にモダナイズしていくか。『Architecture Modernization』はまさにその部分を扱っています。
翻訳作業を通じて、共感できる内容が多くありました。特に「既存システムの暗黙知をどう言語化するか」という問題意識は、本書の「ドメインエキスパートとの対話」と通じるものがあります。
AI エージェント時代において、この問題はさらに重要になっています。AI は「新しいコードを書く」ことは得意ですが、「既存システムの文脈を理解する」ことは苦手です。10 年前の設計判断の背景、当時の技術的制約、組織の歴史。これらを言語化し、モダナイゼーションの方針を決めるのは、依然として人間の仕事です。
本書を読んで「関数型 DDD」に興味を持った方には、『Architecture Modernization』も勧めたいです。新規開発だけでなく、既存システムをどう改善するか。両方の視点を持つことで、設計の引き出しが増えます。
最後に
2018 年に出版された本書を、2026 年に読む価値はあったでしょうか。
かなり、Yes です。
ただ、正直なところ、本書の「すべて」を実践できる自信はありません。Smart Constructor を徹底すると言いながら、明日には String を直接使っているかもしれません。型で不可能を作るのは、思っているより面倒くさい作業です。締め切りに追われると、つい妥協してしまう。
それでも、本書を読んだことで「何かに気づいた」感覚はあります。うまく言葉にできませんが、型を書くときの解像度が変わった気がします。Option を見たとき、「本当にこれは省略可能なのか?」と問い直すようになりました。
冒頭で触れた『Architecture Modernization』の翻訳作業。本書を再読したことで、「Bounded Context」「Aggregate」といった用語を訳すとき、以前より自信を持てるようになりました。言葉の背後にある設計思想を、型という道具で理解したからです。翻訳は続いています。
この感覚が正しいのかどうかは、実務で検証していくしかありません。関数型 DDD は、特定の言語やパラダイムに縛られません。F#で書かれた本書の概念は、Rust でも実践できます。そして、人間と AI が協業する時代において、「不可能を型で定義する」技術の価値はますます高まっていく—たぶん。










