俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ

  • 666
    いいね
  • 0
    コメント

ちなみに、最初に結論だけ言っておくと、まずSandi Metzの「オブジェクト指向設計実践ガイド」を読め、という話です
それだけで終わってしまいたい気持ちはあるが、不親切過ぎるしもうちょっとRails向けの話を書こうと思う。

ただ言いたいことは、よく分かってないのに使うのは止めろということ。
自分も本で書いたりした手前、それが参考にされた結果なのかもしれないが、世の中には本当に酷いクラスが存在するもので、雑にサンプルで書くと以下の様な感じのコードが存在したりする。

class HogehogeService # Hogehogeはモデル名まんま
 def process(hogehoge, option_a: nil, option_b: nil, option_c: false)
   history = hogehoge.histories.last

   unless hogehoge.active?
     hogehoge.histories.last.update(state: :cancel)
     return error_message
   end

   case hogehoge.kind
   when "type_a"
     # ...
   when "type_b"
     # ...
   end

   ActiveRecord::Base.transaction do
     history.save!
     history.create_foobars
   end
 end
end

何が酷いって、機能が何なのかクラス名からさっぱり分からない、受け取る主要なクラスが一種類しかない、引数でモードが分かれている、問い合わせるのではなく命じるというオブジェクト指向の基本が守られていない、テスタビリティが悪い、という駄目なコードの見本市の様な感じになっている。
こういうのを見ると、本当にすまんかったと言わざるを得ない。分かると思うけど、こういうことじゃないんだ。
こういうコードを書く心配の無いメンバーだけで最初から最後まで開発できるなら、好きに使ってくれて構わない。

ややこしいのだが、PofEAA等で語られているモデル層を守る障壁としてのサービスレイヤの場合はapp/servicesに置いて明確に場所を分割しても良いと思う。
上手くやれば、一連の処理をハンドリングする立場をPlainなオブジェクトに任せてコンテキスト管理のテスタビリティを上げられるし、Webというインターフェースから処理のエントリポイントが独立する。
trailblazerのOperationなんかは、この考え方に則っていると思う。
しかし、ルールを明確に決めておき、ある程度のオーバーヘッドを抱えるぐらいの規模感が無いと、コントローラーが半端に二重になって辛いみたいな状態になるかもしれない。

一方で、DDDの文脈の上で語られているドメインサービスの概念は、モデル層における機能や振舞い自体をクラスとして定義し責任の置き場所を表現したものだ。
これは実質的にはモデルの一種であり、app/modelsの中のクラス構造の中で表現できるものだ。ディレクトリ構造ごと分けるようなものではない。

まず、これが分かっていなければならない。

もし、trailblazerのOperationの様なコンテキストをハンドリングする立場としてのサービスレイヤを構築する場合は、自分達がどういう定義でサービスクラスというものを扱っているかを明確にして、そのルールに従うこと。
そうでないと、処理の記述場所が散らかって訳が分からなくなる。
前者のサービスと後者のサービスを混同するようなことは避けなければならない。

今回は、その中でもドメインサービスの書き方にフォーカスした話だ。

レイヤーや新しいクラスグループを自己定義する場合の注意点

少し本筋とはズレるか、チームで開発している場合、レイヤーが持つ役割が自明である様にしなければならない。そして、その範囲を逸脱するようなコードを書いてはいけない。
でないと、後から参加したメンバーが、どこでどういう仕事が行われているか分からなくなる。
フレームワークとアーキテクチャパターンのメリットは、後から参加したメンバーに構造を説明しなくても知識を伝えられるところにある。
役割が明確でないレイヤーが増えると、そのメリットを破壊してしまう。
私はRepresenterというレイヤーで何故かActiveRecordのレコード保存が行われているのを見たことがあるし、名前から想像される以上のことをやるのは開発者を困惑させるだけだ。

