前置き
- Domain Modeling Made Functional 的手法というのか、fDDD的なやつのおすすめ記事です
# order-workflow
Order -> validateOrder() -> CorrectOrder -> payment() -> PiadOrder
- ↑のように、↓の両者をシンプルに同期します
ビジネスプロセス(つまりドメイン): in-process-outの連鎖
↕︎
プログラム: in-process-outの連鎖(と素直に捉えることも出来る) - 例としての注文ワークフロー ⇄ それをpythonにエンコードしたもの
の2つの世界を行き来しつつ説明していけたらと思います - ゲーム系や数学的な問題等ではなく、webサービス/SaaSバックエンド、業務システムをドメインと想定した場合の考察になります(つまりビジネスプロセスをドメインと考えて良いだろうケース)
- 間口が広そうだし結構いける(@dataclass/パターンマッチ)のでpythonでやってみます
- サンプルコードの環境: python3.12(説明にはあまり関係ないがFastAPIで構築)
- 型チェッカ: Pyright(VSCode拡張のPylanceを使用)
ドメイン ⇄ コードの全体観
題材: 商品注文受付ワークフロー
複数のタスクからなる、シンプルなワークフローを題材にします。
コード側
現実世界側
現実の働き(プロセス: In-Process-Outであるタスクの連鎖)に、コードの設計(In-Process-Outである関数の連鎖)がそのまま対応します。
データ切りではなくプロセス切りのモデルを素直に採用することで、現実⇄コード間の翻訳にあたり、実在する「働き」をそのままエンコードすれば良いというシンプルさが得られると感じます。
ビジネスプロセス/業務フロー/ユーザストーリーという字面の通り、「動き」カットで捉えるとモデルのねじれが生じません。
しかし、そのような素朴なプロセス指向には問題があったためそれを超える種々の技法が発展してきたものとも理解します。
記事終盤では、当時との前提の変化(周辺技術の発展)も考慮しつつ、その問題に逆戻りしてしまわないかという点を考えてみようと思います。
ズームアップ
以下の順番で具体的な要素を探訪していきましょう。
- workflow層: ピュアでコアなドメインロジック
1.1 Order業務そのものの関数
1.3 登場してくるデータの定義
1.2 各タスクである関数 - integration層:
workflow層から押し付けられた、不純なもの(システム都合の処理/副作用)を吸収。手続的で、現実都合とピュアなドメインロジックを統合する - 実装Tips:
最後にまとめて、登場した実装Tipsに触れます
サンプルコードのworkflow層も、厳密な意味では完全に純粋関数で構成されていないと思います(IO回りとか)。
目的である旨み(テスト容易性等)に、複雑さが勝らないと思える範囲で関数型手法を取り入れていきます。
workflow層: ピュアでコアなドメインロジック
Order業務そのものの関数
おおよそ、
- 注文内容のチェック -> 価格の計算 -> 配送日の決定
という流れでそれぞれのタスクの担当者が作業を行います。
担当者は、inputとしての書類を受け取り・作業し・結果を次工程に渡します。
また、以下のような具体的な業務マニュアルが存在したとします。
(都度、必要な部分を再掲していくので、ここで全て読んでいただく必要はありません。)
Order業務マニュアル
#### 商品受注業務マニュアル全文1. 業務の全体像
1.1 「商品受注」全体のinputと成果物
- input: フロント業務より受け取った書類「注文書」
- 成果物: 発送票付き請求書 または 不備報告書
1.2 全体の動きの流れ
初期注文受付
-> 注文内容のチェック
-> 価格の計算
-> 配送日の決定
2. 書類とその項目
2.1 初期注文書
- 記載項目: 商品ID、数量、注文者都道府県、注文者市町村区以下住所
2.2 確認済み注文書
- 記載項目: 商品ID、数量、配送先住所
2.3 請求書
- 記載項目: 商品ID、数量、合計価格、配送先住所
2.4 発送票付き請求書
- 記載項目: 合計価格、配送先住所、到着予定日
2.5 エラー報告書
- 記載項目: エラーコード、エラーメッセージ
3. 各タスクの手順
3.1 初期注文受付
- フロントから注文書を受け取り、この書類を封筒に入れ、後続の係に渡す
3.2 注文内容のチェック
- input: 注文書
- 成果物: 検証済み注文書
- 仕事: 封筒から書類を取り出し、住所の有効性と数量の妥当性をチェック。不備がある場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
3.3 価格の計算
- input: 検証済み注文書
- 成果物: 請求書
- 仕事: 封筒から書類を取り出し、商品情報に基づき価格を決定し、請求書を作成。商品情報が不足している場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
3.4 配送日の決定
- input: 請求書
- 成果物: 発送票付き請求書
- 仕事: 封筒から書類を取り出し、配送先に基づき配送日数を計算し、発送票付き請求書 に記載。配送不可能な地域の場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
早速、Order業務そのものをエンコーディングしましょう。
process_order()関数全体
""" workflow entry point """
type ProcessOrderResult = Result[ShippedInvoice, OrderError]
def process_order(
address_checker: AddressCheckerProtocol,
product_catalog: CatalogCheckerProtocol,
estimate_delivery_days: DeliveryDaysEstimatorProtocol,
) -> Callable[[UnverifiedOrder], ProcessOrderResult]:
# ↑は依存性を待ち受けるための部分
# まずは、↓の部分に着目してください!
def _process_order_core(
order: UnverifiedOrder
) -> ProcessOrderResult:
return (
From(order)
.bind(review_order(address_checker))
.bind(calculate_price(product_catalog))
.bind(determine_arrival_date(estimate_delivery_days))
)
return _process_order_core
マニュアル
1.1 「商品受注」全体のinputと成果物
- input: フロント業務より受け取った書類「注文書」
- 成果物: 発送票付き請求書 または 不備報告書
補足ですが、ワークフローの途中で不備があった場合は、「不備報告書」に記載し、それを後続タスクに渡す流れなっているとします。
type ProcessOrderResult = Result[ShippedInvoice, OrderError]
def _process_order_core(
order: UnverifiedOrder
) -> ProcessOrderResult:
Result[]について
pythonの組み込みではありませんので、後ほど実装Tipsでの紹介とさせてください。
現時点、happy pathのoutput または error pathのoutputを表すとご理解ください。
こちらの全体関数のシグネチャにて、現実がエンコードされています。
order業務は、inputが"注文書"でoutputが"発送票付き請求書"です。
およそ仕事・タスクというものにはinputと成果物がありますよね。
マニュアル
1.2 全体の動きの流れ
初期注文受付
-> 注文内容のチェック
-> 価格の計算
-> 配送日の決定
コード
From(order)
.bind(review_order(address_checker))
.bind(calculate_price(product_catalog))
.bind(determine_arrival_date(estimate_delivery_days))
bind()について
UNIXのパイプのように、関数のoutを次の関数のinに繋ぐ単純なものです。Resultに生やす形にしているので、同様に実装Tipsで触れます。
こちらもそのままです。
全体の流れ -> 個別の手順という説明の順番は王道ですね。
仕事に登場してくるデータの定義
2.書類とその項目
2.1 初期注文書
- 記載項目: 商品ID、数量、注文者都道府県、注文者市町村区以下住所
2.2 確認済み注文書
- 記載項目: 商品ID、数量、配送先住所
2.3 請求書
- 記載項目: 商品ID、数量、合計価格、配送先住所
2.4 発送票付き請求書
- 記載項目: 合計価格、配送先住所、到着予定日
2.5 エラー報告書
- 記載項目: エラーコード、エラーメッセージ
""" state transition in workflow """
@dataclass(frozen=True)
class UnverifiedOrder(OrderInProtocol):
item_id: str
quantity: int
delivery_method: DeliveryMethod
shipping_to: CustomerAddress | ConvenienceStore
@dataclass(frozen=True)
class VerifiedOrder:
item_id: str
quantity: Quantity
shipping_to: CustomerAddress | ConvenienceStore
@dataclass(frozen=True)
class Invoice:
item_id: str
quantity: Quantity
total_price: Decimal
shipping_to: CustomerAddress | ConvenienceStore
""" resulting event """
@dataclass(frozen=True)
class ShippedInvoice(OrderOutProtocol):
bill_amount: Decimal
shipping_to: CustomerAddress | ConvenienceStore
arrival_date: datetime
# error path
@dataclass(frozen=True)
class InvalidOrder(OrderErrorProtocol):
code: Literal["InvalidAddress", "InvalidStoreCode", "InvalidQuantity"]
message: str
@dataclass(frozen=True)
class OutOfStock(OrderErrorProtocol):
code: Literal ["ItemNotFound"]
message: str
@dataclass(frozen=True)
class Undeliverable(OrderErrorProtocol):
code: Literal ["NonDeliverableArea"]
message: str
type OrderError = InvalidOrder | OutOfStock | Undeliverable
具体的なエラー種類など、マニュアル文書には記載のなかった知識をヒアリングの上コードに反映することも多いでしょう。
ワークフローを構成する個別タスクの関数
長くなるので1つだけピックアップします。
type CalcPriceResult = Result[Invoice, OutOfStock]
def calculate_price(
product_catalog: CatalogCheckerProtocol
) -> Callable[[VerifiedOrder], CalcPriceResult]:
def _calculate_price_core(order: VerifiedOrder) -> CalcPriceResult:
try:
item_price = product_catalog(order.item_id)
except KeyError:
return Err(
OutOfStock(
code="ItemNotFound",
message=f"The item_id {order.item_id} is not found in the product catalog.")
)
return Ok(
Invoice(
item_id=order.item_id,
quantity=order.quantity,
shipping_to=order.shipping_to,
total_price=int(order.quantity) * item_price,
)
)
return _calculate_price_core
まず、関数のシグネチャで
3.3 価格の計算
- input: 検証済み注文書
- 成果物: 請求書
の事実を写し、関数の中身はもちろん実際の仕事を写します。
- 仕事: 封筒から書類を取り出し、商品情報に基づき価格を決定し、請求書を作成。商品情報が不足している場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
補足ですが、
def calculate_price(
product_catalog: CatalogCheckerProtocol
) -> Callable[[VerifiedOrder], CalcPriceResult]:
この部分で依存性を待ち受けています。(データアクセス。これも後ほど実装Tipsで取り上げます。)
現実のタスク遂行を考えても、
- メインのInputである注文内容に加えて、隠れたinput(というか依存性というか)である"商品カタログ"を受け取り、使用する必要がある
という事実がそのままコードに反映されています。
「マニュアル」との一致は、正直コードを書いてから、自然言語の文章の方を寄せたからというのはあります。
しかし、前述の「Order業務マニュアル」もコードからリバースしたにしては、わりにマニュアル然とした、ありがちな構成になっているのではないでしょうか?
- 全体の流れ/概要/総論を書き、
- フローの中で登場してくる各データ(この場合書類)を示し、
- 具体的なステップ/各論 を書いていく
という構成は、コードでも自然言語でもベーシックであり理解し易い構造と思います。
integration層: 現実都合とピュアなドメインロジックを統合
今回の趣旨的にあまり詳細には取り上げません。
コード
from datetime import datetime
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Body, HTTPException
from pydantic import field_validator
from pydantic.dataclasses import dataclass
from data_access.index import product_catalog, existence_check_japanese_address, lookup_delivery_days_pack
from workflows.order_workflow import process_order
from common.protocol.order_protocol import OrderInProtocol, OrderOutProtocol, OrderErrorProtocol
from common.models.order import ConvenienceStore, CustomerAddress, DeliveryMethod
from common.util.result import Err, Ok
from common.serializer.order import order_response_to_json
from common.client_api.pub_event import send_event
from docs.order_examle import home_delivery_example, convenience_store_delivery_example
# 外部通信(http、DB書き込み、他のサービスへのイベント通知/キューイングなど)レイヤ。
# 非純粋な領域。このレイヤーではFW依存機能や例外をガンガン使用する
router = APIRouter()
@dataclass(frozen=True)
class OrderRequest(OrderInProtocol):
item_id: str
quantity: int
delivery_method: DeliveryMethod
shipping_to: CustomerAddress | ConvenienceStore
@field_validator("item_id", mode='before')
def validate_item_id(cls, value: str) -> str:
if len(value) != 10:
raise ValueError("item_idは10桁でなければなりません。")
if not value[:3].isalpha():
raise ValueError("item_idの最初の3文字はアルファベットでなければなりません。")
return value
@field_validator("quantity", mode='before')
def quantity_must_be_positive(cls, value: int) -> int:
if value <= 0:
raise ValueError("数量は1以上でなければなりません。")
return value
@dataclass(frozen=True)
class OrderResponse:
bill_amount: Decimal
arrival_date: datetime
shipping_to: CustomerAddress | ConvenienceStore
@router.post("", operation_id="create_order", response_model=OrderResponse)
async def create_order(
order: Annotated[
OrderRequest,
Body(
openapi_examples={
"home_delivery": home_delivery_example,
"convenience_store_delivery": convenience_store_delivery_example,
}
),
]
) -> OrderResponse:
order_workflow = process_order(
existence_check_japanese_address,
product_catalog,
lookup_delivery_days_pack,
)
match order_workflow(order):
case Ok(o):
ordered_event = OrderResponse(
bill_amount=o.bill_amount,
arrival_date=o.arrival_date,
shipping_to=o.shipping_to,
)
send_event(order_response_to_json(ordered_event))
return ordered_event
case Err(e):
raise HTTPException(status_code=400, detail=e.message)
case _:
raise HTTPException(status_code=500, detail="unexpected error in http layer")
以下のようなことを吸収する層です。
- インフラ都合のこと
- FW都合の処理
- http都合の処理
- 非純粋な外界との通信手続き
- データアクセスのDI
- ワークフローの結果イベントの永続化の処理(典型的な副作用)
例えば、
特定のRBDに結びついたMVCフレームワークを使用している場合などは、結びついたデータストアの読み書きはworkflowに書いても良い、などの判断もあり得ると思います。
があると思います。
ツール支援により、上記のケースはテストのコストがあまり増加しないので、
- 引数としての依存性の肥大化 ⇄ テスト容易性
のトレードオフの考え方が変わってくると思います。
実装Tips
いくつか紹介していきます。
パターン | 関心事 | DMMF(F#)では? |
---|---|---|
result型 | ・逐次処理 ・エラーハンドリング |
・パイプ ・Result型 |
ADT(代数的データ型) | ・データとその関連の表現 ・ドメインルールの表現(値の制約) |
Discriminated Union |
ADTのパターンマッチ | データによる振る舞いの多態 | パターンマッチ |
Smart Constructor | ランタイムにしか出来ない、値のルール表現 | Smart Constructor |
部分適用 | 依存性のDI | 部分適用 |
Result型
Result型(あるいはResultモナド)は、以下の2つの部分のコードを共通化した、ユーティリティ的なコードです。
- エラーハンドリング構造
- happy pathとerror pathの条件分岐の共通構造の括り出し
- 逐次処理の構造(パイプライン的構造)
- 前の関数のリターンを引数に、次の関数を実行していくような共通構造の括り出し
こちらについても、
- ドメイン(現実の事象) ⇄ コード
という2面から説明していきます。
コード(Resultモナド)の概念を説明するための喩えとして、サンプルストーリーを語るという試みにもなっています。
ドメイン面
Order業務について、流れを復習しましょう。
それぞれのタスクはそれぞれ担当者が分かれており、書類を後続に渡すことで流れ作業を行なっています。
書類は、封筒に包んでやりとりします。
ここで、タスクの中で不備を発見したら"不備通知書"に不備内容を記載して後続に渡すというルールを思い出してください。
つまり、封筒の中身は必ず"通常ケースの書類" or "不備通知書"の常にいずれか一方が入っています。
次に、もう一度"Order業務マニュアル"を見ます。
ここでは、Don't Repeat Yourselfの視点を持ってみてみることにしましょう。
マニュアル > 3. 各タスクの手順
3. 各タスクの手順
3.1 初期注文受付
- フロントから注文書を受け取り、この書類を封筒に入れ、後続の係に渡す
3.2 注文内容のチェック
- input: 注文書
- 成果物: 検証済み注文書
- 仕事: 封筒から書類を取り出し、住所の有効性と数量の妥当性をチェック。不備がある場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
3.3 価格の計算
- input: 検証済み注文書
- 成果物: 請求書
- 仕事: 封筒から書類を取り出し、商品情報に基づき価格を決定し、請求書を作成。商品情報が不足している場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
3.4 配送日の決定
- input: 請求書
- 成果物: 発送票付き請求書
- 仕事: 封筒から書類を取り出し、配送先に基づき配送日数を計算し、発送票付き請求書 に記載。配送不可能な地域の場合は不備報告書を作成。書類を封筒に入れ、後続の係に渡す
明らかなDRYが2点存在します
- 緑字: パイプライン的逐次処理の構造のDRY -> モナド
- 具体的なタスクの前に、封筒(なんらかの文脈、構造)から書類(値)を取り出す働き
- 具体的なタスクの後に、また封筒(なんらかの文脈、構造)に書類(値)入れる働き
- 前後にその動き(封筒出し入れ)を差し込みつつ、タスクを逐次的に処理していく働き
このような操作の共通化が、いわゆる「モナド」というもののイメージとしての一つの捉え方と思っています。
用語のアカデミックな、厳密な定義を気にされる方はこちらを
ご確認ください。
2.赤字: エラーハンドリングの構造のDRY -> Result
- 「タスクの中で不備があれば不備報告書を、そうでなければ通常の書類を封筒に入れる働き」
- 言い換えると「封筒の中身は正常時の書類or失敗時の書類が入っているという文脈」
成功or失敗がシュレディンガーの猫的に2択であるという文脈の表現になりますね。
コード面
この2点の横断的関心事を共通化したコード(Result + モナドでResultモナド)が以下です。
モナドではなくて、それを模倣したようなもの、という表現が正確かとは思います。
また、実運用向けはなく、説明用の実装になります。
result.py 全体
from dataclasses import dataclass
from typing import Any, Callable, TypeVar
T = TypeVar('T')
E = TypeVar('E')
U = TypeVar('U')
@dataclass(frozen=True)
class Ok[T]:
value: T
def bind(self, op: Callable[[T], 'Result[U, Any]']) -> 'Result[U, Any]':
return op(self.value)
def or_else(self, op: Callable[[Any], 'Result[T, E]']) -> 'Result[T, E]':
return self
@dataclass(frozen=True)
class Err[E]:
error: E
def bind(self, op: Callable[[Any], 'Result[Any, E]']) -> 'Result[Any, E]':
return self
def or_else[F](self, op: Callable[[Any], 'Err[F]']) -> 'Err[F]':
return op(self.error)
type Result[T, E] = Ok[T] | Err[E]
def From(value: T) -> Result[T, Any]:
return Ok(value)
いくつか要素がありますが、Result
が本体で、しかしその実体は、Ok
またはErr
いずれかである。そして、その両者がbind()
を持つという構成です。
type Result[T, E] = Ok[T] | Err[E]
封筒の中身は正常時の書類or失敗時の書類が入っているという文脈
bind()メソッドは、Result型を戻り値とする関数を受けとり、その関数に自身の値を適用します。
def bind(self, op: Callable[[T], 'Result[U, Any]']) -> 'Result[U, Any]':
return op(self.value)
封筒から書類を取り出し、(コード上は引数として受け取った)処理をし、また封筒に入れ次の処理に繋ぐ働き
また、Resultの中身がErr
だった場合(Err
に生えてる方のbind()
)は、何もせずスルーします。
タスクの各係は、封筒の中身が不備通知書であったら、なにもせずそっと封筒に戻して後続の係に渡す
def bind(self, op: Callable[[Any], 'Result[Any, E]']) -> 'Result[Any, E]':
return self
個人的には、エラーは以下の使い分けを指針と考えます。
- Result: ドメインモデルとしてのエラー。想定内のあり得る状態
- 例外: なんらかの、想定外の言葉通り例外を考慮するとき
エラーを例外ではなくResultで表現すると、関数のシグネチャで失敗するということ、どのような失敗を返すかがおおまかにわかり親切ですね。
この便利機構を使用することで、
reviewed = review_order(address_checker)
if reviewed is None:
raise ValueError("reviewed is None")
else:
calculated = calculate_price(product_catalog)
if calculated is None:
raise ValueError("calculated is None")
else:
invoice = determine_arrival_date(estimate_delivery_days)
↑こんなようなことは書かなくてよい、
From(order)
.bind(review_order(address_checker))
.bind(calculate_price(product_catalog))
.bind(determine_arrival_date(estimate_delivery_days))
ボイラープレートにまみれたガタガタな記述ではなく、本質的なことを宣言的に表現出来る旨みがあります。
ADT(代数的データ型)とパターンマッチで多態
これもドメイン->コードと説明していきます。
ドメイン
実は、このような要件がありました。
エンドユーザは、商品の配送方法として 1.自宅受け取り と 2.コンビニ受け取り を選択できる。
自宅受け取りの場合は、情報として 顧客住所 が必要であり、コンビニ受け取りの場合は コンビニチェーン名と店舗コードが必要である。
コード
そして、すでに実装していました。
@dataclass(frozen=True)
class UnverifiedOrder: # 検証前の注文書を例に
item_id: str
quantity: int
shipping_to: CustomerAddress | ConvenienceStore # <-特にここの部分
type Franchisors = Literal["SevenEleven", "FamilyMart", "Lawson"]
@dataclass(frozen=True)
class CustomerAddress:
prefecture: str
detail: str
@dataclass(frozen=True)
class ConvenienceStore:
company: Franchisors
store_code: str
ユニオンタイプにより、「そのまま」要件をエンコーディングしていると感じます。
また例えば、送付先が自宅かコンビニかによって、具体的手続きが異なるタスクがあります。
match order.shipping_to: # 住所実在チェックというサブタスク
case CustomerAddress(prefecture=pref, detail=det):
if not check_address_existence(pref, det): # 自宅の場合は普通に住所チェック
return Err(
InvalidOrder(code="InvalidAddress",message="The provided address is invalid.")
)
case ConvenienceStore(company=_, store_code=code):
if code == "": # 今回はダミーだが実際はAPI叩いて店舗コードの実在チェックなど
return Err(
InvalidOrder(code="InvalidStoreCode",message="The provided store code is invalid.")
)
ポリフィズモムのように、データ毎に実操作のディスパッチを行なっています。
ポリフィズモムのようにと書いたものの、
これって if instance of 〜などと並べていくことと違うのか?とも思いました。
しかし、パターンの網羅チェックが入ることが大きな違いになります。これにより、データ方向の増減に安全に対応できると思います。(この辺も終盤にもう一度触れます)
Smart Constructor(値オブジェクト)
更に、以下のような個別のビジネスルールも存在しています。
商品の注文「数量」は、0であることはあり得ず、99個までしか受け付けない。
数量オブジェクトに対するスマートコンストラクタ(Fromメソッド)を実装し、pythonに翻訳しました。
# Value Object Example
@dataclass(frozen=True)
class Quantity:
value: int
def __post_init__(self):
if not 1 <= self.value <= 99:
raise ValueError("Quantity must be between 1 and 99")
@staticmethod
def From(value: int) -> Result['Quantity', str]:
try:
return Ok(Quantity(value))
except ValueError as e:
return Err(str(e))
def __int__(self):
return self.value
Fromメソッドは、結果をResultで返却します。
return Quantity.From(order.quantity).bind(
lambda quantity: Ok( # ビジネスルール適合時
VerifiedOrder(
item_id=order.item_id,
quantity=quantity,
shipping_to=order.shipping_to
)
)
).or_else( # ビジネスルール違反時のフォールバック処理。デフォルト値を書くようなことも可能。実はResultに生やしていた便利メソッド
lambda error: Err(
OrderError(code="Order", message=error)
)
)
Javaだったりのように、デフォルトコンストラクタを使用できないようにする方法は発見できませんでした。From()
を使用せずに生成すると、例外を投げてしまう可能性があります。
部分適用
これは主にシステム都合(テスト容易性)のためですが、ドメインでも喩えてみます。
ドメイン
- Order業務統括リーダ(integration層の喩え)は、外部とのやりとりを一手に担います。
また、商品カタログ(依存性の喩え)を倉庫から取ってきて、メンバーに業務指示と共に渡すということもやります - その結果、各タスクを行うメンバーは、本質的なタスク実行そのものに集中出来きます。(外部とのやりとりや、データ取得方法の詳細を気にしなくていい)
コード
def process_order(
address_checker: AddressCheckerProtocol,
product_catalog: CatalogCheckerProtocol,
estimate_delivery_days: DeliveryDaysEstimatorProtocol,
) -> Callable[[OrderInProtocol], ProcessOrderResult]:
コールサイト(integration層)はこんな感じです。
# 依存性をbakeする
order_workflow = process_order(
existence_check_japanese_address,
product_catalog,
lookup_delivery_days_pack,
)
match order_workflow(order):
case Ok(o):
...
それぞれのデータアクセス関数には、プロコトルによるI/Oの契約を定義します。
class CatalogCheckerProtocol(Protocol): # ex) 商品カタログ
def __call__(self, item_id: str) -> Decimal:
...
インターフェースというより、インターフェースの規格定義という感じですね。(言葉のまんまですが)
一通りの説明は以上です。
全体的に、手続きの連鎖からなるドメインを直接的に表現できると感じ、Domain Modeling Made Functional本での「エンプラ系にむしろ向いている」という記載があったことも納得します。
デメリットは?
個人の感想としては、ペインポイントを一つも感じなく、「え、シンプルにこれでいいのでは?」というのが正直なところです。
ただ、シンプルな一本のワークフローしか実装しておらず、現実の運用で問題が生じないか確かめるのは今後の課題です。
あるいは、素朴なプロセス指向に問題があったから、種々の技法が発展してきたはずです。
最後に、
- そこのそもそもの問題ってなんだったんだっけ?
というのを、
- 当時からはアップデートされている前提(周辺領域含めた技術の発展)
も踏まえて考えていきたいと思います。
独力で網羅的に検討出来ているとはとても思っていません。
「いやいや、このやり方はこういうシーンに困るでしょ」という観点があれば、ぜひ教えていただければ幸いです。
expression problem
https://eli.thegreenplace.net/2016/the-expression-problem-and-its-solutions/
プロセス切りなので、データ方向の増減に開放閉鎖的ではないというのが真っ先に問題として挙げられると思います。
そもそもそれだとなにが困るんだっけ?を考えてみます。
-
既存のコードブロックをいじる(追加ではなく変更)になるからリグレッションテストが大変
- 現代前提だと、自動テストがあるため軽減されている問題に思っています
-
そもそも、修正すべき場所が散らばってしまい、見落とす
-
共通のプロトコルを実装したdataclassで多態する
https://zenn.dev/kei1104/scraps/fdbb0c4176b144
共通化
今回示せていないものの、これは単純に共通する関数、型があれば切り出していけば良いのではないでしょうか。
ジェネリクスも使用してそれなりに効率的に差分プログラミングも可能という気がします。
依存性/引数のバケツリレー/肥大化
Reactで起こること同じと思います。
DMMF本では、「多くなってきたら一つの構造体にまとめよう」くらいで片付けられていた記憶です。
バケツリレーは許容するしかなさそうです。本格的に解決するなら、Readerモナドなどの更なる関数型概念の導入が必要になってくるという理解です。
そもそも階数が深くなりすぎない/必要依存性が大きくなりすぎないようにworkflowを分割する、という対処療法?も考えられます。
FaaSに関数をバラして配置する、Nanoサービス的な前提であればあまり問題にならないとも思います。
まだデメリットになりうるトピックはあると思いますが、すぐに思いつく限りはこんなものです。
最後の最後の所感ですが、この方法は
- 言語使用やコンパイラの発達
- 高度な型システム
- 関数型由来機能の広まり
に加え、
- 周辺技術の発達
- 状態はSPAやDBで持てばいい(ドメインロジックはステートレス)
- FaaS上で実行(ただの関数として載せる)
- イベントソーシング/CQRS
- イベントストーミングからも直繋ぎできるのではないか?
- 末端レベルの関数は、in/outの型と仕様をgenAIに与えればコードとテストを生成できる
など、周辺領域の進化とも噛み合うのではないでしょうか。
周辺領域は、フロント(宣言的UIとか)やインフラ(クラウド->サーバレスコンポーネントを組み合わせる)などなどパラダイムシフトレベルも含め進化が著しいですが、いわゆるバックエンド領域は割と従来の伝統が継続しているというイメージです。
前提状況の変化も踏まえ、慣れ親しんだやり方に拘泥せず、生産性の高いアプローチを更に模索していければと思います!