Open11

『Layered Design for Ruby on Rails Applications』読書メモ

ほげにしほげにし

問題設定

  • デフォルトのRailsには抽象化レイヤーがMVCの3つしかないので、ある程度大きくなってくるとツラくなる
  • Fat Model, Thin Controller でModelに処理を寄せることはある程度合意ができてきた
  • そうすると次はModelの責務が大きくなりすぎてくるので、それにどう対処していくか

という、実務規模でRailsアプリを運用したときにぶつかる課題に対して、

  • Layered Architectureを意識しながら
  • Rails Wayに沿った形で
  • 責務が膨らんだ部分を抽象化レイヤーとして切り出していく

という方法で、ツラくないRails拡張を目指していく。

ほげにしほげにし

Layered Architecture

4層構造のレイヤードアーキテクチャ。

[DDD]ドメイン駆動設計で実装を始めるのに一番とっつきやすいアーキテクチャは何か

これだとドメイン層がインフラ層に依存しちゃってるので、そこを逆転させたオニオンとかクリーンアーキテクチャが推奨されることが多いけど、本書ではレイヤードアーキテクチャを採用。
Rails自体が実際こんな感じになってる気もするので、実態と合ってていいのかもしれない。

※「アプリケーション層」だと責務がふわっとするので、松岡さんの『ドメイン駆動設計 モデリング/実装ガイド』に従って以降は「ユースケース層」と読み替える。
https://booth.pm/ja/items/1835632

ほげにしほげにし

RailsのMVCをLayered Architectureに当てはめると

書籍によると、こんな感じ。

(D. Vladimir『Layered Design for Ruby on Rails Applications』p.97)

ここで気になるのが、

  • Controllerはユースケース層じゃないの??
  • ユースケース層に対応するのがServiceクラスになってるけど、デフォルトのRailsではユースケース層が存在しないってこと??

というあたり。

ほげにしほげにし

Controllerはユースケース層じゃないの?

インフラ層は一旦置いておいて、RailsのMVCがレイヤードアーキテクチャの各層に対応してると考えるとすっきりするし、実際そう捉えている記事なども多そう。

でもこの本ではControllerはプレゼンテーション層になっている。
それはControllerの責務が、リクエストを受け取ってビジネスアクション(もしくは一連の手続き)に渡し、Viewをアップデートすることだから、とのこと。

The controller layer's responsibility is to translate web requests into business actions or operations and trigger UI updates.

(D. Vladimir『Layered Design for Ruby on Rails Applications』p.12)

これは確かにテストを書くと明らかで、Controllerがビジネスロジックを持ってしまっていると、実質ビジネスロジックをテストすることになり、色んなテストケースをカバーする必要が出てくる。
ただControllerはWebリクエストと密結合していて基本テストが書きにくいので、これはあんまりよくない。

なのでControllerはあくまでリクエストを受けてそれを誰かに渡し、結果をハンドリングするだけという、Scaffoldで作られるようなシンプルな形を理想形として考えることができる。

この場合ビジネスロジックのテストは別途それを担う部分で独立して書くので、Controllerのテストも非常にシンプルになる。

ほげにしほげにし

デフォルトのRailsではユースケース層が存在しないってこと??

MVCマッピングでの2つ目の疑問がこちら。
ユースケース層を担うのがServiceクラスしかないので、Serviceクラス使ってないデフォルトのRailsではユースケース層がないということになる。

これは実際どうなっているかというと、ControllerとModelがそれぞれ越境してユースケース層を担ってる(担ってしまっている)というのがよくあるケースなんじゃないかと。

Controllerによるユースケース層への越境

これはいわゆる Fat Controllerで、Controllerの中に手続的な処理を書いてしまっているケースが該当するのかなと。

Userのレコードを作成して、ユーザーに通知を送って、Subscriptionテーブルにもレコードを作る、みたいなことをControllerに書いちゃってるケース。

前提にもあったとおり、昨今ではこれを避けようという合意が取れてきているので、あんまりこういうことをしてるケースは少ないはず。

Modelによるユースケース層への越境

じゃあどうなるかというと、代わりにModelに処理が寄った結果、Modelがユースケース層も担ってしまっているケースがある。

典型的なのは悪名高いcallback処理で、さっきの例だとUserのafter_createで通知送ったり関連レコードを作ったりするようなケース。

ドメイン知識なのかユースケースなのか(Modelが持つべき知識かどうか)の判断ポイントとして、callbackに条件がつくかどうかは大きい。条件がつくというのはユースケース依存してる可能性が高いので、それは本来Modelで負う責務ではないはず。
こういったModelの責務超過を、状況に応じて適切な抽象化レイヤーに切り出そうというのが本書のメイントピックになる。
(callbackのタイプを分類して、許容できるものと避けるべきものを定義してる箇所も非常におもしろい)