また名前からそのクラスがどこのレイヤーに属するものか分かる様に書くべきだ。
基本的に全ての主要クラスはモデルレイヤーにあるはずで、Suffixが付いてないクラスはまずモデルの中から探す事になる。
逐一grepして探さなければいけないとなると、それはRailsにおける命名規約のメリットを損うことになる。

そもそも何でクラスを分けたいのか

ファットモデルが問題になってクラスを分けたいのは,現実にそのクラスが変更され更新されるからだ。
あるクラスが色々な責務を抱え過ぎて大量のインターフェースを抱えた結果、ある要件の元に改修を加えようとすると変更範囲が確定できなくて爆死することを避けたいからだ。
もし、あるクラスがファットだろうが、構造が安定していてインターフェースが明確で変更されることが無いなら特に問題にはならない。
クラスを分ける場合は、変更が発生した時にその領域に関わる変更は分割したクラス内の中で収まってないと分割する意味は無い。そうでなければ、むしろマイナスが増えると言ってもいい。

サービスクラスの問題点

単純に、ActiveRecordを中心にしたその他のモデルと責任範囲が被っていることが、大きな問題になる。
明確に責任の境界線を引かないと、ただモデルにあるべきコードが散らばって見辛いだけのコードになる。
加えて問題なのが、トランザクションスクリプト的な書き方の言い訳を作りかねないところだ。
これはドメインサービスでもアプリケーションサービスでも同じだと思う。
本当に酷い状態になると、手続的な処理の羅列を書いたサービスクラスが跋扈し、モデルレイヤーがただのデータアクセスレイヤーとトランザクションスクリプトの群れになるドメインモデル貧血症という状態になる。

別にトランザクションスクリプト自体が悪いというわけではないが、RubyやRailsでそういう書き方をすると大抵地獄になる。
例えば、上記の辛いコード例の様にサブルーチンとして呼び出した処理の結果による分岐や、プロパティによる処理の分岐が存在する場合を考える。
本当にその分岐条件が正しいかテストするためには、hogehogeの持ってるプロパティレベルで境界値を設定しメソッドを呼び出すか、処理途中のメソッドをモックして返り値を差し替えないとテストコードを書くことができない。
しかも、その処理はhogehogeに変更が入る度に壊れる可能性がある。
こういったコードは事前に準備しておかなければいけないコンテキストが多くなり、単体でインスタンスを生成し処理を実行することが難しくなる。
テストの難易度も上がるし、テストコード自体の複雑性が増したり、モックの意義が失われて、安定なテストを維持することが難しくなる。
型やnull安全等で守られていないRubyのコードにおいて、テスタビリティが下がるというのは複雑な処理において致命的な問題になる可能性がある。

サービスクラスとかいう前に知っておくべきこと

ややこしい処理はなんか名前を付けてまとめればいいんでしょ、とか考える前に真剣に考えておかなければいけないことがいくつかある。

  • オープン・クローズドの原則
  • 単一責任の原則
  • デメテルの法則
  • クラス同士の依存関係の管理
  • 実装ではなくインターフェースに依存する
  • ダックタイプとクラスの中に内在するインターフェース
  • 集約クラス

オープン・クローズドの原則

先に述べた様に、変更が発生した場合に自分の外に影響が漏れず閉じている状態が望ましい。
でないと、ある処理を変えた時に連鎖的に調査範囲が拡大して変更をコントロールできず死ぬ。
もしサービスクラスが、モデルレイヤーのメソッドをいくつも直接呼び出しているとすると、モデルにちょっとした変更が入ったらぶっ壊れるかもしれない。
そんなのがいくつもあると辛みしかない。

単一責任の原則

