Decision パターンは言語を問わず判断の中断を型にする

Decision パターンは、判断の途中で必要になった外部データを取得せず、「このデータがあれば判断を続けられる」という中断状態を返す。 呼び出し側は状態に対応するデータだけを取得し、結果をドメインへ戻す。 そのため、ドメイン判断の純粋性、判断の集約、不要な取得の回避を同時に保てる。

この発想の価値は Go に固有ではない。 言語によって変わるのは、有限個の中断状態とその網羅的な処理をどれだけ直接表現できるかである。

中断の処理は二つに分かれる

「中断を処理する部分もドメインに置く」という説明では、二種類の処理を区別する必要がある。 取得した事実を受け取り、許可、拒否、次の中断のどれへ進むかを判断する遷移はドメインに置く。 一方、中断状態を読み取ってデータベースや外部 API を呼び、得た値を遷移へ渡す解釈処理はドメインの外に置く。

ここで不足しているのは「ドメインの外の情報」とは限らない。 フォロー関係や購入履歴も業務上の事実だが、現在の純粋な入力だけには載っておらず、取得に副作用が要る。 Decision パターンは、その事実の意味と取得手段を分離する。

この分離により、値を見て許可や拒否を決める条件分岐はドメインに集められる。 ただし外側には、中断状態と取得処理の対応関係が残るため、業務の進行手順が完全に隠蔽されるわけではない。 新しい中断状態を追加すれば、呼び出し側の対応も追加しなければならない。

この残存を純粋性の喪失と呼ぶかは、純粋性の定義による。 参照透過性という意味では、ドメインの関数は値だけを受け取って値だけを返すので純粋なままである。 一方、ドメインモデルの自己完結性という意味では、呼び出し側が ViewNeedFollowing を IsFollowing で満たすと知っており、モデルの外に解釈処理が残る。 ただし「フォロー中なら許可する」という結果の規則と、「フォロー状態が必要である」という要求はドメイン側にあり、呼び出し側にあるのはその事実をどの取得手段で得るかという対応関係である。 したがって Decision パターンはドメイン知識を完全に隠蔽するものではなく、業務上の規則と副作用を伴う取得の接続知識との境界を明示するものと捉えるのが正確である。

https://zenn.dev/tellernovel_inc/articles/0193eb68cabb6e

Decision パターンという名前の位置づけ

2026年9月7日時点では、記事と作者の go-decision-pattern リポジトリ以外に、同じ意味で「Decision パターン」という固有名を使う独立した解説や実装は検索で確認できなかった。 Go パッケージ索引にも公開版は登録されているが、既知の利用元は0件と表示されている。 したがって、広く定着したパターン名ではなく、作者が社内規約と検査器へ付けた名前と捉えるのが妥当である。

https://github.com/tomoemon/go-decision-pattern

https://pkg.go.dev/github.com/tomoemon/go-decision-pattern

ただし、構造に先行例がないわけではない。 Mark Seemann が2017年に説明した Pure interactions は、連続する副作用をドメイン固有の命令を持つ純粋な構文木として表し、不純な解釈器で実行する。 Decision パターンの中断状態はこの「命令」に、呼び出し側の for-switch は「解釈器」に対応する。

https://blog.ploeh.dk/2017/07/10/pure-interactions/

I/O を含まない状態機械と外側の入出力処理を分ける sans-IO も近縁である。 ただし sans-IO は主に通信プロトコルを対象とし、業務判断の完全性や DDD トリレンマを直接扱うものではない。

https://github.com/brettcannon/sans-io/blob/main/how-to-sans-io.rst

Functional Core, Imperative Shell は、純粋な判断と副作用を分ける上位の設計原則に当たる。 通常の一往復では外側が入力を全て集めてから内側を呼ぶのに対し、Decision パターンは判断の途中で追加取得が必要になる場合を、再開可能な状態として具体化している。

一方、イベントソーシングの Decider パターンは名前が似ていても別物である。 Decider はコマンドと現在状態からイベントを決め、イベントから次の状態を作る構成であり、途中で必要な外部データを要求することを中心にはしていない。

https://docs.eventsourcingdb.io/blog/2026/03/19/decide-evolve-repeat/

TypeScript では判別共用体にする

TypeScript では、状態を判別共用体としてそのまま表せる。 各状態が必要な識別子だけを持ち、遷移関数は新しい状態を返す。

type ViewDecision =
  | { kind: "allowed" }
  | { kind: "denied"; reason: DenyReason }
  | { kind: "needFollowing"; viewerId: UserId; authorId: UserId }
  | { kind: "needVip"; viewerId: UserId; storyId: StoryId };

