『関数型ドメインモデリング』勉強会 第3章 関数型アーキテクチャ

  • 2024.10.07
63
『関数型ドメインモデリング』勉強会 第3章 関数型アーキテクチャ

『関数型ドメインモデリング』とある通り、関数型の考え方を採用している。関数型プログラミングと手続き型のプログラミングではプログラミングの作法や根本的な考え方に差異が見受けられる事があるものの、本省ではまずそういった細かい所に入る前に、構築されたドメインモデルをどのように実装していくかについて、DDDの概念である境界付けられたコンテキストやドメインイベントをソフトウェアにどのように変換していくかについてみていく。

本章では、サイモン・ブラウンのC4アプローチの用語をユビキタス言語として扱い、アーキテクチャを以下の4レベルで構成していく。

  • 最上位概念として「システムコンテキスト」を導入する。
  • システムコンテキストは複数の「コンテナ」から構成される。これはデプロイ可能な単位でもある。実際に我々が手掛けている案件でも、フロントエンド(CloudFront~S3オリジンのWebページ)、バックエンド(APIサーバー)などでデプロイが分かれているが、これらがコンテナ。
  • 各コンテナは多数の「コンポーネント」から構成される。これらはコードにおける主要な構成要素とあるが、例えばJavaでいうとJarパッケージとして分割できるプロジェクトや、その中のパッケージが相当しそうだ。
  • 各コンポーネントには、OOPでいうと「クラス」、関数型でいう「モジュール」が多数含まれる。

優れたアーキテクチャの目的の一つは、これらの構成要素の境界を定義して、変更コストを最小限に抑える事にある。少し内容を先取りしてしまうと、APIサーバーを構成するモジュールの内部的な変更と思われている所が、実はWebページに影響を大きく影響を与えている、といった例は、教会の定義に問題がある可能性が高いだろう。

自律的なソフトウェアコンポーネントとしての境界付けられたコンテキスト

「コンテキスト」というのは自律的なサブシステムであり、そこには明確に定義された境界を持つことが重要になる。例えば先の例でいうと、フロントエンドとバックエンドの境界はAPIサーバーの入出力スキーマの形で定義できるだろうし、DBとAPIサーバーの境界なども同様と考えらえるし、BFF(Backend For Frontend)の導入などでもそれが自律的なサブシステムと認識できるような境界が明確にあってしかるべきとなる。

とはいえコンテキストの境界を適切に設定するのは難しい問題のため、最初からマイクロサービス的なアーキテクチャを前提に進める事を本書では推奨していない。実際のところ、マイクロサービスの一つが停止したら他のサービスもダウンするような場合、それは分散モノリスであって真のマイクロサービスではないと主張されている。

実際の所、真のマイクロサービスを実現するにはサービス間を疎結合にした上で、フォールバック、サーキットブレイカー、スケーリングなどの耐障害戦略を決定したり、Blue/Greenデプロイを各所に取り入れて無停止でのサービスのバージョンアップを行えるようにしたりと、確かに考慮すべきものが多すぎるので初期段階でそこまで踏み込むのは確かにマイクロサービスプレミアムが気になる。一方で、先に挙げたようなフロントエンドとバックエンドの分離などは両者の並行開発、特にSPAやUnity(WebGL)でのリッチなUIの実現の際には必要になってくるので、やはりユーザーの要件次第でどの程度最初から分割されるのかは決まってくるし、要件次第では分散モノリスで進むのも止む無しに思える。

境界付けられたコンテキストのコミュニケーション

境界付けられたコンテキストの間のコミュニケーションは、イベントを用いて行う事がまず考えられる。これまでの内容で受注コンテキストと発送コンテキストが出てきたが、この時

  • 「受注コンテキスト」の「注文確定」ワークフローは「注文が確定した」イベントを発行する。
  • このイベントはキューに積まれるなり何なりで他のコンテキストに通知される。どのように通知されるかは実装次第。
  • 発送コンテキストはこのイベントをリッスンし、イベント受信で「注文を発送する」コマンドが作成される。
  • 同様に「注文を発送する」コマンドは別のワークフローを開始し、それはまた別のイベントを発行していく。

