DDDにおいて、外部連携先のコールバック処理は鬼門となるので注意したい話

DDDを実践するにあたり、外部SaaS等のデータ構造がドメイン層まで食い込んでいるのをよく見かける。

これは複雑な連携であればあるほど避けるべきなのに、複雑な連携こそ、そうなっている事例をよく見る。

どうしてか考えてみたところ、それらには共通して「コールバック」による状態通知のような処理が存在していることに気がついた。(それ以外の場合は普通にアーキテクチャモデリングに問題がありそう)

これを「自組織のフロントエンドやアプリとのやりとり」と「コールバック通知のやりとり」で分けて考えると問題点に気づきやすいのではと思ったので書いてみる。

自組織のフロントエンドやアプリとのやりとり

自組織だったり、公開APIとしてのやりとりで使うrequestやresponseは、割とドメインモデルに近い形をしている。 ドメインモデルに近い形では厳しい場合にCQRS+ESにしたり、一部そのままでは都合の悪い場所をプレゼンテーション層で加工したりする程度である。

PrimaryAdapter -> requestToDomainModel -> UseCaseArgs(has a domainModel) -> UseCase という流れで、ここに問題はない。(domainModel変換なしでUseCaseArgsに詰めてUseCaseでRepositoryを引くパターンももちろんある)

コールバック通知のやりとり

一方、この方法で外部連携先のコールバック通知を実装するとどうだろう

PrimaryAdapterが受け取ったものをそのままrequestToDomainModelとしてしまうが、コールバックのリクエストの形は外部連携先が管理しているただのデータ構造だ。

これが無遠慮にドメイン層に流れ込んでくることになる。これを許容して良いのだろうか。

これでは外部連携先のデータ構造がドメインに漏れ出てしまい、変更容易性も凝集性も結合度もすべておじゃんである。

ではどうすればいいのだろうか

この外部のデータ構造そのものはUseCase層に置き、ただのDTOとして扱うべきだと思う。 これはUseCaseの引数以上の意味はなく、我々のドメインには全く関係がない。

流れとしては👇 PrimaryAdapter -> requestToDto -> UseCaseArgs(has a DTO) -> UseCase

※もちろん、内部のパラメーターの一部がドメイン足りうることはある。外部連携用IDとか。だが、構造そのものがドメインになることはほぼない。

こうすればドメインは真にやりたいことに注力でき、構造も外部連携先に左右されず、処理も変に外部連携先のデータ構造と密結合になることもない。(変換層があるとはいえ、同じ形に変換していたら疎結合とは言えないのではないだろうか)

本来、requestToDomainModel とするのも手間を嫌うのと実用上問題ないからであって、理想論はすべてDTOを噛ませるべきなのだろう。(手間がでかすぎるしメリットがないのでやらないけど)

保存はどうするのか

おそらくここが意見の分かれるところだと思われる。 外部連携先からの情報はもれなく保存していたほうが無難である。だが、ドメインとしては扱いたくない。

自分は👇で書いたDelegateProcess相当の処理でDTOをデータモデルに変換して保存している。 再考 - ドメインサービス - まっちゅーのチラ裏

こうすれば、ドメインはいつでも情報の過不足なくコールバック通知の情報を元にモデルを組み立てられるし、構造はドメイン層に漏れ出さない。

他には、外部連携用のコンテキストを分けてしまい、そこではガッツリドメインとして登場させる等の手法も考えられるがそこまでやるのはオーバーキルかなと思っている。

考え方

UseCaseに何もやらない作業者を想定しない という考え方が良いのかなと思う。

ECサイト作る際、商品配送を外部の配送会社に委譲することを考える。

配送会社ごとに伝票のフォーマットが違うと仮定したとき、ドメイン層に外部連携先のデータ構造を置くということは

  • ①作業者 -> ユーザーの配送情報をください!
  • ドメイン層 -> ホイ。配送情報です。
  • ③作業者 -> azs! 伝票書きたいんだけど書き方がわからん!配送会社Aの伝票の左上の欄には何を書くべきですかね?
  • ドメイン層 -> 住所やな。というかハイ。書いといたわ。伝票。
  • ⑤作業者 -> わーい。配送会社に出しておきますね!

という流れである。めちゃくちゃドメイン層に頼りまくってる。

データ構造を置かない場合の流れは👇

  • ①作業者 -> ユーザーの配送情報をください!
  • ドメイン層 -> ホイ。配送情報です。
  • ③作業者 -> azs!伝票書いちゃお〜〜
  • ④作業者 -> 伝票できた!配送会社に出したろ!

むちゃくちゃスムーズで、いちいちドメインが伝票の構造を把握する必要もない。

もちろん、ドメインが伝票相当の構造に興味があるケースもあるかもしれないが、ほとんどのケースでは興味がないはずで、伝票のどこに何を書くかくらいの情報は、知識というよりは作業者がアドホックに判断するものとした扱ったほうがスマートでないだろうか。

表示したいという1点だけで興味があるケースもあるが、それは伝票IDとTypeをpresentation層に持っていって直接データ引いちゃったり、CQRS+ESにしたり、UseCaseで表示用のDTOを持っちゃってPresenterでResponse用のモデルに変換したりで回避できる。

まとめ

コールバック通知等の情報を同期する系の連携は密結合になりやすいという話でした。

外部連携先オブジェクトのライフサイクルそのものにめちゃくちゃ関心があるのであればドメインに顔を出さざるを得ないと思いますが、構造そのものを出す必要があるのかはめちゃくちゃ考えてできるだけ避けていきたいですね。

自分が大規模システムで組むアーキテクチャは、下から上の依存は構造として参照すらできないように制限してるのですが、下へ下への圧力が強くなりがちなのかもと思うところがあったので、上の層における部分は置くようにし、下から上への圧力も意識してドメインをシンプルにし、本来やりたいことに注力できるようにしていきたいと思います。

