nazoです。
最近はUUUMもエンジニアを含む開発チームもメンバーが増えてきました。メンバーが増えてきた時に、どうしても技術力にムラがある(単純に高い低いという話ではなく、得意な点や考え方が個人により異なるということ)という問題が生じます。
個別の細かい技術についてはその場で説明すれば良いのですが、「設計」と言うと単純に説明するのは難しく、そもそも人によって考え方にも違いがあります。とはいえここは抑えておいてほしいというところはあるので、社内で勉強会を開催して説明した内容を社内向け文書も兼ねて公開したいと思います。
今回は「サービスクラス」の作り方について紹介したいと思います。サービスクラスという概念はバックエンドに限らず、フロントエンドやその他様々なアプリケーションで応用できる概念かと思います。資料には例外についての記述もありますが、ここでは取り上げません。
以下は資料を補足するような内容になります。本件は「DDDの一歩手前」くらいの内容と捉えています。またサービスクラスの使用を強制するものでもありません。
MVCって何だっけ?ビジネスロジックって何だっけ?
MVCが本来Webアプリケーションのためのものではないとかは置いておきますが、MVCは「システム的な観点で処理を分解するための技法」であるという認識です。
MVCがFat ControllerからFat Modelになったとかは今更の話ですが、ともかく「MVC」という単位でしか考えないとどこかにビジネスロジックを書く必要が出てきます。
「Modelはビジネスロジックを書くところなんだから、ビジネスロジックが複雑化すればModelはFatになるだろ!」と言いたいところなのですが、この場合に抜けてない誤解として「Model=1テーブル」という概念が残っていることが多いです。また、前述の通り「MVC」というのはシステム的な観点の話であり、Modelの中をどうするかについては規定していません。
そもそも「ビジネスロジック」とは何なのでしょうか?私はビジネスロジックとは「会社における部署」のような概念だと考えています。
会社は、部署ごとに業務があり、例えば「人事部」には「採用を依頼する」「採用結果を確認する」「現在の人材配置を確認する」といった業務があります。人事アプリを作る場合、これらをコードに落としていく必要があります。
データレベルで考えた場合、人事アプリには「人材テーブル」「エージェントテーブル」などが存在すると思います。細かい設計は省略しますが、「人材」「エージェント」といった要素と「採用を依頼する」「採用結果を確認する」「現在の人材配置を確認する」といった要素は1:1では結びつかないように思えます。1テーブルにマッピングされるModelに無理やりロジックを詰め込むと、これらの1つのテーブルにロジックが偏りがちになることでしょう。実際の業務で考えれば、例えば「人材情報を握っている人」にだけ存在する業務、というのはないと思います。また、そもそも「人事部」というテーブルは普通は存在しません。「部署」というテーブルの1つのデータになっているのが普通でしょう。
つまりテーブルとビジネスロジックは1:1ではなく、「ビジネスロジックの集合体」と「データ(テーブル)の集合体」という抽象的な概念がそれぞれ存在し、使用するデータとは全く関係なくロジックを設計していく必要があるのでないかと思います。
MVCとサービスクラス
Railsで見てみると、MVCのMは大きく3つに分解できるのではないかと思います。
- DBへのクエリ
- DBから取り出したレコード
- 主にDBに対して何かをする一連の流れ(ビジネスロジック)
このうち、「DBへのクエリ」はscopeなりクラスメソッドなりで表現され、「レコード」はインスタンスになりますが、「一連の流れ」は前述の通りテーブルと直接同一の概念ではないということになったため、別の概念に切り出したほうが良いように見えます。
そこで「具体的なビジネスロジックのみを記述する集合体」として「サービス」という概念が登場します。
サービスクラスは以下のような作りにします。
- コンストラクタでのみ依存関係を解決する
- 原則としてコンストラクタで注入された依存以外の状態を持たない(キャッシュ用などで持つ場合はある)
- インスタンスメソッドが複数あり、インスタンスメソッドの結果は引数と依存関係によってのみ決まる(時刻とかが含まれる場合は別途)
実際にシステムに落としてみましょう。例えばECサイトのようなものでは、「注文に関する業務」「在庫を管理する業務」「商品を発送する業務」などがあります。このような分解をする場合、必ず第一言語で設計するのが良いです。慣れていない言語(私のような英語ネイティブでない人が使う英語)だと、要点を的確に説明できないことが多いです。
「注文に関する業務」には、「注文を受ける」「注文をキャンセルする」「現在の注文を確認する」といった処理があると考えられます。コードにすると以下のような感じです。
class 注文に関する業務 { constructor { } fn 注文を受ける(ユーザー, カート) { 在庫確認 注文 } fn 注文をキャンセルする(ユーザー, 注文情報) { キャンセル可能かどうか確認 キャンセルする } fn 現在の注文を確認する(ユーザー) { return 現在の注文 } }
雑ですがこのようになります。「注文に関する業務」というと「注文」テーブルが存在するので、「注文テーブル」にそのままロジックを書きたくなりがちですが、よく見ると「ユーザー」も「カート」もありますし、それらに書いたからといって間違いではなさそうです。あくまで処理を行うのはテーブルではなく業務担当なので、テーブルとは別に書くのが良いです。
サービスクラスが肥大化してきた場合は、会社の部署を細分化するのと同様の感覚で細分化するのが良いでしょう。
なお、Symfonyでは以下のような分類になります。
- DBへのクエリ=Repository
- DBから取り出したレコード=Entity
- 主にDBに対して何かをする一連の流れ(ビジネスロジック)=Service
サービスクラスとDI
会社の実際の業務が他の部署と連携して行われるように、サービスは複数のサービスと連携することがあります。このような関係を「依存関係」と呼び、それらをコンストラクタで定義する際にDIを用いて解決します。また、サービス内では通信やファイル操作といった複雑な要素が絡むことも多々あり、それらを本来のビジネスロジックから切り離すためにもDIを使用します。
DIを使う理由としては、メソッド書き換えなどでも確かにできなくはないのですが、特定言語の機能に大きく依存するような機能は避けたいというのと、自由に書き換えてしまうことにより責任の境界が曖昧になってしまうという問題があります。
大きくはテストを書く時に影響しますが、コンストラクタで与える依存関係だけをMockにすることによって、「どこまでがテスト対象なのか」を明確にすることができます。
具体例で考えてみましょう。先ほどのECサイトでの「注文に関する業務」では「在庫」に関する処理が必須ですが、これを実際の通信販売で考えると、注文を受けた人が直接倉庫に行って在庫を確認してくることは少なく(あるかもしれませんが)、通常は倉庫の人なりシステムなりに在庫を問い合わせます。つまり、「在庫管理に関する業務」という全く別の業務が存在することになります。これを踏まえて先ほどのコードを書き換えてみましょう。
class 在庫管理に関する業務 { fn 在庫を確認する(商品名, 数量) { return 在庫があればtrue } } class 注文に関する業務 { constructor(在庫管理に関する業務) { this.在庫管理に関する業務 = 在庫管理に関する業務 } fn 注文を受ける(ユーザー, カート) { if 在庫管理に関する業務.在庫を確認する(カート.商品名, カート.数量) { 注文 } } fn 注文をキャンセルする(ユーザー, 注文情報) { キャンセル可能かどうか確認 キャンセルする } fn 現在の注文を確認する(ユーザー) { return 現在の注文 } }
これにより、「在庫に関する責任は注文業務では負わない」というのが明確になりました。通常はこのくらい直接書いてしまえばいいのではと思うところですが、例えば商品によって在庫を管理しているシステムが複数存在したりとか、最近流行りのオンデマンド注文が混ざったりしたりすると、直接書いてしまうとどこまでが何の処理なのかわからなくなります。在庫管理を別業務に切り出すことで、注文業務は注文のみに専念することができます。テストを書く時も、在庫部分はモック化してしまうことにより、どこのテストを書いているのかすぐわかり、テストを書く時に在庫部分の挙動を気にする必要がなくなります。
Railsだと Trailblazer のようなものもありますが、概念が独自すぎ、かつRubyに寄りすぎているので私はあまり好きではないです。弊社ではRailsを使う場合でもRuby/Rails固有すぎるものは避けるようにしており、例えばテストはMinitestで書いています。
まとめ
今回はサービスクラスの作り方と考え方を中心に紹介しました。「サービスは業務をコード化したものであり、役割ごとにサービスは分割されるべきである」という点を抑えておけば良いのではないかと思います。
これ以上深く設計することもできますしそうしないこともできますが、「誰でもそこそこできて変にならないライン」が大体このくらいかなと私は考えています。既存メンバーもサービスの設計には苦戦しているものの、大体このくらいの認識であれば受け入れやすいのではないかと思います。ここから先の具体的な設計手法については今のところ個別のコードレビューを中心に議論しています。
「もっと深いところまで踏み込むべきだ!」とか「そもそもこの内容の時点で間違っている!」という場合は是非UUUMに入社して我々に共有していただきたいと思います。UUUMではエンジニアを積極採用しております。