素のRailsは十分に豊かである(翻訳)
はじめに
「Railsは関心の分離が不十分である」という批判をよく目にします。状況が深刻になったら、Railsに足りない別のピースを導入しなければならないというのです。しかし私たちはそうは思いません。
「素のRails(vanilla Rails1)ではここまでしかできない」みたいな批判を耳にすることがよくあります。Railsはアーキテクチャレベルで関心の分離が不十分なのだから、アプリはいずれメンテナンス不能になり、足りないピースを導入するという別のアプローチが必要になるというのです。
代表的なDDD(ドメイン駆動開発)書籍では、概念上の4つの層である「プレゼンテーション層」「アプリケーション層」「ドメイン層」「インフラストラクチャ層」について議論しています。
アプリケーション層は、ドメイン層と協調動作してビジネスタスクを実装します。しかし、Railsが提供しているのは「コントローラ」と「モデル」のみです。モデルはActive Recordによる永続性を含んでおり、Railsではコントローラからモデルに直接アクセスすることを推奨しています。
Railsへの批判では、アプリケーション層、ドメイン層、インフラストラクチャ層は必然的に1個のファットモデルにマージされて混乱の元になるとされています。実際、別の設計手法では、アプリケーション層にServiceやUseCase Interactorを含めたり、インフラストラクチャ層にRepositoryを含めるといった形で置き場所を追加するのが常套手段です。
私たち37signalsは素のRailsとドメイン駆動設計のどちらについても大ファンなので、これはとても興味を惹かれる議論だと思います。私たちの場合、アプリを進化させるときにいわゆるメンテナンス問題に遭遇することはありません。そこで本記事では、私たちがアプリケーションコードをどのように整理整頓しているかについて議論してみたいと思います。
🔗 アプリケーションとドメイン層を区別しない
私たちの場合、アプリケーションレベルの成果物とドメインレベルの成果物を分離せず、代わりにドメインモデル(Active RecordとPORO2の両方)のセットをpublicインターフェイスとして公開し、システム境界(通常はコントローラやジョブ)から呼び出されるようにしています。アーキテクチャ上は、このドメインモデルをAPIから分離しません。
私たちは、これらのモデルや公開するAPIの設計方法については細心の注意を払いますが、それらへのアクセスをオーケストレーションする追加の層についてはほとんど価値を見出していません。
言い換えれば、私たちはコントローラのアクションを実装するのにデフォルトではServiceもアクションもCommandもInteractorも作成しません。
🔗 コントローラはドメインモデルに直接アクセスする
単純なシナリオであれば、コントローラからの純粋なCRUDアクセスで問題ありません。たとえば、以下はBasecampでメッセージやコメント用のBoost3を作成する方法です。
class BoostsController < ApplicationController
def create
@boost = @boostable.boosts.create!(content: params[:boost][:content])
end
end
しかし、より頻繁に用いられる方法は、それらのアクセスをドメインモデルが公開するメソッドを経由して実行することです。たとえば、HEYの以下のコントローラは、指定の連絡先で使いたいボックスを選択します。
class Boxes::DesignationsController < ApplicationController
include BoxScoped
before_action :set_contact, only: :create
def create
@contact.designate_to(@box)
respond_to do |format|
format.html { refresh_or_redirect_back_or_to @contact, notice: "Changes saved. This might take a few minutes to complete." }
format.json { head :created }
end
end
end
モデルに直接アクセスするこの方法は、ほとんどのコントローラで用いられます。モデルはメソッドを公開し、コントローラはそれを呼び出します。
🔗 リッチなドメインモデル
私たちの手法では、Martin Fowlerの言う「ドメインモデル貧血症4」と対照的な、"リッチな"ドメインモデルの構築を推奨しています。私たちはドメインモデルをアプリケーションAPIとみなしており、設計法則の指針として、そのAPIができるだけ自然なものに感じられるようにしたいと考えています。
私たちはドメインモデルを介してビジネスロジックにアクセスすることを好んでいるため、コアとなるいくつかのドメインEntityは、かなり多くの機能を提供することになります。あの恐ろしい「ファットモデル」問題を私たちはどのように回避していると思いますか?
私たちの場合は、以下の2つの戦術を用いています。
- モデルのコードをconcernsで整理する
- 機能をオブジェクトの追加システムに委譲する(純粋なオブジェクト指向プログラミングとも呼ばれます)
例をひとつ挙げて説明しましょう。BasecampにおけるコアドメインEntityはRecording
です。Basecampでユーザーが管理するほとんどの要素は「記録」であり、これはRailsのActiveRecord::DelegatedType
が作られるきっかけとなったユースケースでもあります。
「記録」を用いて、他の場所へのコピーや記録の"焼却"(incineration: データを永久に削除するというHEYの用語)といったさまざまなことが行なえます。私たちは、コントローラやジョブといった呼び出し側に以下のような自然なAPIを提供したいのです。
recording.incinerate
recording.copy_to(destination_bucket)
しかしデータの焼却とデータのコピーは、内部でそれぞれの責務がかなり異なります。そこで、以下のようにconcernsを用いてそれぞれの責務をキャプチャしています。
class Recording < ApplicationRecord
include Incineratable, Copyable
end
module Recording::Incineratable
def incinerate
# ...
end
end
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
# ...
end
end
私がここで用いたconcernsの書き方についてご興味のある方は、以下の記事をどうぞ。
参考: Code I like (III): Good concerns
さて、焼却とコピーはどちらも複雑な操作であり、この2つの操作を実装する場所としてRecording
はふさわしくないでしょう。その代わり、Recording
は処理そのものをオブジェクトの追加システムに委譲します。
焼却の場合は、Recording::Incineratable
がRecording::Incineration
を作成して実行します。このRecording::Incineration
は記録の焼却処理のロジックをカプセル化します。
module Recording::Incineratable
def incinerate
Incineration.new(self).run
end
end
コピーの場合は、Recording::Copyable
が新しいCopy
レコードを1件作成します。
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
copies.create! destination_bucket: bucket, destination_parent: parent
end
end
ここは少々複雑な部分です。Copy
はFiling
の子クラスです。そしてFiling
は「コピー」と「移動」の両方の操作で共通の親クラスです。あるfilingが作成されると、このfilingは最終的に#process
メソッドを呼び出すジョブを1件キューに入れます。この#process
メソッドはfile_recording
を呼び出します(子クラスが実装するTemplate Methodパターン)。このメソッドを実装すると、Copy
はコピーを実行するためのRecording::Copier
インスタンスを作成します。
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
copies.create! destination_bucket: bucket, destination_parent: parent
end
end
class Copy < Filing
private
def file_recording
Current.set(person: creator) do
Recording::Copier.new(
source_recording: source_recording,
destination_bucket: destination_bucket,
destination_parent: destination_parent,
filing: self
).copy
end
end
end
class Filing < ApplicationRecord
after_create_commit :process_later, unless: :completed?
def process_later
FilingJob.perform_later self
end
def process
# ...
file_recording
# ...
end
end
この例は焼却の例ほどシンプルではありませんが、原理は同じです。つまり、リッチな内部オブジェクトモデルが、ドメインモデルの高レベルAPIの背後に隠蔽されているわけです。これは、concernsを実装するときは常に追加クラスを作成せよという意味ではありません。しかしこの複雑さに見合う正当性が得られるなら、私たちは追加クラスを作成します。
concernsを用いることで、巨大なAPIサーフェス(露出面積)を持つクラスによるアプローチがうまく機能するようになります。単一責任原則(SRP: Single Responsibility Principle)について考えたことがおありでしたら、『レガシーコード改善ガイド(Michael Feathers)』で述べられているように、単一責任原則の違反が発生している場所が「インターフェイスレベル」なのか「実装レベル」なのかをしっかりと区別しなければなりません。
単一責任原則の違反でより注意を向けるべきは、実装レベルの違反である。要するに、「そのクラスは本当にすべての作業を自分でこなしているのか」、それとも「そのクラスは単に他のいくつかのクラスに委譲しているだけなのか」を気にかけること。委譲しているのであれば、巨大なクラス1個でまかなう代わりに、多数の小さなクラスと、それらの受付となるファサードクラス1個を用いることで管理しやすくなる。
私たちのコード例には、多くのものをたらふく抱え込んだファットモデルは存在しません。Recording::Incineration
クラスもRecording::Copier
クラスも、それぞれ1つの作業を凝縮したクラスです。Recording::Copyable
は、高レベルの#copy_to
をRecording
のpublic APIに追加し、関連するコードとデータ定義をRecording
の他の責務から切り離します。さらに、これがRubyによる古き良きオブジェクト指向そのもの、すなわち「継承」「オブジェクトのコンポジション」「シンプルなデザインパターン」であることにもご注目ください。
最後にもうひとつ。
以下の3つの書き方はどれも同等だと言う人もいるでしょう。
recording.incinerate
Recording::Incineration.new(recording).run
Recording::IncinerationService.execute(recording)
私たちにはこの3つが同じとは思えません。1つ目の形式こそ望ましいと強く思います。
理由の1つは、他の2つよりも複雑さをうまく隠蔽している点です。コードを呼び出す側にコンポジションの負担を押し付けていません。
もう1つの理由は、平易な英語のように自然に感じられる点です。つまり、よりRubyらしく感じられるということです。
🔗 ところでServiceはどうよ?
DDDの建築材料のひとつである「Service」は、ドメインEntityやValue Objectに自然な置き場所が見当たらないような重要なドメイン操作をキャプチャすることを意味します。
私たちの場合、ServiceをDDDの意味(ステートレス、動詞を名前に持つ)におけるアーキテクチャの第一級成果物としては使っていませんが、操作をカプセル化するために存在するクラスならたくさん使っています。私たちはそうしたクラスを「Service」とは呼びませんし、特別扱いもしません。私たちは多くの場合、そうしたクラスを「操作を呼び出すために単なる手続き的構文を使う」ためではなく、「必要とされる機能を公開するドメインモデルとして示す」ことを好んでいます。
たとえば以下のコードは、Basecampに招待トークンで新規ユーザーを登録します。
class Projects::InvitationTokens::SignupsController < Projects::InvitationTokens::BaseController
def create
@signup = Project::InvitationToken::Signup.new(signup_params)
if @signup.valid?
claim_invitation @signup.create_identity!
else
redirect_to invitation_token_join_url(@invitation_token), alert: @signup.errors.first.message
end
end
end
class Project::InvitationToken::Signup
include ActiveModel::Model
include ActiveModel::Validations::Callbacks
attr_accessor :name, :email_address, :password, :time_zone_name, :account
validate :validate_email_address, :validate_identity, :validate_account_within_user_limits
def create_identity!
# ...
end
end
つまり、「新規登録」というドメイン操作を担当する SigningUpService
ではなく、アプリでIDを検証して作成できるSignup
クラスを用意するのです。
「それってServiceやForm Objectにシンタックスシュガーをちょっぴり加えたのと大差ないのでは?」という見方もできるでしょう。しかし私の目には、Rubyによる純粋なオブジェクト指向そのものが、ドメインの概念をコードで適切に表現しているのが見えます。
さらに、私たちはドメインモデルが永続化されるかどうか(Active RecordかPOROか)をさほど重要視しません。ビジネスロジックのコンシューマー側から見ればどちらであっても構わないので、私たちはコード内でドメインEntityとValue Objectの区別をキャプチャしません。私たちにとってはどちらもドメインモデルです。私たちのアプリのapp/models/フォルダには多くのPOROが配置されています。
🔗 アプリケーション層を分離することの危険性
アプリケーション層の分離について私が最も問題だと思うのは、「ついやりすぎてしまいがち」な点です。
オリジナルのDDD本では、Service乱用の問題について警鐘を鳴らしています。
さて、もっとよくある間違いは、振る舞いを適切なオブジェクトに適合させる作業を早々に諦めてしまい、徐々に安直な手続き的プログラミングに手を出してしまうことである。
『実践ドメイン駆動設計( Vaughn Vernon)』にも同じアドバイスがあります。
ドメイン概念をServiceとしてモデリングする手法に頼りすぎてはいけない。状況がふさわしい場合にのみ行うこと。この点に注意しておかないと、そのうちServiceをモデリングの「銀の弾丸」として扱うようになるだろう。Serviceを一つ覚えで使いまくると、たいていの場合ドメインモデル貧血症という残念な結果に終わる。そうなると、あらゆるドメインロジックがServiceにばかり置かれてしまい、EntityやValue Objectにはほとんど置かれなくなってしまう。
どちらの本も、アプリケーション層を分離することの困難について議論するときに、最初にドメインとアプリケーションServiceを区別するときの微妙なニュアンスについて説明しています。さらにどちらの本も、ほとんどのレイヤードDDDアーキテクチャにおいて、プレゼンテーション層がドメイン層に直接アクセスする場合もあるという「ユルさ」を認めています。オリジナルのDDD本では、DDDを可能にするのは「ドメイン層の決定的な分離」であると述べつつ、「プロジェクトによってはユーザーインターフェイスとアプリケーション層を厳密に区別しないこともある」とも述べています。
しかしRails界隈では、「コントローラがモデルと直接やりとりするなど絶対許されない」「代わりにコントローラとモデルの間には中間オブジェクトを置くべきだ」(例: DDDにおけるアプリケーションサービスや、クリーンアーキテクチャにおけるInteractorなど)という主張をよく見かけます。
彼らの推奨するアーキテクチャはそうした微妙なニュアンスを取りこぼしており、以下のどちらかの形を取っているだろうと私は信じています。
- 定形コードが山ほどやってくる(アプリケーションレベルの要素の多くは、単にいくつかのドメインEntityに対する操作を委譲しているため)
アプリケーション層にビジネスルールを含めてはならず、アプリケーション層は、処理を調整して下の層のドメインオブジェクトに委譲するだけのものになる。 - ドメインモデル貧血症
アプリケーションレベルの要素はビジネスルールを実装するものであり、ドメインモデルはデータを運ぶだけの抜け殻になる。
このような方法は、しばしば非常に複雑な問題(すなわちソフトウェアを正しく設計するにはどうすればよいか)に対する「トレードオフのない解法」という形を取ってお披露目されます。そうした方法ではしばしば「典型的な手法をかき集めて使いさえすれば、よい設計になる」ことがほのめかされますが、これは単に無邪気という言葉では済まされず、経験の浅い学習者をひどく誤解させてしまいます。
真に実用的な本物の手法を探し求めている人たちが、本記事で紹介したアイデアに共感してくれることを願っています。
🔗 まとめ
私たちの経験では、本記事で紹介した素のRailsによる方法を用いることで、巨大Railsアプリがメンテナンス可能になります。
最近の例で言うと、Basecamp 3の上に構築されたBasecamp 4をリリースしました。Basecamp 4のコードベースのほとんどは9年もの歳月を経ており、コントローラ400個にモデル500個という規模でありながら、毎日数百万ものユーザーにサービスを提供しています。私たちの方法がShopifyの規模でもうまくいくかどうかまではわかりませんが、Railsを用いるほとんどのビジネスでうまくいくと思います。
私たちの方法には、Railsドクトリンの柱の1つである「パラダイムを1つに絞らない(No one paradigm)」が反映されています。
私はアーキテクチャのパターンが好きですが、開発者たちがパターンをコードに落とし込もうとすると、人が変わったように非常に独善的になりやすいという問題がこの業界で繰り返されています。その理由は、ソフトウェア開発という厄介な問題に取り組んでいると、シンプルなレシピをひたすら厳守するという方法論が抗いがたいほど魅力的に見えてしまうからだと思います。
37signalsのコード品質は、私のキャリアでこれまで目にしたコードの中でも最高峰です(コードのほとんどは私が書いたものではないので、あくまで観客目線の感想です)。とりわけ、DDDの建築材料をほとんど使っていないにもかかわらず、DDDの原則を最大級に具現化している点が見事です。
そういうわけで、もし皆さんが素のRailsで進む道を断念して、画面操作を扱う必要が生じるたびに「この定形クラスを本当に追加していいんだろうか」とくよくよ悩んでいるのでしたら、どうか素のRailsでもアプリケーションのメンテナンス性を損なわない別の方法があることを思い出してください。別の方法にしたからといって新しい書き方を学ばなくてよいわけではありません(そんな都合の良いものはありません)が、再び幸せの境地を取り戻せる可能性はあるのです。
- 本記事に貴重なフィードバックを下さったJeffrey Hardyに感謝いたします。Jeffreyは、私が学び愛して止まない「素のRailsアプリケーション」アーキテクチャの主要コントリビュータの1人です。
-
写真: Johannes Plenio(Unsplash)
関連記事
- バニラ (ソフトウェア) - Wikipedia ↩
- PORO: Plain Old Ruby Object ↩
- Boosts - Basecamp Help ↩
- 参考: 技術/ソフトウェア設計/ドメインモデル(Domain Model), Service Layer周辺 - Glamenv-Septzen.net ↩
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。画像は元記事からの引用です。
参考: 週刊Railsウォッチ20221122「素のRailsは十分に豊かである」
本記事は、後に『私の好きなコード』シリーズに加えられました。