モジュラモノリスの実現方法

ほぼ創業時から副業で手伝っているAlp社の同僚がモジュラモノリスの実現方法をScalaMatsuri2020で発表したので気になる方はどうぞ。

横の境界だけきった状態から、途中でモジュラモノリスに舵を切ったのでDB分割はこれからですが、それ以外の部分はキレイに分離できています。

自分がモジュラモノリスに重要だと考えているのは以下の2点です。

  • いかに少ない手数でマイクロサービス化できるか
  • マイクロサービス化した場合と同じ制約をコードに持たせられるか
    • パッケージで制限するのは少し制約として弱いと思っている。

この方法は、アプリ側は内部通信用adapterを叩くclientのDIを差し替えるだけでマイクロサービスとして切り出せるし、境界外のコードは参照すらできないようになってるのでマイクロサービスと全く同じ制約をもたせられている。とても良い感じに実現できた👍

ScalaMatsuri 2020 | アジア最大級の Scala のカンファレンス

speakerdeck.com

マイクロサービスでチームを分離したくないマン

コンウェイの法則とかで、マイクロサービス=組織 という話になることが多いなと感じる。

正解の場合もあるし、不正解の場合もあると思っていて、個人的には小さいチームでもマイクロサービスをやるメリットは技術的にも組織的にもあると思う。 そのメリットを無視してすぐ組織の話に持っていきたくないので、基本分離したくないマンとしての主張を書いておく

技術観点でのメリット

いまさら語るまでもないけど、

  • ドメイン境界の分離
  • デプロイ独立性
  • リソースの最適配分
  • 障害の局所化(サーキットブレーカー等)

このうち、ドメイン境界の分離だけはモジュラモノリスで対応可能だが、あとの3つにはマイクロサービスが必須。(もっとあるかも)

この3つが必要なのにモノリス or モジュラモノリス で進める判断をするということはシステムの表現力を落とすことに直結する。

もちろん、複雑度は増すし難易度も増す。熟練のサーバーサイドエンジニアとインフラエンジニアは必須。

※別の記事でも言及してますが分散モノリスにならないようアーキテクチャ側で制約をもっている。チームが分かれてなくても分散モノリスの回避は可能。

※モジュラモノリスはどこまでも分離を前提として設計しています(アプリ側はDI差し替えたら分離完了する)。そうしないとあまり意味がなさそう。

組織、チーム観点でのメリット

開発を進めていき、コードベースが大きくなっていくと「全てを把握してコードを書く」ことがどんどん難しくなる。

ここで安易に人数を投入して把握すべき単位を小さくするアプローチは、個人的にはかなりコスパの悪いアプローチだと思っている。

  • 安直なコンウェイ(というかSRP=組織構造?)否定派のワイ。なので、リポジトリもチームも別でやるMicroserviceには基本否定派である。
    • 完全個人的見解ですがAWSは各サービスの独立性が強くて(1サービス1プロダクトと捉えられる)上手くいってるし、Netflixはテクニカルな単位でサービスを切ってる&ハイパー高給と強い人事施策で組織的なコンテキスト跨ぎを促進して上手くいってると捉えている。
    • それ以外のケースではリソース効率だったりアジリティだったりの面できびしいことも多いんじゃないだろうか(異論は認める)

具体的には以下のようなことが考えられるからである。

  • エンジニアは把握すべき単位を超えた部分に対する理解や責任が浅くなり、開発の速度や機能開発の質が悪くなる
  • 案件をすすめるにあたり、コミュニケーションを取る対象が増え、アジリティが低くなる
  • 開発チーム内でサイロ化が始まり、やるべき破壊的な変更に対し実行する判断を下せなくなる(めんどくさくてやらなくなる)

この辺はラストマンシップをもった人で構成されているチームであればある程度解消できるが、そもそもラストマンシップをもっている人にとってはこの垣根は邪魔なものでしかない。

個人的には適切に関心事が分離されているコードベースであれば、1プロダクトであればかなりの規模のコードでも少人数で回せると思っている。(少人数の定義はむずいが、1つのプロダクトに100~1000人のエンジニアは絶対要らないはず。もちろん場合にもよると思うけど)

理由は 

  • 適切に関心事が分離されていれば、全てを把握してなくても「今触りたい機能に関係する部分」だけ知識のアップデートをして開発に取りかかれる
  • 誰がどの機能に詳しいなどの偏りは出るが、むしろそれによってアサインが明確になりレビューが捗る(その機能に詳しい人+そうでもない人をレビュワーとしてアサインする等)し、チームメンバーのコミュニケーションも円滑になる(経験則)
  • 複数の機能に詳しい人が育ちやすく、それによって案件を一人で遂行できる能力がつき、ラストマンシップが身につく といった流れができる。

上記のようなメリットを享受しつつ開発時のコミュニケーションも最小で済ませることができる。

単機能を担当するような組織だと担当機能の変更を好まない性質が出やすい。

  • 変更しないほうが障害リスクもないし対外的に見て得なことが多い。スケジュールの握りも過剰に安全性に倒しまくったほうが得になる。
  • 常にシステム全体の利害関係者であるべきだが、その状態が維持しにくい。維持できないと適切な機能開発は難しく、歪になっていく。
  • ○○チームがあまり要望聞いてくれないから勝手に○○機能実装しました。みたいな例も聞いたことがある・・・。馬力のあるやつの無駄遣いすぎる。

以上のようなことを考えないで済むのはかなりのメリットだと思っている。

まとめ

現職では、19のマイクロサービス(Scalaで76万clocほど)は15人ほどのサーバーチームと、5人のSREチームで管理している。(マイクロサービス化した頃は、7つのサービスにサーバー3人、インフラ兼サーバー1人だった) それらは案件ベースで協力して動くが、組織的観点では同じチームだし、案件に必要ならどのマイクロサービスも触る。(クレイグ・ラーマンの法則に近い動き方なのかな?あんまり詳しくないのでわからないけど)

