アプリケーションの機能を組み立てる
概要
この記事は、現場で役立つシステム設計の原則〜変更を楽で安全にするオブジェクト指向の実践技法を読み学習した内容を Ruby on Rails のコードに例を変換してまとめ直したものです。
また、書籍では Java のコードを例にしており、Ruby on Rails の慣習とは一部異なる部分があるので、その部分はオミットした内容となっています。
この記事では主にアプリケーションの三層構造におけるアプリケーション層の設計、アプリケーションの機能をどのように組み立てるかについて記述しています。
ドメインオブジェクトを使って機能を実現する
アプリケーション層のクラスの役割
三層+ドメインモデルの設計では、アプリケーション層のクラスは以下のように処理の流れの進行役・調整役となる。
- プレゼンデーション層からの依頼を受け取る
- 適切なドメインオブジェクトに判断・加工・計算を依頼する
- プレゼンテーション層に結果を(ドメインオブジェクト)を返す
- データソース層に記録や通知の入出力を指示する
プレゼンテーション層に業務サービスを提供しているという意味でアプリケーション層のクラスをアプリケーションサービスクラスまたは単にサービスクラスと呼ぶ。
サービスクラスの設計はごちゃごちゃしやすい
アプリケーションの機能が増え、使用が複雑になるにつれて、サービスクラスに業務ロジックが入り込んでくる。
即ち、サービスクラスに if 文の条件分岐が追加され、異なるサービスクラスに同じ業務ロジックの重複が目立つようになる。
原因としては以下の三つがある。
- ドメインオブジェクトが業務ロジックの置き場所として十分機能していない
- プレゼンテーション層の関心事に振り回される
- データベースの入出力の都合に引きずられる
サービスクラスのコードが膨らみ複雑になると、アプリケーションコードの見通しが悪くなり、ちょっとした変更が思わぬ副作用を起こしやすくなる。
これを防ぐために、サービスクラスの設計においては次の方針を徹底する。
- 業務ロジックは、サービスクラスに書かずにドメインオブジェクトに任せる(サービスクラスで判断/加工/計算はしない)
- 画面の複雑さをそのままサービスクラスに持ち込まない
- データベースの入出力の都合からサービスクラスを独立させる
この方針で設計する具体的な方法を以下で述べる。
サービスクラスを作りながらドメインモデルを改善する
オブジェクト指向の設計は、コードを追加したり修正するたびに、クラスの設計や、クラスとクラスの関係を見直しながら改善を繰り返していくことが基本となる。
アプリケーションに機能を追加したり、業務ルールの追加や変更を行うときは、サービスクラスのコードをシンプルに保つようなクラス分担を心がける。
そのようにサービスクラスを組み立てながら、全体の設計を改善していくやり方は以下の通り。
安易にサービスクラスに業務ロジックを追加しない
開発初期段階では、開発者の業務知識不足によりドメインオブジェクトが提供する業務ロジックは貧弱になりがち。
業務知識を新たに獲得した場合、それを表現する業務ロジックをアプリケーションへ追加する方法は以下のどちらかとなる。
- ドメインオブジェクトを追加したり、修正してドメインモデルを充実させる
- 不足している業務ロジックをサービスクラスに直接書いてしまう
三層+ドメインモデルの設計の利点を活かすには、たとえ簡単であっても後者のようにサービスクラスに安直に業務ロジックを追加しないことが重要となる。
業務ルールを表現するのに適切なドメインオブジェクトがなければ、まず必要な業務ロジックを持つドメインオブジェクトを作成する。
また、既存のドメインオブジェクトがニーズに合わなければ、サービスクラスが使いやすくなるように改良する、など、ドメインオブジェクトを少しずつ改良し充実させることが、アプリケーション全体の見通しを良くし、業務ロジックの重複を防ぐことになる。
サービスクラスの実装をドメインモデルの成長の機会とする
サービスクラスに業務ロジックを書きたくなったら、それをドメインモデル改良の機会として積極的に活用する。
サービスクラスの設計を単純に保つために、ドメインオブジェクトの追加・改良を繰り返すことでドメインモデル全体が成長し、アプリケーション全体の業務ロジックがわかりやすく整理される。
例えば、一つのドメインオブジェクトが複数のサービスクラス(複数の業務機能)から利用されるようになれば、コードの重複を防いでいる良い傾向である。
逆に、ドメインオブジェクトの変更が、業務的なつながりとは異なるサービスクラスに影響が出た場合は、ドメインオブジェクトの設計に問題があると判断できるので改良を試みる。
画面の多様な要求を小さく分けて整理する
プレゼンテーション層に影響される複雑さ
いくらドメインモデルを充実させても、サービスクラスの設計は複雑になりがち。
その原因の一つが、サービスの依頼元のプレゼンテーション層となる。
例えば、検索画面は一つ一つの検索条件ごとに異なる画面を用意することは現実的ではないので、一つの検索画面でありとあらゆる検索条件を扱う「何でも検索画面」となりやすい。
こういった画面の多様な要求に対応するために、サービスクラスでは入力された内容や利用者の利用履歴、個人設定などをもとに場合ごとの対応をする必要が出てくる。
このような多様な要求をサービスクラスの1メソッドに押し込めると、if 文だらけになり、コードの見通しが悪く、変更もしづらくなる。
このような画面の複雑さに由来するサービスクラスの複雑さを解消する方法を以下で述べる。
サービスクラスを小さく分ける
オブジェクト指向設計の基本は小さく分けて独立させた部品を用意すること。
サービスクラスの設計も、まずはサービスを独立性の高い部品に分けることを考える。
サービスクラスを小さく分ける基本は、まず登録系のサービスと参照系のサービスを分けること。
例えば、以下のサービスは単純であるが、参照と更新の分離の原則に反している。
class BankAccountService
def initialize(account)
raise ArgumentError, "account must be a BankAccount" unless account.is_a?(BankAccount)
@account = account
end
def withdraw(amount)
@account.withdraw(amount) # 口座から差し引く
@account.balance # 残高を取得して返す
end
end
上記を原則に従って参照と更新でクラス分離すると以下のようになる。
# 参照系のサービス
class BankAccountService
def initialize(account)
raise ArgumentError, "account must be a BankAccount" unless account.is_a?(BankAccount)
@account = account
end
def balance
@account.balance
end
def can_withdraw(amount)
current_balance = balance
current_balance.has(amount)
end
end
# 更新系のサービス
# こちらでは何も情報を返さない
class BankAccountUpdateService
def initialize(account)
raise ArgumentError, "account must be a BankAccount" unless account.is_a?(BankAccount)
@account = account
end
def withdraw(amount)
@account.withdraw(amount)
end
end
上記に作成した三つのメソッドが、預金の引き出し機能を実現する基本部品となる。
これらが独立して利用できることが重要であり、それができれば個別にテストが可能となる。
意味のある最小単位で、かつ単独でテスト可能な単位にメソッドを分割するのがサービスクラス設計の基本となる。
小さく分けたサービスを組み立てる
預金を引き出す機能を実現するためには、上記の三つのメソッドを組み合わせれば良い。
- 残高が不足していないことを確認する: can_withdraw(amount)
- 残高を更新する: withdraw(amount)
- 更新後の残高を照会する: balance
これらの組み立てを実現するには二つの選択肢がある。
- コントローラで組み立てる
- 新たな組み合わせ用のサービスクラスを作成する
コントローラで組み立てる
コントローラで直接サービスクラスを組み立てる場合は、残高不足の時に画面を出し分けるなどの処理が記述しやすくなる。
一方で、業務ルールの変更によって if 文による条件分岐が増えると、コードの見通しが悪くなり、別画面で同じ処理を実装したい場合には重複が発生してしまう。
class BankAccountsController < ApplicationController
before_action :set_account
def withdraw(amount)
unless @query_service.can_withdraw(amount)
redirect_to bank_account_path(@account), alert: "残高が不足しています"
return
end
@update_service.withdraw(amount)
@balance = @query_service.balance
redirect_to bank_account_path(@account), notice: "引き出しました" # ページ内で@balanceを参照
end
private
def set_account
@account = BankAccount.find(params[:id])
@query_service = BankAccountService.new(@account)
@update_service = BankAccountUpdateService.new(@account)
end
end
組み合わせ用のサービスクラスを作る
サービスクラスを組み合わせるクラスとして以下のようなシナリオクラスを作成する。
この設計の場合、基本サービスを提供するサービスクラス群とそれらを組み合わせるシナリオクラスという二層構造になり、構造が明確で見通しがよくなる。
さらに、コントローラクラスに記述する場合の同じ機能の記述が複数のコントローラで重複することを防ぐことができる。
また、「残高不足」を例外として扱うことで、呼び出し元との結合を弱くし、独立性を高めることができる。
具体的には、ユースケースによって呼び出し元コントローラがこの例外に対するアクション(リダイレクト、コンソールにログ出力)を柔軟に設定できる。
class BankAccountScenario
def initialize(account)
@query_service = BankAccountService.new(@account)
@update_service = BankAccountUpdateService.new(@account)
end
def withdraw(amount)
raise IllegalBalanceError, "残高が不足しています" unless @query_service.can_withdraw(amount)
@update_service.withdraw(amount)
@query_service.balance
end
end
利用する側と提供する側の合意を明らかにする
前項で作成したシナリオクラスは、メソッドの中で更新系のBankAccountUpdateService
を呼び出す前にcan_withdraw
メソッドで残高不足を確認し、例外を通知する設計となっている。
この例のように、更新系のサービスでは、使う前に使う側のクラスが事前に状態を確認するという約束事にすると、サービスを提供する条件が明確になり、サービスを提供する側のクラスの設計がシンプルになる。
このような、サービスを利用する側と、サービスを提供する側とで、サービス提供の約束事を決め、設計をシンプルに保つ技法を契約による設計と呼ぶ。
この設計と対照的な技法が、「サービスを提供する側は、利用する側が何をしてくるかわからない」という前提で様々な防御的ロジックを書く防御的プログラミングである。
防御的プログラミングは、無意味にコードを複雑に読みにくくする。
また、どれだけ防御しても、想定外の使われ方が起き、想定外の戻り値が起きてしまうことが多い。そもそも防御が無理なケースもある。
サービスクラスの設計にあたっては、契約による設計の思想に従って、プレゼンテーション層(使う側)と、どういう約束事でサービスを提供するかを決めるのが設計の重要なテーマとなる。
基本的な約束事としては以下のようなものがある。
- null を渡さない / null を返さない
- 状態に依存する場合、使う側が事前に確認する
- 約束を守った上でさらに異常が起きた場合、例外で通知する
シナリオクラスのメリット
上記までで説明したように、基本的なサービスを組み合わせた複合サービスを提供するのがシナリオクラスである。
シナリオクラスはコードの整理に役立つだけでなく以下の利点も持つ。
- アプリケーション機能の説明
- シナリオテストの単位
小さなサービスクラスの集合だけでは、どこに何が書いてあるかわかりにくくなるが、業務の視点で必要となる機能単位をシナリオクラスで表現することでこの問題を解決できる。
シナリオクラス内でどうやって具体的に機能を実現するかを基本単位のサービス単位で表現することで、そのシナリオクラスをテストするコードは業務手順書となり、業務の具体例となる。
データベースの都合から分離する
業務アプリケーションの中核は、データベースとのデータの入出力である。
しかし、データベースへの CRUD 操作をそのままサービスクラスのコードに並べてしまうと、異なる画面や機能に、同じようなロジックが重複しがちとなる。
つまり、データベースの入出力に引っ張られたプログラムは、そのような業務ロジックの重複や散在により見通しが悪くなり、変更もしづらくなる。
データベース操作の都合に引っ張られずにサービスクラスをすっきりと記述するには以下のような方法を用いる。
データベース操作ではなく業務の関心事で考える
重要なことは、業務での関心事は「情報の記録と参照」であり、「データベースの単なる CRUD 操作」ではないということ。
この考え方を実践するためには、業務の関心事として「記録と参照」を記述する以下のようなリポジトリクラスをドメインモデルへ追加する。
# app/repositories/order_repository.rb
class OrderRepository
def find(id)
OrderHeader.find(id).to_domain
end
# 注文ヘッダーテーブルと注文明細テーブルに別々にインサートする処理をラップ
# サービスクラスから呼び出す時は「注文を登録する」という単純な業務上の関心事になる
def register(order)
ActiveRecord::Base.transaction do
# 注文ヘッダーテーブルへのインサート
header = OrderHeader.create!(
customer_id: order.customer_id,
order_date: order.order_date
)
# 注文明細テーブルへのインサート
order.details.each do |detail|
OrderDetail.create!(
order_header_id: header.id,
product_id: detail.product_id,
quantity: detail.quantity
)
end
header.to_domain
end
end
end
上記のクラスは、業務の視点からの記録と参照の関心事を、リポジトリとして宣言している。
リポジトリはドメインオブジェクトの保管と取り出しができる架空の収納場所であり、オブジェクト指向の観点からはすべてのドメインオブジェクトをメモリ上に保管していつでも取り出せる仕組みと考えることができる。
そして、データベース操作系の処理はリポジトリの背後に隠蔽し、リポジトリのメソッド名・引数・返却値はすべて業務の用語で表現する。
これにより、サービスクラスからはリポジトリを利用して業務データの記録や参照を行うことで、データベースの入出力処理はデータベース操作ではなく、業務の関心事としてシンプルな記述で呼び出すことができる。
参考文献
この記事は以下の情報を参考にして執筆しました。