このようにイベントの発行と、そのハンドラーによるワークフローの起動、次のイベントの発行、といった処理がなされていく。この時、概念的にはコンテキストの間は疎結合である。これを実現するために、例えばAWSのLambdaとSQSのような仕組みを使う事も考えられるし、独立したプロセスの間をMQで繋ぐようなことも考えられるし、一つのモノリシックなアプリケーション内部のイベントバスやを使う方法や、あるいは上流と下流のコンポーネントのような関係で直接関数呼び出しを行うものかもしれない。

重要な事は本質的に2つのコンテキストを疎結合にできることで、これは例えば後述の2つのコンテキストの間の契約で共有カーネルを選んだ時などは、2つのコンテキストは共有カーネルである共通ドメイン設計に従っている限り、互いに一切依存せずにキューやイベントバスで処理を繋ぐことができる。一方で上流下流の関係の場合、一方がもう一方のドメイン知識を有する事になるが、その場合であってもイベントバスやキューの仕組みを用いる事で、イベントの発行者と受信者という意味での結合度は下がる。これは特に下流のコンテキストが上流のコンテキストの知識を持つ順応者パターンで上流がイベントを発行する時に重要と思われる。(順応者パターンで上流から下流を直接呼び出すと、依存関係がループしてよろしくない。)

またこのようなコンテキストを跨いで情報をやり取りする場合、元のコンテキストの情報そのまま(ドメインオブジェクト)ではなく、DTOを用いると本書では示されている。これは外部のコンテキストで後続の処理を行う情報をすべて含み、かつ目的に合わせて異なる構造になっており、またシリアライズ/デシリアライズ可能、というように説明されている。この詳細は後の章でされるようだが、実際ドメインオブジェクトそのままだと情報過多だったり扱いにくいデータ構造だったりというのは多々あるので、確かにDTO的なものは良く定義している。

また強化付けられたコンテキストは、信頼の境界線ともされている。即ち外部から送信されたDTOがドメインの仕様に合致しているかをチェックし、また外部に不要な情報が漏れないようにチェックするといった入出力のゲートについて言及されている。先に出した「APIサーバーを構成するモジュールの内部的な変更と思われている所が、実はWebページに影響を大きく影響を与えている」という例は、このゲートをきちんと機能させれば起こらない物ともいえる。

境界付けられたコンテキスト間の契約

ここでは境界付けられたコンテキストの間でどのようにイベントやDTOを共有し(コンテキスト間の契約)、結合度合いを最低限にするのかの方法論が3つほど挙げられている。

  • 共有カーネル:2つ以上のコンテキストが共通のドメイン設計を共有し、各チームが協力しなければならない場合を指す。恐らくこの場合は共有する独立したドメイン(業務共通など)を設計し、それについて他のドメインと合意を形成するプロセスを辿ることになる。(複数ドメイン間で依存関係がループするような設計はよろしくないし、言語によっては不可能な事がある。)
  • 顧客/供給者:一連の業務フローで、下流にあたる側が上流側にどのようなものを提供してほしいか定義する。依存関係は上流から下流となるので、単純な関数呼び出しのような処理をする場合はこの関係になる。
  • 順応者:上流が契約を定義し、下流が従う。このパターンの場合、依存関係は下流から上流に向かうので、上流で何かイベントが発生した場合、イベントバスやキューを用いた疎結合呼び出しになるだろう。

腐敗防止層

2つのコンテキストの間で、利用可能なインターフェースがまったく一致しない場合、それを適切な形に変換する必要がある。そうしないと、外部のコンテキストのデータ構造に自身のコンテキストが汚染され、ドメインモデルが腐敗するリスクがある。このような事態を防ぐためのものが腐敗防止層であり、「境界付けられたコンテキストのコミュニケーション」で言及した入力ゲートがこれに当たる事が多い。

実際に今直面している問題でいうと、あるAPIサーバーの返すデータがAPIサーバーの採用しているフレームワークの制約により特殊な型をしているが、それをAPIサーバーとやり取りするフロントエンド側でそのまま使ってしまうと、フロントエンドが独立して進化できなくなる。そのため、APIサーバーとのやり取りの部分に腐敗防止層を設け、フロントエンドで扱いやすい形やAPIサーバーの受け入れられるフォーマットに変換する事にアンる。

