Functional Core, Imperative Shell

アプリを純粋な決定ロジック(functional core)と、副作用を担う薄い殻(imperative shell)に分ける設計。Gary Bernhardt 提唱。コアは入力→出力の純関数、殻が IO・DB・ネットワークを集約してコアを呼ぶ。

構造

Functional CoreImperative Shell
中身ドメインの決定(decide)IO の実行(perform)
純粋性純粋(参照透過)副作用を持つ
テスト値 in → 値 out で単体テスト容易(モック不要)薄く保ち統合テストだけ
量ロジックの大半できるだけ薄く

殻が外界から値を集め → コアが次にすべきことを値として返し → 殻がそれを実行する、という往復。

殻 ↔ コアの往復(擬似コード)

肝は コアが「次にすべき副作用」を実行せず、データとして返すこと。判断(decide)と実行(perform)を物理的に分ける:

# ---- functional core: 純粋。IO を一切しない ----
def decide(order, current_stock):
    """入力(値) -> 出力(値)。同じ入力なら必ず同じ出力。"""
    if order.qty > current_stock:
        return Reject(reason="out_of_stock")          # 副作用ではなく「指示」を値で返す
    new_stock = current_stock - order.qty
    return Accept(new_stock=new_stock,
                  email=Email(to=order.user, body="confirmed"))

# ---- imperative shell: 薄い。集める→コアを呼ぶ→返り値を実行する ----
def handle_order(order_id):
    order  = db.load_order(order_id)        # 1. 外界から値を集める(IO)
    stock  = db.load_stock(order.sku)       #    (ここまでは決定しない)

    result = decide(order, stock)           # 2. 純粋コアに判断を委ねる

    match result:                           # 3. 返ってきた「指示」を実行する(IO)
        case Accept(new_stock, email):
            db.save_stock(order.sku, new_stock)
            mailer.send(email)
        case Reject(reason):
            log.warn(reason)

decide には DB も SMTP も登場しない。分岐(在庫切れ / 受理)の全パターンは decide を値で叩くだけで網羅でき、handle_order は「集める・実行する」だけの直線的な配管に痩せる。

テストの比率

設計が直接テスト戦略に効く。ロジックの大半を占めるコアは速い単体テストで網羅し、薄い殻は少数の結合テストで配管だけ確認する:

対象テスト種別量速度
Functional Core分岐・計算・ドメイン規則単体(値 in → 値 out、モック不要)多い・網羅的速い(ms 以下)
Imperative ShellDB/IO/ネットワークの配線結合(実 DB やコンテナ)少数・代表ケースのみ遅い

テストピラミッドが自然に成立する: 多数の速い単体テストが土台、少数の遅い結合テストが頂点。「コアに押し込めたロジック量」がそのまま「モックなしで網羅できる範囲」になり、モックだらけの脆いテストを避けられる。

なぜ効くか

類縁

Hexagonal / Ports & Adapters、Elm Architecture、effect system(殻でハンドラが作用を解釈)。トレードオフは「本質的に IO に絡むロジックをどこまでコアに押し込めるか」の線引き。

押さえどころ(カード化候補)

関連

Graph