普通に事業的にも、開発的にも上手く回ってるし、分割したら上手く回らない未来しか見えない。(少なくとも自分のチームでは)

これからチームがどんどん大きくなっていっても、少なくとも1サービス-1チームの分割はしないんじゃないかなーと思っている。

ただ、上手く回ってるのは、以下の要素も大きいのかなーと思うので、どこでも適用できる話ではないのかもしれない。

  • Scala+Effによるシステムの表現力
  • CleanArchitectureによる分散モノリスを防ぐ制約(例えば👇)
    • 内部通信用アダプタ(gRPC)と外部通信用アダプタ(http)を分けている
    • ドメインに他サービスの関心事が入ってこないようRepositoryとは別で出力OnlyのDIPをしている再考 - ドメインサービス - まっちゅーのチラ裏
    • sbtプロジェクトによる、コードを直接参照できない強固な縦横の境界付と、依存方向を単方向に強制する制約
  • 開発が普通にできるレベルのエンジニアリング力がありつつ、PM力もすごい強いPMが複数いる
  • インフラもk8sで運用が楽。
  • CIも工夫して手間なく複数サービスのデプロイができる

とはいえ、ECSやk8sだったり、CIの仕組みだったり、マネージドサービスだったりでマイクロサービスもどんどん運用が楽になってきているし、これからもなっていくだろう。

その時流の中、チームが小さくて組織とマッピングできないからといって検討もせずにコンテキスト分離のメリットを捨ててしまうのはもったいないと思う。

小さいチームでもマイクロサービスは十分有効で、組織分割は必須ではないという意見でした。

※マイクロサービスの前にモジュラモノリスを考えるべきではある。デプロイ独立性等は不要で、縦のコンテキスト分離だけ必要ならそれで十分なので。

※組織を分割する必要のない小さいチームでもコンテキスト分離を試みるメリットについて書いたのは👇 認証と認可と課金とコアドメインを分離したシステムは勝てるという話 - まっちゅーのチラ裏

※追記 コンウェイってより逆コンウェイじゃね?と教えてもらって、たしかにそうっぽい。と思ったので逆コンウェイの引用も貼っておく。 逆コーンウェイ戦略とDevOps, Microservices, Agile | an Agile Way

ざっくり👇のように理解した。

  • コンウェイ => 組織 -> システム設計となりがち。という知見の話
  • コンウェイ => システム設計にあわせて、組織を変えよう。という戦略の話

※追記 マイクロサービス化 -> 組織分割の間にはグラデーションがあると思っているだけで、組織分割自体を否定したいわけではありません。すぐそっちに話を持っていくのが違うなーという。特に「コアドメインのコンテキスト分離の境界をどこにするのか」は、めちゃくちゃ難しいので組織分割を先にしてコンテキスト分離すると意味不明な単位になるし、組織を専任で当てるほどでもない小さい単位でも、分離すると有用なケースも往々にしてあると思う。

再考 - ドメインサービス 

自分が大規模システムで組むアーキテクチャは基本的にはCleanArchitectureを踏襲しているが、その中の構成要素であるドメインサービスだけは少し独自(?)の解釈をしていて、書籍などでよく見る

という責務の他に、外部システムへの委譲処理だったり、共通UseCaseのような責務も持たせている。

これは、自分が「xxService」という命名にトラウマがあり(何でも置き場になりがち)、単なるServiceだとコントローラやらプレゼンターやら、どこから呼ばれても違和感がない様に見えてしまうから、とりあえずDomeinServiceへ寄せている経緯がある。

※ここで語るのは、あくまで大規模想定で、小さいシステムならこんな事を意識する必要はないはず。

※あくまで自分の考えで、一般的ではない可能性があることをご了承ください。

なぜDomainServiceに寄せているのか

