軽量DDDはF#と相性がいい——判別共用体で「不正な状態」を型から消す

軽量DDDの記事では、ドメインモデルとDBスキーマを分離し、値オブジェクトでバリデーションを1箇所に集約する設計を紹介しました。この設計はTypeScriptでも十分実践できますが、「本当にその境界を破れないか」という点では、規約とコードレビューに頼る部分が残ります。関数型言語であるF#は、同じ設計思想を型システムそのものに埋め込むための言語機能を標準で持っており、比較してみると軽量DDDが目指していたものの輪郭がより明確になります。

TypeScriptで残る「不正な状態」

前回の記事のOrderをもう一段複雑にし、出荷日時を持たせてみます。

// domain/order.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped';

export class Order {
  constructor(
    public readonly id: string,
    public readonly amount: number,
    public readonly status: OrderStatus,
    public readonly confirmedAt: Date | null,
    public readonly shippedAt: Date | null
  ) {}
}

status'draft'なのにshippedAtが入っている、status'shipped'なのにconfirmedAtnull——といった組み合わせは業務的にあり得ませんが、この型定義はそれを一切禁止していません。コンストラクタの引数を正しい順序・組み合わせで渡すことを、呼び出し側の注意力とコードレビューに委ねているのが実情です。

F#の判別共用体は「あり得ない組み合わせ」自体を作れなくする

同じドメインをF#の判別共用体(Discriminated Union)で表現すると、状態ごとに持てるデータが構文レベルで固定されます。

// Domain.fs
type Order =
    | Draft of amount: decimal
    | Confirmed of amount: decimal * confirmedAt: DateTime
    | Shipped of amount: decimal * confirmedAt: DateTime * shippedAt: DateTime

let confirm (now: DateTime) (order: Order) : Result<Order, string> =
    match order with
    | Draft amount -> Ok (Confirmed (amount, now))
    | Confirmed _ -> Error "already confirmed"
    | Shipped _ -> Error "cannot confirm a shipped order"

DraftケースはそもそもconfirmedAtというフィールドを持てないため、「draftなのに確定日時がある」という状態はコンパイラのレベルで表現不可能です。match式も、Draft / Confirmed / Shippedのいずれかを漏れなく処理しないとコンパイルエラーになるため、新しい状態(例えばCancelled)を追加した瞬間に、それを処理し忘れている箇所がすべてビルドエラーとして検出されます。TypeScriptのswitch文でも同様のチェックはnever型を使ったexhaustiveness checkで実現できますが、これは意識してその書き方を選んだ場合に限られ、言語のデフォルトの挙動ではありません。

Result型で「途中で例外を投げない」設計を自然に書ける

軽量DDDの記事ではconfirm()が不正な状態遷移で例外を投げる実装にしていましたが、F#では例外の代わりにResult型を返し、複数の操作をResult.bindでパイプラインとして合成するのが一般的です。

// Application.fs
let confirmAndShip (now: DateTime) (order: Order) : Result<Order, string> =
    order
    |> confirm now
    |> Result.bind (ship now)

let ship (now: DateTime) (order: Order) : Result<Order, string> =
    match order with
    | Confirmed (amount, confirmedAt) -> Ok (Shipped (amount, confirmedAt, now))
    | Draft _ -> Error "cannot ship an unconfirmed order"
    | Shipped _ -> Error "already shipped"

confirmErrorを返した時点でResult.bindは後続のshipを実行せず、エラーをそのまま素通りさせます。呼び出し側はtry/catchを書く必要がなく、失敗の伝播が関数合成の中に組み込まれています。これは「鉄道の分岐」に例えてRailway Oriented Programmingと呼ばれる、関数型言語で定番のエラーハンドリングパターンです。判別共用体でドメインの状態を表現し、Result型・Railway Oriented Programmingで失敗を扱うというここまでの内容は、Scott WlaschinによるF#・DDDの書籍『Domain Modeling Made Functional』が体系的にまとめているテーマそのものであり、より深く掘り下げたい場合の定番の参考書です。

値オブジェクトのバリデーションも「型で通れない道」にできる

軽量DDDの記事で紹介した「値オブジェクトによるバリデーションの集約」も、F#ではprivateなコンストラクタを持つモジュールとして、より強く強制できます。

// ValueObjects.fs
type Money = private Money of decimal

module Money =
    let create (amount: decimal) : Result<Money, string> =
        if amount < 0m then Error "amount must not be negative"
        else Ok (Money amount)

    let value (Money amount) = amount

Moneyのケースがprivateであるため、Money.createを経由せずにこの型の値を作る手段がモジュールの外に存在しません。TypeScriptでも「privateコンストラクタ+static create」という同じ形のパターンは書けますが、それでも呼び出し側が値のバリデーション結果(成功か失敗か)を握りつぶさずに扱うことを保証するのは、Result型と組み合わせて初めて成立します。F#ではこの2つ(不正な値を作れない・失敗を握りつぶせない)が言語の標準機能として揃っています。

それでも言語を乗り換える話ではない

ここまで見た通り、軽量DDDが目指す「ビジネスルールをUIやDBのコードに漏れ出させない」という目的そのものは、F#の言語機能——判別共用体・網羅性チェック・Result型・非nullがデフォルト——と非常によく噛み合います。ただし、Cloudflare Workersのようなエッジランタイムを前提にしたスタックでは.NETランタイムをそのまま動かすことは現実的ではなく、これは「F#に乗り換えるべき」という話ではありません。実務的な着地点は、F#的な考え方をTypeScriptに輸入することです。具体的には、statusと紐づくデータをオプショナルなフィールドの寄せ集めではなくタグ付きユニオン型で表現する、switch文には必ずneverを使ったexhaustiveness checkを添える、例外の代わりにneverthrowのようなライブラリでResult型を扱う——といった形で、型システムに任せられる部分を少しずつ増やしていくことができます。軽量DDDは特定の言語に縛られた設計手法ではなく、「ルールを守らせる責任を、人の注意力からどれだけ型に移譲できるか」という問いそのものだと捉えると、この比較から得られるものは実装言語を問わず応用が効きます。