async function interpret(decision: ViewDecision): Promise<boolean> {
  while (true) {
    switch (decision.kind) {
      case "allowed":
        return true;
      case "denied":
        return false;
      case "needFollowing":
        decision = decideFollowing(await repository.isFollowing(decision));
        break;
      case "needVip":
        decision = decideVip(await repository.isVip(decision));
        break;
      default:
        decision satisfies never;
    }
  }
}

Go の非公開メソッド付き interface と専用検査器に相当する部分を、判別共用体と never 型による網羅性検査で書ける。 ただし TypeScript の型は実行時には消えるので、API やデータベースから来た値を ViewDecision として扱う境界では実行時検証が別に要る。

https://www.typescriptlang.org/docs/handbook/2/narrowing

Kotlin では sealed interface にする

Kotlin では sealed interface と data class または data object の組み合わせが素直に対応する。 when は全ての直接的な実装型を処理したかコンパイラが検査するため、状態を増やすと解釈側の修正漏れがコンパイルエラーになる。

sealed interface ViewDecision {
    data object Allowed : ViewDecision
    data class Denied(val reason: DenyReason) : ViewDecision
    data class NeedFollowing(val viewerId: UserId, val authorId: UserId) : ViewDecision
    data class NeedVip(val viewerId: UserId, val storyId: StoryId) : ViewDecision
}

suspend fun interpret(initial: ViewDecision): Boolean {
    var decision = initial
    while (true) {
        decision = when (val current = decision) {
            ViewDecision.Allowed -> return true
            is ViewDecision.Denied -> return false
            is ViewDecision.NeedFollowing ->
                decideFollowing(repository.isFollowing(current.viewerId, current.authorId))
            is ViewDecision.NeedVip ->
                decideVip(repository.isVip(current.viewerId))
        }
    }
}

suspend は取得側だけに置けるので、ドメインの遷移関数を通常の純粋関数として保てる。 ただし sealed は継承可能な場所を制限して閉じた集合を作る仕組みであり、副作用を禁止する仕組みではない。

https://kotlinlang.org/docs/sealed-classes.html

Rust では enum と match にする

Rust ではデータを持つ enum が直和型そのものであり、match は原則として網羅的でなければコンパイルできない。 Go 版で必要だった命名規約、非公開メソッド、外部の網羅性検査を言語機能に畳み込める。

enum ViewDecision {
    Allowed,
    Denied(DenyReason),
    NeedFollowing { viewer_id: UserId, author_id: UserId },
    NeedVip { viewer_id: UserId, story_id: StoryId },
}

一方で、状態遷移のたびに事実を値として運ぶ設計は所有権の影響を受ける。 識別子を小さなコピー可能な値にするか、共有データを参照カウント付きの所有権で持つかという判断が、Go や Kotlin より表面に出やすい。

https://doc.rust-lang.org/stable/core/keyword.match.html

関数型言語では問い合わせ命令として一般化できる

Haskell や PureScript のような言語では、中断状態を個別の業務判断だけに閉じず、「フォロー関係を問い合わせる」「会員状態を問い合わせる」といった命令の代数として定義し、解釈器へ渡す設計にも発展させられる。 これは自由モナドや効果システムに近い形であり、取得方法を差し替えやすい反面、単一の判断には抽象度が高すぎることもある。

https://hackage.haskell.org/package/free/docs/Control-Monad-Free.html

Decision パターンは、この一般化を業務ごとの有限状態機械まで具体化したものと捉えられる。 Go では直和型の不足を規約と静的解析で補い、TypeScript、Kotlin、Rust では組み込みの型機能で同じ境界を短く書ける。 どの言語でも中心にあるのは、「何を取得するか」という判断をドメインに残し、「実際に取得する」という副作用だけを外へ出す分離である。

型だけでは解決しない問題

網羅性の検査は、全ての中断状態に処理があることを確かめるだけで、遷移が必ず終了することまでは保証しない。 遷移の誤りによる循環や、取得の再試行には別の上限が要る。

複数回の取得が同じ時点の事実を見ていることも保証されない。 判断中にフォロー関係や購入状態が変わり得るなら、トランザクション、読み取り時点、版番号のどれを使って整合性を定義するかを別に決める必要がある。

単件では不要な取得を避けられても、一覧処理で状態機械を一件ずつ動かすと N+1 問題を起こし得る。 一括処理では、同種の中断を集めてまとめて取得できる解釈器か、判定専用の読み取りモデルのほうが適する場合がある。

したがって、このパターンが合うのは、取得の要否がそれ以前の業務判断で変わり、判断の段数と再利用箇所が型の増加を正当化する場面である。 分岐が短い場合や必要な事実を安価に一括取得できる場合は、先に取得して純粋関数へ渡すほうが小さく保てる。

#domain-driven-design #sum-type #state-machine #typescript #kotlin #rust