ほげにしほげにし

Serviceクラスですべて解決するのか

本書の定義に従うと、Serviceクラスがユースケース層を担うことになっている。図にするとこんな感じ。

Controllerがリクエストを受け付けて適切なServiceに渡し、Serviceは適宜ドメイン知識を持ったModelを呼んで処理を行い、結果をControllerに戻す。Controllerはその結果を持ってViewを出力する。

レイヤーの越境もなくなってきれいに整理できてるが、残念ながらServiceクラスの導入ですべて解決するわけではない。

Serviceは何でもできてしまうがゆえに、困ったらすべての処理がServiceに置かれるようになったり、本来ドメイン知識として持つべき内容も抱えてドメインモデル貧血症になってしまったりしがち。

なので本書の主張としては、

  • 可能な限りServiceではなく、もっと責務を明確にした抽象化レイヤーに切り出す
  • でもアプリケーションの要件は常に変わるので、一時置き場としてServiceが残ることは許容する

という感じになってる。

ほげにしほげにし

Serviceじゃなければどこに切り出すのか

本書で挙げられてる主な抽象化レイヤー:

  • Query Object
  • Form Object
  • Filter Object
  • Presenter/Decorator
  • Policy Object
  • Notification Layer
  • View Component

もちろんこれ以外にもレイヤー抽出は可能で、基調講演のスライドにはWorkFlowオブジェクトなんかも出てる。

このあたりは以下の記事とも重なる部分が多そう。
https://techracho.bpsinc.jp/hachi8833/2021_01_07/14738

ほげにしほげにし

抽象化レイヤーの設計指針

各レイヤーの具体的な話は本書を読んでもらうとして、共通する抽象化の手順・指針みたいなのがよかったのでメモ。

できるだけRailsWay

オブジェクトの命名など、できるだけRailsWayの規約に則る。

We want to come along with Rails, not fight against it.
ぼくらはRailsと喧嘩したいわけじゃない。Railsとうまくやっていきたいんだ

必ず基底クラスを挟む

前項とも関係して、たとえばPolicyレイヤーを作るなら ApplicationPolicyを用意して各Policyはそれを継承する。他も同様に ApplicationForm, ApplicationNotifier などを用意する。

実際には各抽象化をやってくれる便利なgemを採用することも多いけど、直接使うのではなく基底クラスを挟むことで、インターフェースを統一できるようにする。
こうすることで、まず将来gemを変えることになったとしても、変更箇所を最小限にできる( Wrapperパターン )。

また環境やデータによって振る舞いを変えたい場合は、Adapterパターン を採用して簡単に切り替えられるようにすることもできる。

ほげにしほげにし

メソッド名をカラフルにする

Serviceオブジェクトのベストプラクティスとして、.call だけ公開インターフェースとして用意するcallableオブジェクトにすることが挙げられる。

Serviceを具体的な別オブジェクトに切り出していくにあたって、そちらもcallableオブジェクトなんだけど、役割に応じてもうちょっとカラフルな名前にすることが提案されている。

  • Queryオブジェクト: XxxQuery.resolve
  • Formオブジェクト: XxxForm.save (これはActiveModelと合わせて)
  • Filterオブジェクト:XxxFilter.filter

みたいな感じ。

gemで.callメソッドで定義されてる場合でもエイリアスを当てていて、このあたりはWrapperパターンがうまく効いていてよさげ。

ほげにしほげにし

違う考え方:ユースケース層をわけなくてもいいんじゃないか

37signalsの「素のRailsは十分に豊かである」で紹介されているアプローチは、本書の考え方とはちょっと違う。
https://techracho.bpsinc.jp/hachi8833/2023_01_12/124378

私たちは、これらのモデルや公開するAPIの設計方法については細心の注意を払いますが、それらへのアクセスをオーケストレーションする追加の層についてはほとんど価値を見出していません。
言い換えれば、私たちはコントローラのアクションを実装するのにデフォルトではServiceもアクションもCommandもInteractorも作成しません。

このアプローチも当然Modelに処理が集まることになるけど、それには以下2つの戦術で対応しているとのこと。

  • モデルのコードをconcernsで整理する
  • 機能をオブジェクトの追加システムに委譲する(純粋なオブジェクト指向プログラミングとも呼ばれます)

ユースケース層(アプリケーション層)を分離しないことについては、EvansのDDD本も引用した上で問題ないというスタンス。「ドメイン層」を他とわけることがとにかく大事で、他の層についてはそこまで厳密じゃなくてもよいと。

オリジナルのDDD本では、DDDを可能にするのは「ドメイン層の決定的な分離」であると述べつつ、「プロジェクトによってはユーザーインターフェイスとアプリケーション層を厳密に区別しないこともある」とも述べています。