1年前くらいにRailsの設計にDDD(ドメイン駆動設計)のService層を導入し、Modelの肥大化対策をしました。
この記事では、まずどのようなルールでService層が組み込まれているかと、1年間運用してみて良かったところ、悪かったところの感想を書きます。
[2018/05追記]
最近ではサービス層の導入は賛否両論あるようなので、導入する際は自分のプロジェクトに合っているかどうかを十分にご検討ください!
Service層を導入するきっかけになった問題点
- Modelの肥大化
- Model間の複雑な依存関係
- 多数のミドルウェアの導入による複雑さの倍増
これらにより..
- メンテナンスやテストがしにくい
- コードが整理されていないのでとにかく読みづらい
Model複雑化の例
<ユーザがECサイトの商品をお気に入り(like)にするメソッドを書く場合>
処理に関連するテーブル
- my_itemsテーブル(like情報を持つテーブル)
- itemsテーブル
処理に関わるミドルウェア
- MySQL
- Redis
必要な処理
- my_itemsテーブルへのinsert
- itemsテーブルのlike_countの更新
- ユーザがlikeしたアイテムのidを保持するRedisの更新
- アイテムをlikeしているユーザのidを保持するRedisの更新
上記のような処理をModel内の1つのメソッドに書こうとすると、他のModelや複数のミドルウェアのupdateを全てをMyItem Modelに記述することになり、Modelが煩雑化してしまいます。一方で、上の処理をControllerに書こうとすると、アプリケーション内でlikeの処理を統一できなくなります。(method化できない)
そこで
ControllerとModelの間にService層を導入
Service層とは?
Service層とは、DDDのドメイン層の構成要素のひとつです。
DDDは下記のように構成されていて、MVCとの対応関係はこんな感じです。
Service層は、エンティティや値オブジェクトに属さない層で、振る舞いだけを記述する層です。
MVCではService層がないため、単体のModelで完結しない処理を無理矢理ControllerかModelに詰め込むしかなかったですが、Service層を入れる事で、単一Modelで完結しない処理を一つの処理として切り出して記述することができます。
実際の設計
Service層の定義(ルール)
Service層を作成するにあたって、今回は下記のルールに基づいて設計してあります。
- 一つのModelで複数のミドルウェアと通信する場合はService層に書く
- 複数のModelが絡み合う処理はService層に書く
- Service層では振る舞いだけを定義する(状態を持たないためModuleで設計する)
ディレクトリ階層
app/
controllers/
my_item_controller.rb
services/
my_items/ # ←名前空間が被らないように複数形にする
post_service.rb
search_service.rb
models/
my_item.rb
item_like_user_index.rb (Redis/cache処理)
user_like_item_index.rb (Redis/cache処理)
- 各Modelに紐づくService層を用意
- (例だと
services/my_items
がmodels/my_item.rb
に紐づいている)
- (例だと
- Service層の中身を下記の二つのファイルに分類
- post系
- search系
Service層では、例えば"商品をlikeする"や"商品を購入する"等の行動単位でModule化することが多いところを、今回は行動単位ではなく、各Modelに紐づくService層をpost, search系の処理に分けてModule化しています。記述したい行動と、より密接に関わるModelのService層に処理を書くルールとなっています。
行動単位にしない理由は、
- Moduleが乱立することがなくディレクトリが奇麗
- Model単位で分けるとどこになにを書くのかが直感的でわかりやすい
の二つがあります。
ECサイトの商品をお気に入り(like)にする場合の例
図
実装例
スーパー簡略バージョンです
def create
MyItems::PostService.like!(user_id, item_id)
end
module MyItems
module PostService
module_function
def like!(user_id, item_id)
MyItem.new.set_myitem(user_id, item_id)
Item.find(item_id).increment_like_count
ItemLikeUserIndex.new(item_id).liked_by!(user_id,at)
UserLikeItemIndex.new(user_id).like_to!(item_id,at)
end
end
end
class MyItem < ActiveRecord::Base
def set_myitem(user_id, item_id)
self.user_id = user_id
self.item_id = item_id
self.save!
end
end
# itemテーブルが保持するlike_countカラムの更新
class Item < ActiveRecord::Base
def increment_like_count
self.increment!(:like_count)
end
end
# Redisの各itemをlikeしたuserリストを保持する処理
class ItemLikeUserIndex
def initialize
@item_id = item_id
end
def liked_by!(user_id, at)
@@redis.zadd(_key, at, user_id)
end
end
# Redisの各userがlikeしたitemリストを保持する処理
class UserLikeItemIndex
def initialize(user_id)
@user_id = user_id
end
def like_to!(item_id, at)
@@redis.zadd(_key, at, item_id)
end
end
他のModelの処理やミドルウェアの更新等がMyItem Modelから切り出されることにより、MyItem Modelの煩雑さはなくなり、肥大化を抑えられた感じがします。
運用してみて感じた事
メリット
- ModelやControllerがシンプルになり、読みやすい
- postとsearchに分けてあるとコードが整理されて、どこに何が書いてあるかわかりやすい
- 処理がservice層に切り出されている為、Modelのテストが書きやすい
デメリット
- チームで認識が揃っていないと運用が難しい (サービスの役割とモデルの役割について人によって解釈が違うと、どこになにが記述してあるか逆に分からなくなる)
結論
チーム開発においてService層を導入する際は明確で理解しやすいルールを用意することが重要だと感じました。
今回の設計ではルールが分かりやすくなっているので運用で迷う事が少ないく、チームの認識のずれも生まれにくかったです。
また、Service層が、Modelに紐づいたModule設計になっているので、Service層のディレクトリが奇麗にまとまっていて、どこになにが記述してあるか分かりやすいです。
Modelに紐づいたService層にすることで、ある行動をどのModule内に記述していいかわからない場面があるかと思いましたが、今のところは特に困る事はなく実装できています。