注意点としては、これはデータの業務的な観点での検証というものではなく、2つのコンテキストの語彙の差異を吸収するという事。バリデーションも重要な仕事ではあるが、それと腐敗防止層というのは分けて考えるべきもので、腐敗防止層があるために複数のコンテキストが独立して発展できるとされる。これは共有カーネルの場合には然程必要にならないかもしれないが、それ以外のパターンではかなりコンテキストの自律性が高まるので、腐敗防止層はより重要になるだろう。(実業務でも順応者パターンや顧客/供給者パターンと捉えられる関係のサービス間には腐敗防止層を作っている。)

関係を記述したコンテキストマップ

書籍ではコンテキストの間がどのような契約によって結ばれているかが図示されているが、これも見れば見るほどグラフである。実は弊社はモデリングの際にグラフ構造を念頭に置いたモデリングに取り組んでいるが、実はコンテキストマップという大域的な視点でもグラフ構造を用いる事で、微視的なモデルからすべてグラフの関係性で繋がる事が見て取れる。

この時、各モジュール間の呼び出しが直接的な関数呼び出しであってよいのか、イベントバスやキューを用いるべきなのかは、契約がどの種別なのかで許容できるものが変わると考えれば、実際に処理を実装する際の手助けにもなりそうだ。また、個々のモジュールの型同士の関連を辿って行った際に、順方向の参照がループするような設計を検出できれば、恐らくそこにおかしな契約が見て取れるはずだ。(共有カーネルを採用した場合、実際的には複数コンテキストの共通コンテキストを独立して設計し、そこに対する依存はあっても、共通コンテキストから他のコンテキストに依存関係はないはずなので。)


「3.4 境界付けられたコンテキストのワークフロー」以降は、実際に後の章でより詳細な実装を見た方が納得できる話なので、ここでは重要な「永続性非依存」とそれに関連した「I/Oを端に追いやる」についてのみ記述する。

「永続性非依存」はどのようにデータが永続化されるかを気にしないいう事なのだが、それと同時に関数型のアーキテクチャでは、DBやファイルシステムの読み書きなどは不順と見做されて、コアドメインではそれらの処理を行わずに、ワークフローの両端で行う、即ちワークフローの内部のコアドメインでは読込結果のオブジェクトを扱い、ワークフローの終了時に書き込み処理を適宜行うといった考え方になる。

これは実はオニオンアーキテクチャと相性が良く、即ちドメイン層はインフラストラクチャの事を一切考慮する必要がなく、インフラストラクチャ層がドメイン層の提示した契約に従って読み書きを行うといった形に自然とマッチする。

プログラミング言語によっては、これをモナドと関連付けて語る事も出来るかもしれない。例えば一連のワークフローをW、コアドメインの処理全体をFとし、ワークフローへの入力イベントをI、出力イベントをO、ストレージから読みだすオブジェクトをS、ストレージへ書き込むオブジェクトをS'とする。この時、FI, Sを受け取ってO, S'を返す純粋関数とする。なので以下のシグニチャを持つ関数と考える。

F :: (I, S) -> (O, S')

そしてSをストレージアクセスのモナドをMとするなら、ワークフロー開始時のストレージアクセスRは以下のようなものになるか。

R :: I -> M S

ワークフロー終了時のストレージアクセスをPとすると

P :: (S') -> M ()

次のワークフローを起動するためのディスパッチ処理をDとして処理がそこで終わるとするなら

D :: (O) -> ()

これら全部を結合して

W :: I -> M ()
W i = do
  s <- R i
  let (o, s') = F (i, s)
  P (s')
  D (o)

このように捉える事ができるか。(Haskellなどの勉強は最近また少し始めたので上記は割と適当。)こうしてみると、コアドメインの処理が本当にストレージの事を気にせずに行われ、インフラストラクチャ層がコアドメインの型に対して永続化の処理を引き受けている事が見て取れると思う。