Railsのアプリケーションの設計について考える機会があったのでその過程で考えたこととをまとめておきます。
(2022/03/22) 追記
この記事の発展版、Layered Architectureの勘所という記事を書きました。
Railsをやってた頃はActiveRecordの便利さについ流されてしまいましたが、今やるなら
- ActiveRecordのモデルとEntityの分離する
- Entityはimmutableにする
というあたりをもっと徹底するのが良いと思います。
背景
Railsをメインで使用している企業で新規のWebアプリケーションを0ベースで作ることになった。
その企業はRailsでかなり大規模なアプリを運用しており、Rails自体の知見は十分。
一方で伝統的なRailsでの開発手法に大規模開発の限界も感じており、この機会に設計のPrcaticeを模索したいという要望もあります。
筆者の方はアプリケーションの設計にはそれなりに自信がありますが、普段使用している言語はScalaやNodeJS(TypeScript)がメインです。
Railsでの開発は何度もやっており、特に開発に不自由することはありませんが、フレームワークの細かい癖などはあまり把握していません。
今回の要件では元々のチームのスキルを活かすために、Rails自体を別の言語やフレームワークに置き換えることは考えません。
Railsの良さを活かしつつモダンな設計手法を取り入れるにはどうすれば良いかを考察するのがこの文書の主目的になります。
基本設計方針
Clean Architectureを下敷きにします。
Clean Architecture(以下CA)はここ数年に急速に人気の出てきた設計手法で、日本でも日本語版の書籍発行をきっかけ多くのエンジニアがそれに言及するようになりました。
筆者自身も2、3、CAをベースとした開発を経験(自分で設計したものも、人の設計に乗っかって開発したものもあります)しており、このやり方はとてもイケてると感じています。
一方でCAを既存のWebアプリケーションフレームワークに載せる際の課題もいくつか感じており、そこに考察を加えて自分なりの勝ちパターンを見つけたい、と考えたこともこれをわざわざ文章化しようと思った動機の一つです。
ちなみに今の今、この節を書いている時点ではまだモヤモヤしているポイントがいくつかあるので、それらが文章化することによって整理されると良いなという願いもあります。
CAとRailsの相性の悪さ
さて、RailsにCAのエッセンスを適用とするにはどうすれば良いのか?という命題についてこうして考え始めたわけですが、いくらもしないうちにその相性の悪さに躓くことになります。
これはよく考えると当たりまえの話です。
何故なら、Clean Architectureは「使用するフレームワークに関わらず通用する普遍的な設計手法」を目指したものであり、一方でRailsなどのアプリケーションフレームワークは「フレームワークに依存させることによって開発コストを最小にする」ことを目指したものだからです。
つまり、最初から目指している方向が真逆なので相性が良いはずがありません。
特にRailsは「フレームワークに依存させることによって開発コストを最小にする」ことを極限まで追求したような化け物フレームワークなので相性は最悪と言えます。
CAではこの問題に対する解として、同心円の内側(EntityとUsecase)ではフレームワークの機能を使わないで実装すべし、としています。
実際これまで関わったプロジェクトでは(完全ではないにしても)そういうアプローチを取っていました。
Railsでも同じアプローチを取ることはできますが、そうするとRailsの良さを完全にスポイルすることになるので、それが良いとは正直言い切れません。
実際、Rails Wayでやれば3行で済むような処理がCAの作法にのっとると複数ファイルにまたがって数十行のコードが必要になったりすることもあるので、それがコストに見合っているかどうかは疑問です。
ていうか、自分ひとりで開発するのであれば間違いなくRails Wayを選択すると思うんですよね、この場合。。。。
おそらく世のRailsプログラマも同じように感じる人は多いはずです。
予想ですが完全にCA準拠したやりかたはRailsエンジニアには受け入れられない可能性が高い。
つまり、この場合Railsの良さを残しつつCAのエッセンスを取り入れる新しい方法を考える必要がある、というのがこの節の結論です。
(CA準拠のやり方を採用するのであればそれを納得させるだけの強い理由が必要と思いますが、ちょっと思いつきません。前述の通り目指しているところが違うので、各々の軸における優位性を並べることはできてもそれは一種の宗教戦争のようなもので、他方を納得させることができる気はしないです。また、個人的にもどちらか一方が正しくて他方が間違っているとは思いません。)
CA EntityとActiveRecord
RailsといえばActiveRecordです。
Railsの利便性はActiveRecordの存在によるところが大きく、ActiveRecordを使わないのであればRailsを使う意味がないと言っても過言ではありません。
CAに則るならEntityはActiveRecordとは無関係な形で再定義するべきですが、RailsにおいてはここでActiveRecordを排除するのは得策ではないと思っています。
動的型付け言語であるRubyではinterfaceは作れず、DuckTypingだよりでObjectを扱うだけなので、単純なモデルの場合は再定義したところで、結局ActiveRecordと交換可能なオブジェクトになる可能性が高い気がするんですよね。。。
メソッドに引数として渡すオブジェクトがCAのEntityであってもActiveRecordであっても動いて、かつそれを制限する方法もないのであれば、再定義が徒労感が伴うだけの苦痛な作業になってしまうことを危惧します。
であれば、この際単純なテーブルマッピングで済むようなEntityはActiveRecordのモデルをそのまま使うのもアリではないかと。
ただし、ActiveRecordに直接ビジネスロジックに関わるメソッドを定義するのが良いとは思わないので、そうしたメソッド定義が必要な場合は再定義します。
また、例えば請求書のようなDB上は複数のテーブルにまたがって保存されるけれどビジネス上のエンティティとしては不可分なものも再定義した方が良いと思っています。
再定義が必要なものとそうでないものの境界がイマイチあいまいですが、とりあえずは最初はActiveRecordを直接使っても良くて、必要を感じた時に再定義するというルールでスタートしてもまぁ良いかと思っています。(これは開始時点のプロジェクトメンバーが少ないことを踏まえての選択です。最初からメンバーの多いプロジェクトの場合はもう少し慎重に考えた方が良いとも思いますが、そんな大規模のプロジェクトとはそもそも縁がないのでこれ以上の考察はここではしません。)
再定義する場合も完全にActiveRecordを排除する必要はなく、ラップする形で定義すれば十分です。
例えばこんな感じ
# 請求書
class Bill
initialize(ar_model)
@ar_model = ar_model
end
def corp_name
@ar_model.corp_name
end
# 請求明細
def bill_details
# Relationを表に出したくないのでto_aする
# 必要ならコンストラクタで再定義したEntityにmapする用に修正する
@ar_model.bill_details.to_a
end
end
問題点はActiveRecordに山盛りある副作用を伴うメソッド(saveとかupdateなど)が、どこからでも呼び出せてしまうことですが、これもとりあえずは「repository以外のファイルからは副作用のあるメソッドの使用を禁止する」をルール化していおけば十分な気がしています。(これも少人数ならではの選択です。実際にやるかどうかは別としてこのルールはLintでチェックすることも可能だと思います。)
正直ここはやってるうちに気が変わるかも、と思わないでもないですがとりあえずスタートはこんな感じで。(少人数以下略)
ディレクトリ構成
RailsではないプロジェクトでCA構成をやった時のディレクトリ構成は以下のような感じでした。
- domain
- <ドメイン名1>
- <Entityファイル>
- <Entityファイル>
- <ドメイン名2>
- <Entityファイル>
- <Entityファイル>
- ...
- repository
- <ドメイン名1>
- <Repositoryファイル>
- <ドメイン名2>
- ...
- usecase
- <ドメイン名1>
- <Usecaseファイル>
- <Usecaseファイル>
- <ドメイン名2>
- ...
ドメイン名のところには「User」とか「Order」のようなアプリケーションが扱う領域を大きく区切った名前が入ります。
Scalaのプロジェクトの場合はサブプロジェクトを使うことによって、domain層のファイルが絶対にusecaseやrepositoryに依存できないようにもできるのでこの構成には意義があります。
では、Railsの場合はどうか?
残念ながらRailsではそのような依存関係の制限はできないように思います。
問題点はもう一つあって、Railsではautoloadに沿った命名が強く推奨されているので上記構成の場合、Module名+Class名が
- Domain1::Entity1
- Domain1::Domain1Repository
- Domain1::XXXXUsecase
のようにEntity, Repository, Usecaseのすべてが同じ名前空間になってしまいます。
これは非常によろしくない。
もう一段、階層をさげて、
- Entity::Domain1::Entity1
- Repository::Domain1::Domain1Repository
- Usecase::Domain1::XXXXUsecase
としても良いですが、日本語話者的には主たる修飾が前に来る方が自然なので、
- Domain1::Entity::Entity1
- Domain1::Repository::Domain1Repository
- Domain1::Usecase::XXXXUsecase
の方がしっくりきます。
なので、これを踏まえてディレクトリ構成は以下のようにしてはどうかと思っています。
- app
- domain
- <ドメイン名1>
- entity
- <Entityファイル>
- repository
- <Repositoryファイル>
- usecase
- <Usecaseファイル>
- <ドメイン名2>
この方針を取ると仮定して、事前に思いついた問題点と回答は以下です。
「User」とか「Order」のようなありふれた名前がトップレベルのModule名になる。
扱う領域名がトップレベルに現れること自体はわかりやすいのでむしろ良いことだと思っています。
ただ、Railsでは慣習的にActiveRecordをmodels直下に定義することが多く、「User」や「Order」などは思いっきり名前がかぶりそうなので、ActiveRecord側を「AR::User」とするなど、module配下に置く必要があります。
ドメイン定義の方を「Domain::User::Entity::User」とする、という案もあるんですが、「DomainのUser」ではなく、「DomainがUser」なので、修飾としてしっくりこず冗長な気がしています。(個人的にはこうした日本語的にしっくりくる、みたいな感覚は大事にしています。外国人メンバがいる場合はまた別ですが。)
あと、ActiveRecordのモデルがそれに属することがわかる命名であることも優位があると思うので、可能であればActiveRecord側の命名を変える方が良いです。
ちなみに前節で「ActiveRecordを直接DomainのEntityとして使うのもアリ」と書きましたが、ActiveRecord自体は一覧性を重視して従来どおりmodels以下に置くつもりです。
(これを書いている時点ですでに、やっぱりDomain/Entityとして薄いラッパーを必ず定義するようにした方が良いのでは。。。と思い始めてますが、そこは作りながら考えます。)
Usecaseは複数のドメインオブジェクトをまたがって扱うことがある
そうですね。別にいいんじゃないでしょうか。
EntityとRepositoryはそのドメイン内で完結するべきと思いますが、Usecaseは複数ドメインを扱っても特に問題はないと思います。
ファイルの置き場所としてしっくりこないなら、EntityやRepsitoryの無い別ドメインを定義してUsecaseだけを置いても良いです。
蛇足ですが、Repositoryでは同じドメインのEntityモジュールをincludeしても良いと思っています。
ですが、Usecaseはダメです。(というか、コンストラクタで各種Repositoryを渡すならばModule名修飾の名前が出てくる場面はほぼ無いとも思います。)
簡便化
以下、CAの教科書的ではないですが開発速度をあげるためにやろうと思っている内容です。
IDでのEntity取得はドメインModuleのmodule functionにして良い
こんな感じ
module Order extend self
order_repository
@order_repository ||= Order::Repository::OrderRepository.new
end
module_function
def get_order_by_id(order_id)
order_repository.get(order_id)
end
end
ID指定でのEntity取得は、デバッグの際にも使用したい場面があるので必要ならそのショートカットを作っても良いというルールです。(すべてのEntityに対して必ず作るということではありません。)
routesのGET /XXXXs/:id
に相当する処理が、これで間に合うのであればUsecaseを定義する必要すらないと思いますが、実際にはほとんどの場合Permissionのチェック(上記Orderの場合は自分のOrder以外は不可視)が必要になると思うので、その場合はUsecaseが必要です。
usecaseをドメインModuleのmodule functionとして定義する
一般にUsecaseはそのコンストラクタで各種Repositoryを引数に取ります。
これはテスト時にRepositoryを差し替えられないとテストが書きにくいからです。
しかし、ControllerからUsecaseを使う際に毎回Repositoryを明示するのは面倒なので、ドメインModuleにそのショートカットを作ります。
こんな感じ
module Order extend self
order_repository
@order_repository ||= Order::Repository::OrderRepository.new
end
module_function
def create_order_usecase
Order::Usecase::CreateOrder.new(
order_repository
)
end
end
使う側は
val res = Order.create_order_usecase.run(...)
となって、やりたい内容が明快です。
Usecase側でデフォルト引数を指定するでも良いと思いますが、そこはまぁ好みの問題です。
テスト
正直Railsでのテストの知見はあまりなく、手探り状態です。
また、工数的にもフルでテストを書く余裕はなく、必要なところから部分的に書いていく、という形になると思っています。
なので基本的には、
- ロジックのあるEntity
- Usecase
を中心にテストが書ける形になってさえいれば、実際にテストを書くのは後回しになっても良いと割り切ります。
Repositoryが単純なActiveRecordのラッパーであるならそのテストは優先度最下位で良いです。
(外部サービスに依存するものであれば別途テストやMockが必要です。)
Entityのテストは特に問題になるところはないでしょう。
Usecaseの方は物によってはテストを書くのは面倒なケースがありますが、一般にUsecaseは
- validate - 入力パラメータのValidation
- collect - 必要なEntityをRepositoryから収集
- execute - Usecaseで行いたい処理の実行
の3ステップからなることが多く、テストしたいのはexecuteの部分だけだったりもするので必要に応じてexecuteの部分だけテストできるようになっていれば良いと思います。
こんな感じ
class Domain1::Usecase::HogeHogeUsecase
initialize(domain1_repository)
@domain1_repository = domain1_repository
end
def run(params)
error = validate(params)
return ErrorCase.new(error) if error
[v1, v2, error] = collect(params)
return ErrorCase.new(error) if error
return execute(v1, v2)
end
def validate(params)
...
end
def collect(params)
...
end
def execute(v1, v2)
...
end
end
一応これで永続化されたデータの状態を気にせずテストを書けるようになるはずです。
まとめ
以上、開発初期に考えたことをまとめてみました。
文章化したことによって自分の中での整理が進んだことが最大の収穫ですかね。
自分のRails力は決して高くはないし、賛否の分かれる部分もあると思います。
でも、まぁそんなのは割とどうでも良いです。現場での設計は万人受けする必要はないのです。
ただチームのメンバにこのプロジェクトは「こういう方針で作っている」という思想が共有され、一貫性が保たれていればそれで十分だと思います。
正解を求めるのは学問の分野の仕事で、現場のエンジニアがそれを求めてしまうとドツボにはまるだけだとも思います。
すべてを肯定的に捉えることは無理でも、何かしら参考になる点があれば幸いです。
作っている過程で、何か変える部分がでてくればまた追記します。
(何故なら、この文書こそがチームメンバに「こういう方針で作っている」を共有するためのドキュメントそのものだからです。)