はじめに
バックエンドを実装するかもしれないので、改めて、Controller・Service・Repositoryの役割を整理してみました。
Controller・Service・Repositoryの役割について
Controller
役割
ユーザーからのHTTPリクエストを最初に受け取り、その結果をレスポンスとして返す「入り口」の役割を担う。
JSONとDTOの相互変換やリクエスト内容のバリデーションを行い、実際の処理はService層に委譲。
注意点
重要なのは、ビジネスロジックをControllerに直接書かないこと。
Controllerはあくまで入り口に徹し、処理の流れを整えることに専念するようにする。
Service
役割
システムにおけるビジネスロジック*の中心。
複数のRepositoryを組み合わせて業務ルールに沿った処理を実装したり、必要に応じてトランザクションを管理したりする。
業務上の振る舞いを整理することで、アプリケーション全体の整合性を保つ。
※ビジネスロジックとは
業務に特有のルールや計算・処理の流れを実装した部分のこと。
(例)
- 契約期間が満了したら自動更新する
- 月額プランと年額プランで請求金額を計算する
- 支店数を超えたら追加課金する など
注意点
全てをServiceに詰め込むとFat Service
と呼ばれる過剰に肥大状況*が発生することがある。
ドメインモデルとの役割分担を意識し、適切に責務を分けることが重要になる。
※Fat Serviceとは
Service層に本来分けるべき責務を全部詰め込んでしまって、過剰に肥大化した状態。
さらに深掘りFat Service
【Fat Serviceが生まれる状況】
Fat Serviceが生まれるは、下記のようなとき。
- 本来はドメインモデルやRepositoryに委譲すべき処理*までServiceに書いてしまう
- Controllerから呼ばれる唯一の場所だからとにかく「ここに置けば動く」発想で全部寄せる
- メソッド数がどんどん増えていき、1クラスで数千行にも膨れ上がる
※ドメインモデル
ビジネスルールや振る舞い。
そのモデルが自分自身を正しく保つためのロジック。
エンティティや値オブジェクトに閉じたルール。
(例)ユーザーのパスワード更新ロジックや、注文の「合計金額を計算する」「キャンセル可能かを判定する」
- エンティティ
IDを持ち、ライフサイクルにわたって同一性を保つクラス
「この契約」「この店舗」「このユーザー」と特定できる存在 - 値オブジェクト
値そのものが意味を持ち、不変で使い回せるクラス
住所、金額など値が同じなら同じものと扱えるもの - ドメインサービス
ビジネスルールとして必要な振る舞い
ドメインモデル = エンティティや値オブジェクト(+必要に応じてドメインサービス)で表現されたビジネスルールの集合!
→ サービスに直接は書かない!サービスはそれを呼び出すだけ!
※Repositoryに移譲すべき処理
- データ取得/永続化に関する処理
- ドメインに必要なクエリ(ただしロジックは含めない)
- 「どのデータを持ってくるか」という条件はRepository
- 「持ってきたデータをどう判断・計算するか」はドメインモデル or Service
「データの取り出し・保存」はRepositoryの責務。ビジネス判断は持ち込まない!
Repository
役割
データベースや外部APIとのやり取りを行う。
SQLやクエリを一元的に管理し、データの取得や保存といったCRUD処理をアプリケーションコードから詳細を意識させない役割を担う。
返却するデータはドメインモデルにマッピングされることが多く、ビジネスロジックを持ち込まないことが設計上の原則。
Repositoryはあくまで「データアクセスの窓口」と割り切り、責務を限定することで、全体の構造を明確に保つことができる。
もし役割を意識しなかったら?
Controllerにビジネスロジックを入れると?
画面やAPI仕様変更=Controller変更になることがある*ため、中に業務ロジックまであると毎回ロジックも巻き込まれ、料金計算・状態遷移の修正までセットで発生することになることも。
※画面やAPI仕様変更=Controller変更になることとは
UIやAPIの仕様が変わった場合、場合によって、リクエストDTOやレスポンスDTOの変換をする必要がある。
Serviceがなんでも屋になってしまうと?
メソッドが肥大し、分岐だらけで読めない・直せない状況に。
トランザクション境界が曖昧になり、部分更新や二重実行で不整合(在庫の多重引当など)が発生する可能性も。(あっちこっちで更新が走る、トランザクションがまとまっていない状況)
Repositoryに業務ルールを書いてしまうと?
そのRepositoryを経由しない処理(バッチや別APIなど)でルールが適用されず、
経路ごとに挙動が変わり、不整合や予期せぬエラーが発生する。
役割をさっと確認
下記をチェックすることによって、役割分担がちゃんとできているか確認できそう。
-
Controllerに
if/for
で業務ルールを書いていないか? -
@Transactional
はServiceにだけ付いているか? - Serviceメソッドは1ユースケース1メソッドになっているか
- Repositoryメソッド名に業務語が出ていないか?
- 同じバリデーションを複数層に重複定義していないか?
ちょっと気になったこと
ドメインモデルとDTOの違い
ドメインモデルは業務ルールや不変条件*を表現し、メソッドを通じて正しい振る舞いを保証する内部のオブジェクト。
一方、DTOは入出力専用のデータ構造で、リクエストやレスポンスの形式に合わせて定義され、ビジネスルールは持たない。
※不変条件とは
DTOはあくまで「入出力専用のデータ構造」ですが、アノテーションで@NotNullや@Sizeなどのバリデーションを付けるのは一般的にアリです。やシステムの状態が常に満たすべきルールや制約。
DTOにはバリデーションをつけるが、、、?
DTOはあくまで「入出力専用のデータ構造」だが、アノテーションで@NotNullや@Sizeなどのバリデーションを付ける。
これは、外部から受け取るデータの形式や必須性を保証するためのもの。
ドメインモデルに含める不変条件はダメで、例えば、在庫は0未満にならない」「契約終了日は開始日以降であるなどは、ドメインモデルで絶対に崩れないよう担保する必要がある。
まとめ
お作法のようにかき分けてきましたが、ここを理解していれば、なんとか責務を分けて、迷わずコードが書けそうな感じがしてきました。
実際にコードを書いてみて、迷った点は追記か別の記事にできる良さそうと思いました。