以前 iOS/Android 開発を経験した際に知った Clean Architecture を、Ruby/Rails な API サーバー開発に適用してみた。
Clean Architecture といえば以下の図を元にした説明が多いが、
以下の記事を参考に、今回は Data/Domain/Infra/Presentation レイヤーで分けてみる。(これが一番しっくり来ました)
まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて
最終的なディレクトリ構造
Rails のルールに則り、controllers だけは app 配下に配置している。
app/
controllers/
layer/
data/
entity/
repository/
domain/
model/
translator/
usecase/
view_model/
helper/
infra/
external/
logger/
mail/
messages/
oauth/
payment_agent/
Data 層
Entity
ActiveRecord::Base
を継承したクラス。
こういったクラスは大抵 Fat Model になりがちだが、DB 操作は Entity ではなくRepository を介して処理する。Entity はあくまで永続化されるデータを表すのみ。
Repository
DB や Elasticsearch 等に対して CRUD 操作するクラス。
Entity に対する find/search/create/update/delete などのメソッドを実装する。注意点として、find
系のメソッドは ActiveRecord::Relation
は返却しないようにして、ActiveRecord の存在を隠蔽しておく。
Domain 層
ViewModel
View 層のためのデータ構造を持つクラス。
Ruby/Rails においては、変換コストを考慮して基本は Entity のラッパークラスとしておいて、Controller からレスポンスとして書き出す際に to_json
で JSON データに変換するイメージ。
Translator
Entity から ViewModel を効率的に生成するためのクラス。
Entity のリストから ViewModel のリストを生成する際に、Repository から一気に関連する Entity を取得する、などして効率化を図る。
Usecase
アプリケーションにおけるユースケース (ビジネスロジック) を記述するクラス。
Usecase は以下の流れで処理を行い、結果として State を返す。State オブジェクトには Usecase を実行したことにより得られた ViewModel が格納されている。
- validate
- 受け取ったパラメータから、Repository を介して Entity を取得
- 取得した Entity に対して検証を行う
- エラーの場合は invoke しない
- invoke
- Entity の CRUD 操作を行う
- Translator を用いて操作した Entity を ViewModel に変換する
- ViewModel をまとめた State 返す
- finalize
- 外部 API をコールするような処理を行う
Atomic Usecase
Usecase 内の共通処理が欲しくなったら、それもまた Usecase で実装する。これらは Atomic Usecase と呼ぶ。
- Atomic Usecase は Controller から直接呼ばないように注意すること
- Atomic Usecase の state については、例外的に ViewModel ではなく Entity を含める
- これは Atomic Usecase を呼び元である Usecase が Translator を用いて ViewModel を生成すべきだから
Domain Helper
Domain の中での共通関数を定義するモジュール。必要な Usecase の中で include する。
Helper 層
全てのレイヤーで利用されるヘルパーモジュールの置き場。
内容によっては gem 化して切り出すことを検討しても良い。
Infra 層
外部 API クライアント、ロギング、i18n、キャッシュ実装...etc の置き場。
Presentation 層
Controller
WAF の Controller 層を記述するクラス。
ここは Rails の Controller をそのまま利用する。リクエストパラメータの検証、認証チェックなどを行い、Usecase を実行し、その結果である ViewModel をレスポンスとして返す。
悩んだ (悩んでる) ところ
Entity の初期化処理をどこに記述するか
Rspec では Repository を介さずに処理したいこともあるので、Entity に初期化処理が実装されている方が都合が良いこともある。
現状としては
- Entity 単体で解決可能な初期化処理なら Entity に記述
- ビジネスロジックが絡む初期化処理なら Repository に記述
としている。
Translator のパフォーマンスチューニング
UserEntity -> UserViewModel のような変換頻度が高い Entity が出てくるので、キャッシュを検討したくなる。が、まだ実装はしていない。
ただ、Rails のクエリキャッシュを利用するとクエリ自体のコストは無くなるので大きな問題にはなってない。
また、例えば UserViewModel が所属しているグループ (GroupViewModel) のリストを持っているような場合に、シーンによってはこのグループの情報が不要なケースがある。
この場合は↓のようなイメージで、グループのリストは持たない UserSimpleViewModel といった ViewModel を別途作って高速化している。
UserViewModel
id
name
mail_address
groups -> [GroupViewModel]
UserSimpleViewModel
id
name
mail_address
ViewModel のフロントエンドでの利用
ViewModel の構造がそのまま API レスポンスとして返るため、フロントエンド側で ViewModel に対応した JavaScript クラスを書くことになる。
これを手動で書くのは流石に辛いので、ViewModel から JS コードを自動生成するコードを書いた。
やってみた感想
- どこに何が記述されているか、どこに記述するべきかが明確になる
- WAF に(ほぼ)依存しなくなる
- 実際、最初に Padrino でこの構成を実装した後に Rails に移行したが、コア部分はほぼそのまま移行できた
- 自然と命名ルールが統一されて良い
- DI は導入していない
- Ruby で DI やる意義があまり無いように思う
- テストについては Request spec で Controller 層からテストを流しているので、DI を導入するメリットが今のところ無い
- Repository を導入すると、
update
みたいなActiveRecord::Base
のメソッドと名前が被らないで定義できるのが地味に良い - Translator のおかげでパフォーマンスのチューニングがし易い
- ViewModel をかましておくことで、View 層の要求に対応しやすい
- 慣れるまではちょっと大変かも