依存関係逆転の法則とは
コアのロジックが実装の詳細に依存しないようにして、モジュール間を疎結合にしましょうという原則。
Wikipediaでは以下のように説明されている。
- 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
"High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces)."- 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。
"Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions."
ダメな例
非常に簡単な例を挙げよう。
ユーザIDからユーザ名を引くだけの非常に簡単なサービスを考える。
これを単純に実装すると、以下のようなプログラムになるだろう。(筆者の好みでScalaで解説する)
object FetchUserService {
def run(userId: Int): Future[Either[ServiceError, String]] = {
UserRepository.select(userId).map {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
}
}
object UserRepository {
def select(id: Int): Future[Option[User]] = {
// DBアクセスなどの下位ロジック
}
}
この実装では FetchUserService
が UserRepository.select
を直接使っているため、 サービスのコア部分のロジックがDBアクセスのような下位の実装に依存してしまっている。
それの何がまずいかというと FetchUserService
をテストしようとすると、必然的に DB を用意する必要が出てくるという点である。
サービスのコア部分は重点的にテストをするべき場所であるため、テストが簡単にできる状態にしておきたい。
そのため、下位モジュールへの依存をできるだけ断ち切り、単体でテスト可能な設計にしておくことが重要なのである。
よくある解説
依存関係逆転の法則のよくある解説では、この問題をインターフェースを用いて解決している。Scala ならば trait
を使うことになる。
例えば、以下のようにする。
class FetchUserService @Inject()(userRepository: UserRepository) {
def run(userId: Int): Future[Either[ServiceError, String]] = {
userRepository.select(userId).map {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
}
}
trait UserRepository {
def select(id: Int): Future[Option[User]]
}
object UserRepositoryImpl extends UserRepository {
def select(id: Int): Future[Option[User]] = {
// DBアクセスなどの下位ロジック
}
}
UserRepository
を trait
化し、DBアクセスなどの下位ロジックはそれを実装する UserRepositoryImpl
側におく。
FetchUserService
は UserRepository
を実装したインスタンスを DI などを経由して受け取り、それを利用して select
するようにする。
こうすることで、FetchUserService
はDBアクセスロジックに直接依存しなくなった。
テスト時にはモック版の UserRepository
を渡すことで DB がなくてもテストが可能である。
UserRepository
は下位モジュールが満たすべき仕様を定めたものなので、これも上位のモジュールであるとみなせば、上位モジュールである FetchUserService
と UserRepository
は他のモジュールに依存しておらず、下位モジュール UserRepositoryImpl
は上位モジュールに依存していることになる。
最初の例の時と依存関係が逆転しているため、依存関係逆転の法則と呼ばれる。
この方法の問題点
この方法にはいくつかの問題点がある。
まず一つは、FetchUserService
の単体テストのために UserRepository
のモックが必要となることだ。
モックを利用したテストはしばしばモック側のバグにより壊れることがあり、モックのメンテナンスに馬鹿にならないコストがかかる。
サービスの単体テストのはずがモックのテストになっていたなんて話もしばしば耳にする。
次の問題点は、DB側の都合が select
の返り値の型として漏れ出していることである。
select
の返り値の型が Future
であるのは完全に DB 側の都合であり、FetchUserService
からすれば関心がないはずだ。
実際、FetchUserService
では Future
が関わるような処理は select
以外していない。
Future
が絡んでこないのであれば、FetchUserService
は単なる逐次実行プログラムとなり、よりテストしやすくなるはずである。
ではどうするか
ここで依存関係逆転の法則を思い出そう。
上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
例としてインターフェースに依存するべきと書いてある。
インターフェースを使うというのは実装方法の一つに過ぎないのである。
抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。
つまり、具象的な実装内容に依存しない形で「抽象」を定義し、それに依存するようにすれば良いのである。
リクエストオブジェクトを介した依存
そこで、本稿では select
を実際に実行する代わりに、UserRepository
に実行してほしい内容を記述したリクエストオブジェクトを作ることを考える。
インターフェースの代わりにこのリクエストオブジェクトを「抽象」として利用する。
これは例えば以下のようなデータ型である。
case class SelectUserRequest(id: Int)
このデータを UserRepository
に渡すと、UserRepository
はそれに従って処理を実行する。
object UserRepository {
def dispatch(request: SelectUserRequest): Future[Option[User]] = {
// DBアクセスなどの下位ロジック
}
}
さて、このオブジェクトを FetchUserService
が UserRepository
に直接渡すのでは意味がない。
FetchUserService
が行うべきは、 SelectUserRequest
とそれが返してきた結果をその後どう処理するか(継続と呼ぶ)のペアを作って返すことである。
そうすれば、あとは FetchUserService
の外側で処理を実行することができる。
object FetchUserService {
def run(userId: Int): (SelectUserRequest, Option[User] => Either[ServiceError, String]) = {
// リクエスト
val request = SelectUserRequest(userId)
// 返ってきたものをどう処理するか (継続)
val cont: Option[User] => Either[ServiceError, String] = (userOpt: Option[User]) => {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
// ペアにして返す
(request, cont)
}
}
全体の処理を実行するには、FetchUserService.run
を実行した後で、返されたリクエストを UserRepository
に渡し、その結果をさらに継続に渡せば良い。
// アプリケーションとして公開する関数
def fetchUser(userId: Int): Future[Either[ServiceError, String]] = {
val (request, cont) = FetchUserService.run(userId)
val responseFuture = UserRepository.dispatch(request)
responseFuture.map(response => cont(response))
}
これで FetchUserService
から完全に UserRepository
への依存を排除することができた。
また Future
も FetchUserService
からなくなっている。
FetchUserService
の返り値は一見複雑になっているが、一つ目の返り値の SelectUserRequest
は単なるデータ型であるため容易にテストできる。
二つ目の返り値である継続は関数オブジェクトなので、通常の単体テストと同じく入力を与えて出力を検査すれば良い。
一般化する
この方法を一般化していこう。
まずは、リクエストを一般化する。
SelectUserRequest
以外のリクエストを可能にするために、一般的なリクエストの型 RepositoryRequest
を定義する。
RepositoryRequest
は返却するべきオブジェクトの型情報を持つ必要がある。
また、リポジトリごとにリクエストを分岐させるために、リポジトリ単位のリクエスト型も用意しておこう。
sealed trait RepositoryRequest[T]
sealed trait UserRepositoryRequest[T] extends RepositoryRequest[T]
case class SelectUserRequest(id: Int) extends UserRepositoryRequest[Option[User]]
これを処理するリポジトリは次のようになるだろう。
object Repository {
def dispatch[T](request: RepositoryRequest[T]): Future[T] = request match {
case userRepositoryRequest: UserRepositoryRequest[T] => UserRepository.dispatch(userRepositoryRequest)
}
}
object UserRepository {
def dispatch[T](request: UserRepositoryRequest[T]): Future[T] = request match {
case SelectUserRequest(id) => select(id) // Scala2 の場合は asInstanceOf[Future[T]] が必要なようだ
}
private def select(id: Int): Future[Option[User]] = {
// DBアクセスなどの下位ロジック
}
}
次に、FetchUserService
が返すべき型を一般化する。
サービスが返すべき型は、まだリポジトリに対するリクエストがあるなら、リクエストと継続のペアになるはずである。
ただし、その継続の部分でもリポジトリに対してリクエストを行う可能性がある点は留意しておく必要がある。
もしリポジトリに対するリクエストがないのであれば、サービス自体の返り値を返すはずである。
つまり、以下のような型を考えれば良い。
sealed trait ServiceLogic[R]
case class Continue[T, R](request: RepositoryRequest[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]
このとき FetchUserService
は以下のようになる。
object FetchUserService {
def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
val request = SelectUserRequest(userId)
val cont: Option[User] => ServiceLogic[Either[ServiceError, String]] = {
(userOpt: Option[User]) => {
case Some(user) => Done(Right(user.name))
case None => Done(Left(ServiceError.notFound))
}
}
Continue(request, cont)
}
}
任意の ServiceLogic
を実行するためのヘルパー関数を定義しておこう。
def runServiceLogic[R](serviceLogic: ServiceLogic[R]): Future[R] = serviceLogic match {
case continue: Continue[_, R] => runContinue(continue)
case Done(value) => Future(value)
}
private def runContinue[T, R](continue: Continue[T, R]): Future[R] = {
Repository.dispatch(continue.request).flatMap { t =>
runServiceLogic(continue.cont(t))
}
}
すると、fetchUser
は以下のようになる。
// アプリケーションとして公開する関数
def fetchUser(userId: Int): Future[Either[ServiceError, String]] = {
runServiceLogic(FetchUserService.run(userId))
}
以上で完成である。
上位のモジュールである FetchUserService
は下位モジュールである UserRepository
にまったく依存しておらず、容易に単体テストが可能になっていることがわかる。
まとめ
- インターフェースの利用は依存関係逆転の法則の実現方法の一つでしかなく、またその方法にはいくつか問題があった
- 依存関係逆転の法則の「抽象」としてリクエストオブジェクトを利用することで、これらの問題を解決できることを示した
追記: より便利にするには
ServiceLogic
の Continue
は T => ServiceLogic[R]
という型の引数をとる。
この型はどこかでみたことがないだろうか。そう、flatMap
の引数と同じ型である。
Continue
が第一引数として RepositoryRequest
ではなく ServiceLogic
を取るようにすれば、 Continue
の生成は flatMap
と等価ということになる。
sealed trait ServiceLogic[R] {
def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
}
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
また、ServiceLogic
に map
も実装しておくと便利だろう。
sealed trait ServiceLogic[R] {
def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}
Continue
がRepositoryRequest
ではなく ServiceLogic
を取ると RepositoryRequest
から ServiceLogic
を生成する方法がなくなってしまうので、代わりに RepositoryRequest
のレスポンスを直接返り値とするような ServiceLogic
を生成できるようにしておこう。
case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]
ServiceLogic
の定義はこうなる。
sealed trait ServiceLogic[R] {
def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}
case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]
こうしておくと、FetchUserService
は以下のように書ける。
object FetchUserService {
def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
Suspend(SelectUserRequest(userId)).map {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
}
}
例えば、ここで直接 Suspend[Option[User]]
を生成するようなビルダーとして UserRepositoryLogic.select
を定義してみよう。
object UserRepositoryLogic {
def select(userId: Int): ServiceLogic[Option[User]] = {
Suspend(SelectUserRequest(userId))
}
}
すると FetchUserService
はこうなる。
object FetchUserService {
def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
UserRepositoryLogic.select(userId).map {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
}
}
さて、最初のコードを思い出そう。
最初のコードでは FetchUserService
は以下のように書いた。
object FetchUserService {
def run(userId: Int): Future[Either[ServiceError, String]] = {
UserRepository.select(userId).map {
case Some(user) => Right(user.name)
case None => Left(ServiceError.notFound)
}
}
}
見てわかる通り、ほぼ同じコードとなっている。
追記の追記: Free
ServiceLogic
の定義は最終的にこうなった。
sealed trait ServiceLogic[R] {
def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}
case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]
実はこの型は Free
という名前でよく知られたものである。
ただし、Free
では RepositoryRequest
の部分が型引数として与えられる。
以下は cats の Free
の定義 (抜粋 & 一部改変) である。
sealed trait Free[S[_], R] {
def flatMap[U](f: R => Free[S, U]): Free[S, U] = FlatMapped(this, f)
def map[U](f: R => U): Free[S, U] = FlatMapped(this, r => Pure(f(r)))
}
case class Suspend[S[_], T](request: S[T]) extends Free[S, T]
case class FlatMapped[S[_], T, R](free: Free[S, T], cont: T => Free[S, R]) extends Free[S, R]
case class Pure[S[_], R](value: R) extends Free[S, R]
つまり cats を利用している場合、ServiceLogic
は実は自分で定義する必要はない。
RepositoryRequest
を定義すれば、あとは Free
に与えるだけで ServiceLogic
を実装できる。
type ServiceLogic[R] = Free[RepositoryRequest, R]
実行するときは RepositoryRequest
を計算する方法を与えるだけで良い。
// FunctionK (自然変換) の定義方法
val compiler = new (RepositoryRequest ~> Future) {
def apply[A](req: RepositoryRequest[A]): Future[A] = Repository.dispatch(req)
}
def runServiceLogic[R](serviceLogic: ServiceLogic[R]): Future[R] = {
serviceLogic.foldMap(compiler)
}