domainServiceと呼ぶのも👇のような考えがあったからで、DIPして技術関心事へある程度処理を委譲しつつドメインロジックを実現する場所として色々定義したいからだった(インターフェースはドメイン層だし、ドメインロジックと呼べるよな!?というノリで寄せている。

  • 銀行の順番待ちみたいな実装をすると、待ち番号が書いてある紙を渡すUseCaseがあったとして、待ち番号を発行するロジックをドメインで持つのは、現実世界では待ち番号を案内のお姉さんがメモって採番する行為に等しいように思う。
  • お姉さんが2人いると採番するのにコミュニケーションが必要だし面倒。採番はなんだかよくわからんシャシャシャーと紙を吐き出す機械に任せたい=技術的関心事に渡そうぜ みたいな話だと思っている。
  • 技術的関心事によって実現するドメインに必要な要素を都合よく隠蔽できる場所(Repositoryに近しいドメインとインフラの境界の処理で、Repositoryは入出力、DomainServiceにもたせているのは出力のみ)だと思っている。
  • それが正しいかはしらんけど、そうすると絶対的に便利だなとおもって書いている。(めっちゃ適当に書いてるのでツッコミ大歓迎w)
  • iDDDのサンプルソースなどでは、repositoryのDIだけしてdomainServiceはドメイン層にベタ書き実装があったり、書き方が揺れている。悪いとは思わないが、repoがある時点でpureなdomainServiceではないし、委譲処理を書くところがない(または曖昧)のは分散モノリスになりやすそうという個人的な印象。

ドメイン層で表現しようと思えばできるという余談

コストに見合うとは全く思えないけど、表現しようと思えばできる。

  • Effを使えば受付番号採番機みたいなドメインモデルを定義して、採番的なドメインロジックに技術津的関心事を注入することも可能だが、そこまで細かいモデリングが必要なケースとそうでないケースがありそうだなと。(隠蔽したほうがシンプルになるはず)
  • ActorSystemのアプローチであれば、お姉さん同士の状態が常に問い合わせられる状態で存在しており、コミュニケーションがとれる(オンメモリに状態を持って常駐している)ので、合意形成のアルゴリズムなどをガチガチに実装すれば採番も可能にできるかも。
    • とはいえ基本的には、ActorSystem以外のアプローチだとDB等に副作用を保存しているので都度都度スナップショットを取り出してこないといけない。
    • 取り出すオブジェクトが一意じゃなければ並列性等考慮すると採番のロジックも普通にバグってしまう。
    • こういうのを、redisなどのシングルプロセスのkvsに一旦保存してincrみたいなコマンドで採番していけば、「一旦保存」の部分さえ気をつければincrは全く同時に叩いてもかぶった番号を返すことはないのでサクッと採番が可能になる。
    • これは「採番機」みたいなオブジェクトを実装側に隠蔽して、ドメインでは意識しない良いテクニックだと思う
    • 「採番はなんだかよくわからんシャシャシャーと紙を吐き出す機械に任せる」ということをコードで定義するとこうなる
    • 採番機をドメインにおくのは、めっちゃ採番機に詳しいおねえさんを前提にしているようなことな気がしている(そんなやつおらん)

細分化してみよう

とはいえ、domainServiceの実装がsecondary-adapterにあるのは違和感はあるっちゃーあるし、共通UseCaseをdomainServiceと呼ぶのも違和感がある。

細分化してみると現状のドメインサービスは以下の3つ存在していそう

  • pureなdomainService
  • 汎用業務プロセス(UseCaseからUseCase呼びたい時に逃がすやつ
  • 技術関心事への委譲手続きプロセス(現状はここが多そう)
    • 委譲には現状のドメインサービスでやっている👇のような処理をドメインとAdapterの境界に持ってくることで表現力を上げている
    • 外部SaaS連携する際の、外部連携APIへのリクエストとドメインオブジェクトのマッピングドメイン要件でもありビジネス要件でもある(ドメインのどのパラメーターがリクエストに対応するかはビジネス要件だが、リクエストの型自体や通信の方法はシステム要件)という曖昧な処理の置き場所(そうすることでドメイン層の汚染を防げている)
    • ある要件を実現するために技術関心事の力を借りる場合(上の例の採番ロジックでいうRedis)

これを、

  • pureなdomainService => DomainService
  • 汎用業務プロセス => JointProcess
  • 技術関心事への委譲手続きプロセス => DelegateProcess

として分けるとかなり腹落ちがした。 JointProcessの中でDelegateProcessやDomainServiceを呼んでもOKだし、UseCaseから呼んでもOK。

JointProcessはUseCaseに実装ベタ書き、DomainServiceはDomain層に実装ベタ書き、DelegateProcessはUseCase層にインターフェースを置いてSecondaryAdapterで実装注入(DIP)する。

自分の中で一番表現したかったのはDelegateProcessの部分。これがなしでRepositoryのみだと SaaSのエレガントな連携とは - まっちゅーのチラ裏 でも書いたとおり、双方向の依存を作り、分散モノリス化しやすいと思う。(多分みんなServiceとかに切り出して書いてるのかな。)

DelegateProcess相当のものを用意することで、たとえ双方向の依存が必要な連携方法だったとしても隠蔽でき、UseCase層からは単方向の依存に見せかけることができる。

クソ雑な図で示すと、Repositoryと委譲処理の処理の流れのイメージは👇のような感じ。(

f:id:ma2k8:20201019172420p:plain
ca

また、サードパーティapiのコールバック等、コントローラーからusecase通って〜という処理を書かないといけないからサードパーティapiのデータ構造を丸コピしたようなドメインを定義しないといけないみたいなケースも、コントローラーからusecase -> deligate processと呼んで、ドメイン層を通さずにデータ構造をそのままDBに保存してしまうしまうという手が使えそう(UseCaseにDTOは要るが、ドメインは汚染されない。ドメインに侵食しないのが大事。外部のデータ構造なんてドメインは興味ない。) サードパーティapiのデータ構造は基本全部保存はしといた方がいいが、ドメインに顔出した方がいいかは全く別問題なのでこの手は有効だと思っている。(保存さえしておけば、後で情報落ちで困ることもないし、こういう情報をドメイン層介さず保存することで、自分たちのドメインモデルとして柔軟にReposiotryで組み立てることができる)

委譲プロセスがないと、別コンテキストの関心毎が各所に散らばって分散モノリスっぽくなってしまうと思っていて、みんなどこに書くんだ!と思っていたが名前をつけて思考が整理され、スッキリした。(最近、巷で分散モノリスの話が出てきてるが、これも委譲プロセスをアーキテクチャの中に見いだせていなからおきてると思ってる。UseCase層に登場するからドメインや!的なノリ。委譲プロセスとして切り出せば関心事は最小にできる

JointProcess,DelegateProcessの命名は適当なのでチームの人とかと話していい名前を見つけてみる(個人的には割としっくり来ている)

なぜApplicationServiceじゃダメなのか

目的自体は委譲なので、DIPで注入さえできればApplicationServiceという名前でもいいんだけど、UseCaseと混同する気もするし、「どこかにお願いする処理」を表す言葉として表現に乏しい気がしている。

なぜDomainServiceのままじゃダメなのか

別にダメじゃないと思う。

個人的には なぜDomainServiceに寄せているのか に書いたとおり、DelegateProcessはDomainServiceと呼べると思っているし、アーキテクチャの表現力の観点では全く問題がないので。 ただ、JointProcessはDomainServiceとは呼べないなという違和感と、DelegateProcessは名前をつけて意識しやすくしてあげることで分散モノリスのような悲劇(もちろん途中から分離した場合はある程度仕方ない)を回避できるんじゃなかろうか。と考えている。

あと、DomainServiceだとドメイン層に置かないといけないが、DelegateProcessならUseCase層における。ドメインロジックだよな!から、あくまで業務手順の一つで詳細はしらん!にできる。ドメインモデルじゃなくてDTOで事足りる場面も多いと思うのでこれは良い戦略何じゃないかなと思っている(過度にやりすぎるとドメイン知識が漏れ出すので注意は必要)

※あくまで僕の実装しているプロジェクトの命名規則どうしようかな〜の話です🙏 認知負荷下げていきたい!

まとめ

これを徹底して、UseCaseに委譲の関心事を押し出すことで、誰もが一度は憧れた(かもしれない) :point_down:のようなドメインモデリングが可能になる。(流石にやりすぎではあるけど、あくまでイメージですw)

case class Payment( id: PaymentId, type: PaymentType )

コレを不可能にしていた要素は大部分、UseCase層に押し出せる。(変に抽象化すると歪さをうむのでUseCase層ではある程度愚直に書かないといけない。ドメイン層のものと混ざらないことが大事。また、業務知識として出てきたものをUseCaseへ押し出してはいけないという前提のもとです)

課金履歴みたいなデータ構造は課金手段によって構造が変わるが、PaymentTypeだけ意識してドメイン層で取り扱うようにし、UseCaseで履歴の構造を組み立てるとかもできるかもしれない。(ここはある程度ドメイン層で表現した良さそうな直感はある。Repositoryによる入力が発生しそうなので。なるべく上に押し出す意識が持てればOK。外部連携用のデータをそのまま表示したいなどの要件があった際は、CQRS+ESで表示用のコンテキストに持っていけば、リードモデルが連携先のデータ構造に似てるとかは考えうる。課金レベルの複雑なドメインで表示の要件が出たときの歪さは自分の中でもきっちりとは整理できていないがCQRS+ESにのっておけば適切に分離できるだろうと考えている。近々決済機能を実装する機会があるのでそこで試す。他のドメインでは上手くいっている)

こうすることで、複数の課金要素(たとえばLinePayUseCaseやそれに必要なDTO)はそのままの形ではドメイン層に現れない。 コレでビジネスはシンプルになるはずだ。

※共通プロセス(JointProcess)、委譲プロセス(DelegateProcess)という名前は、結構解釈が割れそうな気もするのでいい名前思いついたらコメントいただけるとうれしい

※JointProcessは使用する側される側どっちなのかわかりにくい気がする・・・

※DelegateProcessは、Delegateという言葉に色々意味が含まれすぎてる気がする・・・

※追記: DelegateProcessは、xxxSolutionとかのほうが、注入するのも違和感ないし、UseCaseからもどっかに依頼してる感が出るし、業務的にもSolutionのことは手順内でやりとりだけ知っとけばいい感あるかもしれない。とはいえ、Serviceと同じくらいフワっとしてる気もする /(^o^)\

我々はまだEffの表現力の恩恵を、わずかな一部分しか得られていないのかもしれない

頭の中言語化チャレンジ企画

01.png (46.3 kB)

飲みながらだと、「Effいいんだよぉ!!」と脳死説明しかできないので事前に言語化しておくチャレンジをするマン。

Effのざっくりとした解説は社のブログに書いたので気になる方はどうぞ

tech.recruit-mp.co.jp

Eff導入まで

長いことモナドトランスフォーマー+typed-finalでプロダクションコードを書いていて、型パズルどうにかならんかと思うことがよくあり、当時のチームメンバーだったイッペーと「Typed-finalは1効果-1型パラの抽象化しかできないし、モナトラは効果の組み合わせが増えていくとどんどんつらくなる。良い解決策になりそうなのはFreeモナドあたりで、そこでなんとかゴニョゴニョすれば1対多の抽象化が楽にできるんじゃね?」と漠然と話をしていた。

論文を読んだり勉強会の資料を漁ったりし、2年前の1月にEffを使ったアーキテクチャの構想に至り、メンバーとなんやかんや議論しつつ、2018年6月くらいに担当しているサービスの1サービスをすべてEffに書き換えてリリースした。

リリースはしたが、Effの表現力によって得られる恩恵の大きさを測りかねていた。

シンタックスがフラットになって可読性があがるとか、律速問題の解決とか、計算の順番が変わっても結果が変わらないとかは、more extensible effect誕生のモチベーションそのものなので自明だが、 ソフトウェアの表現力の部分で、もっとどデカいメリットがあると思っていたので、どういうところに一番効くかをぽーっと思案していた。

自分は、研究と実践でいえば実践好きで、研究で得られた理論は実践によって昇華されるはずだと強く思っている。(もちろん研究で得られた理論がないと体系だった実践はできないので双方ともに重要ではあるし、現時点で世界がおいついていないから実践できない理論とかもあるが、細かいことはおいておく)

新しい技術にただ飛びつくのではなく、1実践者として理論の旨味を理解し、どう昇華させるか、枯れさせていくかを考え、すでにある枯れた技術とうまく掛け合わせて行くべきだと思っているので、かなり自明なメリットがないとprodの環境に何かを導入はしないマンである。(dev,stgまではバシバシ入れて検証しまくるではある。要はEffは結構慎重に入れたよという話です🙏 論文や資料を読み込まずに導入するのはおすすめしません🙏)

担当しているサービスで強行軍でアーキテクチャを整えたのも、「マイクロサービスまでは表現力獲得のためには当たり前ラインで、その先の運用やその効率化、インフラストラクチャや、その上に乗るアプリケーションを考えるチームとして充分なメンバーがいるし、サービスの領域もそれくらいやる意義と必要がある」と考えてのものだった。

※手を入れる前の既存の実装は力技の実装が多かったので、問題のレベルが高くないと解のレベルも上がらないとも思っていた。その時はサーバーを4人、サーバー兼インフラ1人で回していたが、新しい人も入り始めていたので、より良い制約と解が必要だった。(このときmodular monolithに考えが至っていたら、最初で7つに分けたサービスは4つですんだが、4つはデプロイ独立性とリソースの最適配分も必要だったので、どうしてもマイクロサービスとして分けないといけなかった)

※当たり前ラインとかいうと戦争が起きそうですが、ある一定以上の複雑性に立ち向かう上では標準装備になっていくものだと思っています😋

そんなこんなあって、みんなの助けがあって(まじで一生感謝)苦労してもってこれた当たり前ラインに、Effという表現力爆増の武器を搭載したので、ソフトウェアアーキテクチャ+Eff, マイクロサービス+Effで素晴らしい次のステップの実践ができる状態になった。

ソフトウェアアーキ + Effで考えたこと

Effの使いみちとして最初に浮かんでいたものは「トランザクションモナド」で、DBの操作をフラットに記述できる事によりslickのDBIO型やFujiTaskのような仕組みの旨味だけを享受できると考えた。これらは便利だが、取り扱う型が増えるためにシンタックスが煩雑になり、受ける恩恵と同等かそれ以上のデメリットがあったのをEffは解消できる。(型が増えるとまじで型合わせが辛い・・・)

あと、説明で使ったのは「メール」だった。 メールは一度発火するともう取り返しがつかない。トランザクションの最後の方に記述するようにはしていたが、トランザクションがコケない確証はどこにもない。 Effであれば処理のどの位置に記述しても、トランザクションの確定後にメールを送信するような表現ができる。

次にうかんだのは、認証、認可、課金で、UseCaseやドメインにしれっと顔を出しやすいこれらの分離と柔軟な表現に使えると考えた。

マイクロサービス + Effで考えたこと

マイクロサービス上で実装する上で、まだ愚直に書く選択しかない「補償トランザクション」、「分散トレース用のrequestId引き回し」をEffのeDSLで表現することによってシンプルに扱いやすく記述できると考えた。

特に補償トランザクションはTryを送る -> Eitherを取り出してRightならConfirm,LeftならCancelという処理が書け、Eitherの効果をまたぎ、継続の恩恵も受けることができ(すべての処理が終わってEitherが返った段階で判定する)、_compensationTranTry_compensationTranConfirmOrCancel のような効果に分けることでインターフェースに補償トランザクションのどのフェーズの処理かも明示できるようになるので、愚直に書くよりは認知負荷や処理漏れなどのバグの心配も格段に減らすことができると思っている。

分散トレース用のrequestId引き回しも、普通に書くとUseCaseまでrequestIdが顔を出すので色んな所を汚染してしまうが、内部通信用の効果のインタプリタに渡せばよいだけになるのでとてもクリーン。

担当しているサービスでは、これ以上の用途は思いついていなかったがマイクロサービス上で楽に正確にわかりやすく記述できるのでこれだけでもテンションが上っていた。

SaaS + Effで考えたこと

SaaSは汎用性と専門性のせめぎあいが必要になると思う。

大型案件などで、どうしても局所的に専用の処理を組んだりする必要がでてくることもあるだろう。

そういう、各社の持つ独自仕様や、マルチテナントをせざるを得ない状況に陥ったときを考えると「アレ、これどうすんだ、人海戦術しか残っていないのか・・?」という気持ちになって憂鬱になる。

だが、「ドメインロジックやら各種Repositoryへのstore処理も効果として定義すれば良いんじゃね?」という考えに至ったときに、Effの懐の深さでなんとかなるかもな?と思った。

そうすれば、どうしても対応しなければならない各社の持つ独自仕様のようなものが出てきた時でも、かなりの部分インタプリタで吸収できる。UseCase同じでデータ構造違うみたいなこともスッキリ表現できる。それで吸収できない部分はUseCaseごと分ければ良い。

マルチテナント化に舵をきるときも「仕様の違いでforkします!!」という地獄のような理由で判断を下すリスクを極限まで減らすことができるな?と気づいた時に「いけるかもしれん」と思った。(やったことないのであくまで思っただけ)

👇コード書きながらそれを思いついて、テンションがあがり酒飲んで訳わからんことをつぶやいていた。(自分自体はアーキテクトってよりはSWE寄りだと思っております)

Screen Shot 2020-09-27 at 4.20.54.png (81.3 kB)

最後のドメイン系IOのEff化はどう書くのかまだ決めきれていない(というか、現時点では必要がない)が、SaaSが死ぬときは、本質の機能提供がミクロな解の提供によって阻害され、ドメインが汚染されて表現力が落ちていくのが要因であることも多そうだなと思っていたので、 それに備えられるのは良いし、この柔軟性こそが競合優位性になりうる。

インタプリタを作りまくって差し替えまくってより良い挙動を検証していくことができるかもしれない。

どうしても対応しないといけない巨額案件の微妙な要件も、専用のインタプリタとControllerでproviderId判定するだけですむかもしれない。

まとめ?

現状、EffはドメインやUseCaseの記述が自然言語に近くなるだったり、シンタックスがフラットになる恩恵や、トランザクションなどの独自定義モナドの恩恵受けることができているが、他にも色々な使いみちがあるかもしれぬ。主に効果のカプセル化とも呼べそうな部分に魅力を感じていて、適用可能な範囲めちゃくちゃ広そうだなー

とか考えると、まだまだ表現力の表層しか使えてないかも? と思うのであった。みんなで使いながら探っていきたい。

SaaSのエレガントな連携とは

SaaSにおけるエレガントな連携ってなんぞってのを言語化していく(実はSaaSだけじゃなくて連携全般)

Screen Shot 2020-10-14 at 0.45.16.png (78.4 kB)

一言でいうと、疎結合であればあるほどエレガントで、依存性の方向が単方向だとエレガントである。

最近Looker連携を実装して、「これはエレガント!」と感心したのでLookerを例にしていく。

Lookerが提供するエレガントな連携

疎結合ってなんぞ?

疎結合ってのは、やりたいことをやるために知るべき知識が少ないということである。

Lookerでいうと、「GroupAのユーザーHogeAです!ダッシュボードBを頂戴!!」しか、ドメインでは意識していない。 ドメインの誰が見たいとリクエストしているか。と、ダッシュボードがA,B,C複数種類あるよ。くらいの知識しか知らないのである。 「ダッシュボードを見たいユーザーがLooker側に存在するユーザーなのか」 とかはすべての層で意識せずに済んでいる。(すごない?)

依存性の方向が単方向ってなんぞ?

「GroupAのユーザーHogeAです!ダッシュボードB頂戴!!」で結果が得られるのは、 連携元システム -> Lookerという依存性の方向になる。(連携元システムはダッシュボードを得るためにLookerに依存している) これだけで済めば依存性の方向は単方向である。

ダッシュボードを得るために「Lookerはダッシュボードを渡すために連携元システムにユーザーの生成を求めているという」条件があれば Looker -> 連携元システム の方向でも依存し、双方向の依存になる。

どうやってやってるの?

これは、ユーザーの作成処理をLooker側がAPI実行時にアトミックに行ってくれているから実現している。

「ユーザーHogeAです。ダッシュボードB頂戴」というリクエストをLooker側に送ると、 Lookerは「あー、はいはい。HogeAさんね。そんなやついないなぁ〜?作っといたわ。はい、ダッシュボードBをどうぞ。」 という感じで暗黙的にユーザー作成を行ってくれるのでLooker側の状態をこちらが意識する必要がまったくない。

(だるまに教えてもらったんだけど、こういうのをJITプロビジョニングというらしい。知見。コレのおかげでドメイン層はダッシュボードの種類しか意識せず疎結合に出来てるし、依存の方向も単方向にできている)

ユーザー作成時に属性としてgroup_id等を渡しておけば、ダッシュボードに露出するデータは渡したgroup_idに紐づくようにできる。

権限処理すらこなしてしまうのである。実にエレガント。

逆にエレガントじゃない連携って?

例えば、ユーザーが存在しないと、「User not found!」と返してきて終わりのサービスも割とある。(ユーザーは料金体系に紐付いたりするので例として微妙かもだが)

その結果をもってユーザーを作る処理を入れないといけず、連携先の状態を意識しないといけないし、依存は双方向になってしまう。

もし依存が双方向なら、LookerEmbedUserみたいなドメインモデルとRepositoryを用意して、findById->(存在なかったらEmbedUserを作成) ->埋め込みUrlを取得 みたいな感じで書きたくなってしまう。

こうして結合度が上がっていき、エレガントではなくなる。

エレガントじゃない連携に対抗するには

個人的にはこの程度でドメインに顔は出したくないので、委譲処理内でユーザー存在確認->作成のようなretry処理を入れてしまい、委譲処理の一部として隠蔽し、ドメインからは追い出す。

自分が大規模システム作る時に組むアーキテクチャは委譲処理を書く場所をドメインとは別に設けていて、そこで頑張ることで単方向にできるようにしている。

エレガントさ

Lookerのすごいところはこういう層で頑張らなくても普通に単方向にできる点。

厳密にいうと連携というのは連携元、連携先双方に合意(tokenやユーザー,権限)がないと成り立たない。

Lookerのエレガントさを作っているのは以下の点。

  • 先にエンジニアが投入しておく設定(tokenやgroup_idによる権限で見れる範囲等)と、リクエストベースで自動で登録される設定(ユーザーやユーザー属性)の分離がめちゃくちゃうまい
  • JITプロビジョニングを想定した料金体系になっている

エンジニアが投入しておく設定で大まかな合意(システムtokenや権限)を完了し、システム側で細かい合意(ユーザー作成やそのユーザーと権限の紐付け等)をアトミックにやることで単方向の依存と疎結合な連携を可能にしている。

エレガントでない定義

以下を満たすものではないかと考えている。

  • 連携元に不必要な依存を要求し、双方向の依存を作る
  • 連携元に不必要なオブジェクトのライフサイクルを管理するよう要求し、連携元のドメインに侵食する

これらは連携元システムのアーキテクチャである程度対応可能だが、素直に書くとひどい密結合を生み出してしまう。 (サービスの料金体系の都合でJITプロビジョニングをできないパターンは普通にあるので、それをイケていないと言うつもりはないが、料金体系に関係ない部分でこのやり方が適用できる箇所はたくさんあるはず。)

SaaS連携にも設計の原則は適用できる

  • SaaSは、連携元システムから捉えると、マイクロサービス1要素と捉えることができる。(SaaS連携=マイクロサービス間連携)
  • 疎結合とか依存の方向が単方向なんてものは、設計界隈では太古の昔から存在するセオリー。メリットも自明で、それを適用しないのはもったいなさすぎる
  • セキュリティ要件が違うので、内部システムほど気楽に連携できるものではないので考慮すべきポイントは多いが、Lookerのようにエレガントな連携は実現できる。
  • 結合が強すぎると、それは分散モノリスであり、マイクロサービスでなくなってしまう。
  • 連携元システムに不必要に分散モノリスとしての振る舞いを求めるSaaSやマイクロサービスはイケていない。
    • 同じように、不必要に密結合にしてしまう連携元システムもイケていない。

認証と認可と課金とコアドメインを分離したシステムは勝てるという話

自分が複数のシステムの開発を経験して得た確信として、「認証と認可と課金とコアドメインの分離がめちゃくちゃ重要である」というものがあるので、コレを整理してアウトプットしていく

分離するモチベーションとは

Microservice文脈でいうと、デプロイ独立性だったり、リソースの最適配分だったり、障害の局所化だったり、開発組織とのマッピングだったりがメリットとして語られることが多い。

だが、ここで取り上げたいのは戦術的DDD的観点でのコンテキスト分離の有用性である。

※ちなみにコンテキスト分離のみであればモジュラモノリスだけで実現可能。

戦術的DDD的観点での関心事の分離によるメリットとは

コンテキストが分離されていることによって、境界をまたぐ際に「このI/Fは正しいのか?」を都度考えることを強制することができる。 境界がなければ意図しない密結合を生みやすくなってしまう。

もちろん、境界を超えるためのボイラープレートとトレードオフであるが、ドメインをシンプルに保つためのボイラープレートは、常識的範疇の量であればドメインがシンプルになる効果の方が大きすぎるので、開発速度や保守観点、開発する機能のクォリティに寄与し、十分ペイすると考えている。

処理を書く際に境界を意識し、都度「この境界は正しいのか」「境界またぐ時、ここだけ異常にめんどうじゃね?」と考える機会はとても重要で、明確な境界がなければ意図せず不必要な密結合を生み出してしまいがちなところを強制的に考えさせる機会を設けることで、ある種モデルの洗練に強制力をもたせることができる。

(最近自分が自社で実装した)AuthzIOのPermissionReason(権限付与理由)などは「境界またぐ時、ここだけ異常にめんどうじゃね?」から生まれたものだったりする。(例がわかりにくいかもだが、情報の正引き,逆引き両方を効率良く行えるデータ構造を考える機会になった。)

ただ、この部分は定点観測して定量的に差が出せるものでもないので、「メリット」として捉えるのに時間がかかる部分かもしれない。

個人的には1サービス内のHttpAdapter,SecondaryAdapter,UseCase,Domain等の横の境界の有用性と同じくらい有用で、一定の複雑度を持つことがわかっているシステムであれば最初から切るべき境界だと確信を持っているし、モデルの正しさ、汎用性、使い勝手の良さで勝負するシステムではなおさら重要である。(なので縦のドメイン境界を切るモジュラモノリスやマイクロサービスをやっている)

認証と認可と課金とコアドメインの分離は何が良いのか

では、何を分離するのか。という話になるが、何を分離するのかはむずい(おいおい)

ただ、その中でも認証、認可、課金とコアドメインってのはなんの疑いもなく1000%分離すべきものである。

コアドメインの中をどう分離するのかはやりながら考えるしかないが、こいつらはとりあえず分離しておけばサービス成長に伴う成長痛を最低限に抑えることができる。

サービス成長に伴う成長痛とは

急速な機能開発によりシステムに歪な状態が埋め込まれ、それを解消するためにアーキテクチャ変更やリファクタリング、データ移行等が必要になるような状況を指す。

新規機能開発の一時停止や停止メンテを厭わなければ後は馬力でなんとかなるではあるが、相当の馬力を要するのでその馬力はもっと有用な、素敵な機能の開発などに回したいし、ここに馬力を発揮するのはきつい。

当たり前の状態にするための馬力と素敵機能を開発するための馬力は後者のほうが心にも優しい。

成長痛を最低限に抑えられる理由

小規模な時にばーっと書いちゃって密結合になりやすい筆頭の関心事が認証、認可、課金とコアドメインで、分離するのにメチャクチャな労力を要する。(分離なんてきれいな言葉ではなく、もはや引きちぎるに等しい。)

ここをスキップさえしてしまえば後はコアドメインの洗練や分離に注力できるし、ここの分離さえできていれば、かなりの要件に迅速かつ柔軟に対応ができるので無駄な痛みを抑えられると考えている。

開発を止めるレベルでのリファクタリングや停止メンテを必要とする変更は、自分の経験上はすべて認証、認可、課金周りだった。

これらはコード上だけでは済まない外部要因が絡むため、整合性を保つためにかなりの労力を要する。

  • 課金ならPaymentGateway側のデータと自DBに持つデータの整合性
  • 認証ならフロントとJWTのやり取りだったり(全員強制ログアウトさせてOKなら何も考えなくてOKだが・・・)、FirebaseのようなIDaaS
  • 認可なら認証,課金,コアドメインに食い込みまくってる

等など。

引きちぎると形容したように、整合性のために完全には分離できないことが多い。 引きちぎり元に残った患部とはシステムの寿命が続く限りメンテし続けるか、またまた停止メンテを行い連携先と自分らのデータに破壊的な変更を加えて整合性を保つかだが、後者の判断を下すにはかなりの合理性がないと労力に見合わないので大抵はメンテし続けることになる。

コアドメインであれば、データは自分たちのDBに入っているし、入るまでの経路も全て自分たちでハンドリングしている。 変更を加えるのは比較的現実的である。

また、サービス成長に伴ってお金に余裕がでてきたときにマーケティング系の施策をうつことが多くなる。 その時点で分離してないと「そんな要件みたせねーよ」といった機能を求められる。(キャンペーンうつぞ!最初から課金してくれてる人に感謝のほげほげ!等等)

Dropboxの「最初期課金ユーザー永遠無料」みたいな施策は、認可、課金が分離してない状態で実現するのは地獄。

※課金のデータを日付で漁って最初期課金ユーザーだと判定できれば●●を許すみたいなコードが各所に散るか、最初期ユーザーリストみたいなのをもって毎回参照する等。キャンペーンの種類が増えるたびにユーザーリストを作るのは現実的ではない。

分離されていれば「最初期課金マン」みたいな権限用意して権限チェックのときに考慮するだけで済む。

施策を最適なタイミングで最速でうてるのはかなり強いと思う。

まとめ

システムにおける表現力は、あっちを立たせればこっちが立たずみたいなバランスが存在していて、ある要件Aに沿って実装しすぎると後から出てきた要件Bへの対応が難しくなる。みたいな力学がある。コンテキスト分離はこのバランスを保つためにとても重要で、バランスを保つことでシステムの表現力を失わずに済む。

早い段階で取り組むことにより成長痛を抑えつつ、分離による表現力を高めることで今後起こり得る要件に柔軟に対応しつつ、痛みなく爆速で成熟していけるので結果勝てると思う。という話でした。