一つのクラスが担う責務は一つにすること。
ActiveRecordを元にしたクラスはRDBという根幹になるデータ構造と紐付いているため、複数の役割を担うことになる可能性が高い。しかし、それを別のクラスに分割することはできる。
責任範囲でクラスを分けることを意識しないと半端に元のクラスと結合した扱いづらいクラスが生まれることになる。
モジュールで分けても本質的には抱え込んでいる責務を分けたことにはならない。
どういった責任をどういう名前を持ったクラスに与えるべきなのかを考えて、クラスを作っていくことが大事だ。

デメテルの法則

あるクラスが触っていいのは、自分が直接知ってるクラスだけという状態を維持しなければならない。
これを守ろうとしないと、あるクラスがあちこちのクラスに依存し、処理の詳細の知識まで抱え込んだ結果、変更が発生した時の影響範囲が拡大し過ぎて爆死することになる。
また、デメテルの法則に反したコードは、隣人のクラスに問い合わせて取得した結果を元に自分で判断する様なコードになることが多い。
こういったコードは前述した様にテスタビリティが悪い。

クラス間の依存関係

自分のクラスが直接持ってる情報以外を触るということは、外部の何かの知識に依存するということであり、それが存在していないと処理が完結しないことを意味する。
自分以外のクラス名や、それが持つメソッド名、メソッドの引数、メソッドの返り値、外部のクラスの処理結果を知れば知る程依存関係が強くなり、変更が辛くなる。
依存が少なくなる様な構造を考える必要がある。
また、依存関係は一方向に保つこと。相互依存するような関係はオブジェクトの生成プロセスや処理の流れを激しく複雑にする。

インターフェースに対して依存する

あるクラスが知っておくべき知識をできるだけ抽象化しておいた方が良い。
具体的な処理内容の詳細はとても変わりやすく、その内容に依存しているとすぐに壊れる。
そして単純に読みにくさが増す(実装を行ったり来たりしないと何が起きてるか把握できない)。
ポリモーフィズムの力を活かす上でもインターフェースを意識したコードを書くことは重要。

ダックタイプ

Rubyという言語は何も記述しなくても、メソッドの定義だけを元に対象が何者であるかを判断する。
しかし、それはインターフェースが消えてなくなった訳ではない。
プログラマが逐一書かなくてもいいようになっているだけで、その処理に利用される各クラスはインターフェースの実装としての姿を持っている必要がある。
どの様にインターフェースを見出してクラス間の依存関係をどう表現するかは、プログラマの脳内にある業務領域とクラス構造のマッピングにかかっている。
プログラマが楽できる分、その知識に信頼性が求められている。

集約クラス

クラスが持つ責任範囲には境界がありグルーピングができることが多い。
ここから先の詳細は全部こいつに任せる、後は知らなくていい、そういうクラス同士の関わり方の地図を作っておかなければいけない。
そうでないと、複雑さが組み合わせによって無限に増えていく。
Ruby/Railsでは、それを言語レベルで強制する手段は無いので、プログラマの責任として守らなければいけない。

V字型により詳細を知っているクラスにブレイクダウンしていく動きを想像して欲しい。
モデル層の中にも階層構造は存在するし、処理の大枠だけを管理するコントローラーの様な存在はある。


こういった前提の元で、本気で考えてるなら以下の様なことができないのか考えてるはずだ。

  • あるActiveRecordのクラスが持っている責任を独立したクラスとして定義できないか
    • 単体で生成できるPlainなRubyクラスか
    • 中間テーブルを伴う新しいActiveRecordのクラスか
  • そのクラスはプリミティブな値(文字列、数値、単純な配列等)か、単純なインターフェースにのみ依存しているか
    • 依存しているオブジェクトのメソッドを何個も知らなくていいか
    • 結果を受け取って自分で判断しなくていいか
    • 隣人クラスの更に先のクラスの知識に依存していないか
    • そのインターフェースは業務領域の言葉で表せるものか
  • いくつかのActiveRecordクラスのインスタンスを包含した単体クラスとして表現できないか
    • 個別の処理は内部のインスタンスに移譲できないか

