FatControllerに育ったRailsを改善した話
Timelab で Lynx というカレンダーサービスを開発している takahashi(@stak_22)です。
開発の速度を優先するあまりコード負債を多く抱えてしまう、といったプロジェクトは少なくないはずです。私たちが開発している Lynx でも、気づけば Controller が肥大化し、メンテナンス性やテスタビリティを大きく損なってしまう状況に陥っていました。本記事では、Fat Controller問題にどう向き合い、どのように改善していったか、その背景と実際の工夫を共有します。
※抽象的な設計思想について綴ります。具体的な実装に関しては本記事ではあまり触れませんのでご了承ください。
想定する読者
- Rails開発においてアーキテクチャを検討しているジュニアエンジニア
- Fat ControllerになってしまったRailsプロジェクトに携わるエンジニア
- その他、バックエンドプロジェクトの設計で他人が意識しているポイントに興味がある方
背景と課題
Lynxは4年以上継続的に開発しているRailsプロダクトです。開発序盤はとにかくスピードを優先し、Rails Wayを外れない範囲で処理をコントローラに寄せる設計をしていました。しかしその結果、
- privateメソッドが7つのRESTアクションにまたがって肥大化
- 責務が不明瞭になり、修正がデグレを招きやすい
- テストが書きづらく、カバレッジや品質が安定しない
といった典型的なFat Controller問題に直面しました。チームが入れ替わる中で属人化も進み、「できれば触りたくない」「できるだけ触らない方がいい」コードが増えていったのです。
取り組んだアプローチ
私たちが採用したのは、 CQRS (Command Query Responsibility Segregation) をベースにしたアプローチです。キーポイントは次の通りです。
- 1️⃣ ControllerはHTTPの処理だけに専念
- パラメータ取得、認証・認可、ステータスコード返却といった役割に限定。
- 2️⃣ 1 Action = 1 Class
- Commandsクラス/Queriesを作成し、ビジネスロジックはドメイン層に移譲。
- 3️⃣ Resultオブジェクトを定義
- dry-struct や dry-validation を利用して、処理結果・バリデーション結果・エラー情報を集約。
- 可読性と責務の明確化を図る。
1️⃣ ControllerはHTTPの処理だけに専念
Railsプロジェクトを実装する上で、この考え方は非常に重要だと思いました。
その理由は主に以下の通りです。
- 中途半端な共通化を避けることができる。
- 同じcontrollerに複数のアクションの具体実装が存在すると、共通化したくなります。しまいには複数のアクションを跨いで共通化されたprivateメソッドが増えて、スパゲティコード化していきます。
- テストが行いやすくなる。
- これによって、テストピラミッドが正常化されます。request specは最小限にとどめ、unit spec中心の構成にすることができるので、小さい単位で壊れにくい保守性の高いテストを実現できます。
以下に改善前後のサンプルコードを示します。
[改善前のコード例]
[改善後のコード例]
2️⃣ 1 Action = 1 Class
「共通化できるなら共通化した方がいい」
こういう話もありますが、基本的には逆の考え方のスタンスでいた方がいいのかなと思います。
共通化することで後々に影響範囲が広い実装になってしまうからです。
今回は基本思想として、「1 Action = 1 Class」を持っています。
これはつまり、1つのcontrollerに対して、1つのCommandsクラス/Queriesクラスが対応しているということです。
例えば、CalendarController#create
に対しては、::Domains::Calendars::CreateCalendars
が呼び出されるということです。SubCalendarController#create
というアクションが仮にあったとして、実装もほとんど同じであったとしても、::Domains::Calendars::CreateCalendars
を呼び出して再利用することは禁じ手です。なぜなら、どちらかで仕様変更したいときに実装者が気づかず修正してしまった場合に想定していない箇所で影響が出てしまったり、どちらかでバグが出たときに対応範囲(確認する範囲)が広くなってしまうからです。
そのため、基本的には単独のCommandsクラス/Queriesクラスを対応させます。
3️⃣ Resultオブジェクトを定義
dry-struct と dry-validation というGemを導入しました。
これに関する具体的な使い方は深く触れませんが、domainsクラスはこれを使ってinput, outputすることで、どういうパラメータが必要で何が返ってくるかを明確にし、可読性を向上させます。また、バリデーションの張り方も統一できます。
以下にサンプルコードを示します。
このように、InputとOutputを明示的に書くことで、可読性高く実装することができます。
重要なのは、可読性を上げることです。
このクラスには何をinputするのか?何がoutputされるのか?が一目でわかる実装にすることで、実装者のストレスを軽減するだけでなく、新規実装者のキャッチアップコストの低下や、統一された実装方針による開発速度向上が期待されます。
ディレクトリ構成
以下は Lynx が採用しているディレクトリ構成(サンプル)です。なお、今回の説明のために必要なディレクトリのみを抜粋して記載しています。
ポイントは、1つのcontrollerに対して1つのcommands/queriesが対応していることです。
app/
├── controllers/ # API エンドポイント
│ └── calendar_controller.rb
├── domains/ # ドメイン層(ビジネスロジック)
│ ├── account/
│ ├── billing/
│ └── calendar/
│ ├── commands/ # カレンダー書き込み処理ドメイン
│ │ ├─ create_calendar.rb # CalendarController#createの依存先
│ │ └─ update_calendar.rb # CalendarController#updateの依存先
│ ├── queries/ # カレンダー読み込み処理ドメイン
│ │ └─ list_calendar.rb # CalendarController#indexの依存先
│ ├── value_objects/ # オブジェクト定義
│ └── errors/ # カスタムエラー定義
├── models/ # ActiveRecord モデル
└── spec/ # RSpec
├── controllers/
└── domains/
依存関係
ざっくりと依存関係で見ると、以下のようになります。
models
↑
domains
↑
controllers
とにかく肝なのは、
- controllerには具体実装を書かず、domains層の具体実装クラス(commands / queries)を呼び出す。
- domainsが他のサービスクラスとかコマンドクラスとか、他のクラスに依存することはしない。(場合によってはjobsは仕方ない)
この辺りは徹底して意識しています。
テストに関して
テストに関しても意識していることがあります。
それは、controllersのテストとdomainsのテストの切り分けです。
- controllersのテスト
- commands / queries クラスはモックしてOK
- あくまでHTTP通信レイヤーのテストなので、パラメタを渡せるか?返り値が返ってくるか?認証は通るか?など。具体実装は含まないテストケース。
- domainsのテスト
- これは具体実装にあたるので、基本的にモックは使わない(外部APIなどは仕方ないかもしれないが)
- できるだけ具体的なテストケースを洗い出す
これによってテストピラミッドがピラミッドになります。
ユニットテストほど数が多くなるはずですし、それらはHTTP通信がどうなっているかなど気にすることなくテストできるべきです。
得られた成果
この取り組みにより、次のような改善が実現しました。
- デグレ減少 & 修正速度の向上
- スコープが小さいため、原因特定と修正が迅速に。
- テストカバレッジの改善(62% → 76%)
- 移行から約2ヶ月で安定したカバレッジを達成。
- 開発速度の向上
- 「どこに影響するかわからない不安」が解消され、新機能追加もしやすくなった。
- 新規メンバーのキャッチアップ容易化
- 責務が明確な設計により、コードベースの理解がしやすくなった。
まとめと振り返り
まとめ
- Fat Controllerは短期的には開発スピードを上げるが、長期的には負債化する
- CQRSによる「1 Action = 1 Class」で責務を明確化
- ControllerはHTTP処理だけに専念、ロジックはドメイン層へ
- テストピラミッドを整え、リファクタや改善を継続的に行える状態に
振り返り
今回の取り組みを通じて感じたのは、「設計改善は単なるテクニックではなく、チーム全体の体験を変える」という点です。Fat Controllerのままでは、触るのが怖いコードが増え、チームの学習コストや開発スピードをむしばみます。一方、今回はCQRSを導入することで責務分離を行いましたが、テストが書きやすくなり、リファクタも怖くなくなり、チームとしての開発速度が向上しました。これは単に技術的な改善だけでなく、チーム文化や心理的安全性にも直結します。
今日はAIがコード生成を支援してくれる時代になりつつありますが、プロダクトという文脈を理解し、改善し続けるのはやはり人間の役割です。また、この設計思想が全てではないことも重々承知ですが、何かしらプロジェクトにある設計思想は言語化して定義(ルール化)しておくことが大事なことだと思います。
Discussion