自分としては、かなりのアプリケーションにおいて、本当にファットモデルと呼ばれる状況にまで到達する前に、設計をサボってActiveRecordに責務を押し付け過ぎた結果、辛いモデルが生まれている様に思う。
ActiveRecordオブジェクトが中心にあっても、オブジェクト指向の原則や考え方を守ろうとすることで、責務を小さく保ったり、クラス間の依存関係を管理することはできる。
そして、本当に色々な人がしつこく何度も言っているが、ActiveRecordがモデルなのではないし、テーブル定義は変更していく必要があるということを認識しておく必要がある。
RDBのスキーマを変更することは苦労を伴うが、そこから逃避してモデルの構造を歪めサービスクラスという言い訳の元に責任を押し付けて設計した気になるのは、ロクな結果にならないだろう。
RDBはデータの整合性や事実関係を示す最後の砦とも言える場所で、まず最初にそこが適正であるかを再考するべきだ。
RDBで上手く概念が表現できるなら、その概念についてはRDBが持つ制約を利用して強固な整合性を得ることができる。アプリケーションの処理に間違いがあっても異常なデータが入り込まない様に守ることができる。

そしてRDBのデータ構造から新たな概念が見出せたら、それはActiveRecordにそのまま反映することができる。
そうして定義された新たなクラスの責任範囲は、要求されている新しい処理の置き所として妥当なものかもしれない。
RDBの中でどうやって表現するかを知らないために、モデルの設計が歪められている例もあると思っている。

もし、RDBとActiveRecordで表現できないものでも、PlainなRubyクラスを作ることで対処できることが多い。
例えば、ページネーションの様な概念を扱う時には((実際はkaminariとかを使うだろうけど)、レコードの集合に加えて全体の件数や今参照している集合が何ページ目か、といったメタデータを保持しておく必要がある。
そういった場合に、無理にクラスレベルに情報を持たせなくても、コレクションを扱うクラスを定義して良い。
ActiveRecordはクラスレベルのメソッドがRDBへのクエリと紐付いているため、RDBから読み出した後の集合を扱うことが難しいことがあるが、PlainなRubyクラスでラップすることで定義場所を得ることができる。

こういったモデル構造を作れるなら、単体でインスタンスを生成でき、処理の詳細をモックで隠蔽しても、安定したテストコードを書くことができるようになる。
テストしやすい構造は、責任範囲が明確に分割された良い設計に近い形になることが多い。
テストの書き易い構造を考えて、クラスの関係性を改善していくことができる。

もちろん、こういった事を考えてクラスを分けていくと、もしかしたら一連の機能的振舞いに対して名前が付いてクラスになることもあるかもしれない。
それはサービスクラスと呼べるものだが、モデルレイヤーの中の階層構造の中で表現する方が分かり易いと思う。変に別のディレクトリ構造に切り出すべきじゃない。
結局の所、それはドメインモデルが持つ大きな責任の中の一部で、オブジェクト同士の関係性の中に存在するものだと思うからだ。

まとめ

長々と書いたけど、結局の所言いたいのは、サービスクラス等という単語に踊らされる前に、オブジェクト指向プログラミングに関する知識を貯めて、真剣にクラス同士の関係性を設計しロジックを表現する努力をしようということだ。
サービスクラスの様に機能や振舞いに明確に名前を付けて責任範囲を表現したり、抽象的な概念を見出して名前を付けるというのは、慣れが必要な難しい作業だ。
よく分かってない内に、無理に手を出すとマイナスの方が大きくなることも多い。
まずRDBで表現できる概念を知ってActiveRecordをちゃんと使える様になり、ActiveRecordから責務を分離したPlainなモデルを見出せる訓練をしていくのが良いと思う。
その内に、どうしても責任の置き場所を機能そのもので表現したクラスで表現するしかない、という状況がやってきたら、その時にこそサービスクラスというものをapp/models内に作れば良いと思う。