yujiro's blog

「インターネット上で正しい答えを得る最善の方法は、質問することではない。間違った答えを投稿することだ」by ウォード・カニンガム

DDD - 仕様パターンの紹介

DDD には仕様パターンというものが存在する。

仕様パターンは、

  • バリデーションなどの評価処理
  • リポジトリと組み合わせたフィルタリング機能

この2つに使われる事が多い。

評価処理

評価処理とは、オブジェクトがある基準に達しているのか評価する 処理である。

定義した評価ロジックは、バリデーションの際に使用されることが多くなるだろう。

ドメインオブジェクトへの実装

真っ先に思い浮かぶのは、これをドメインオブジェクトに実装する方法だ。

例として、高校のWEBシステムにおいて、

「模試の申し込みは2,3年生の特進クラスの生徒しかできない」

というバリデーションロジックをドメインオブジェクトに実装してみよう。

class Student {
    ...
    private val birthday: Date
    private val classId: Int

    fun canApplyPracticeExam(class: Class): Bool {
        return class.type == ADVANCED && birthday....省略
    }
}

class Class {
    private val id: Int
    private val type: Type
    enum Type {
        SPORTS, NORMAL, ADVANCED
    }
}
  • 2,3 年生だから、Student がもつ誕生日から計算しよう
  • 特進タイプだから、クラスオブジェクトも必要だ、引数に渡せばよいか

など考えながら実装したとき、

「あれ、定義するのはStudent クラスでいいのか...??」という疑問が生まれてくる。

このようにバリデーションをするにあたって複数のドメインモデルが必要な場合、

どのドメインモデルに定義するべきなのか という問題が生じる。

また、バリデーションに必要なドメインオブジェクトが1つのみである場合でも、

class Class {
    fun isHoge: Bool
    fun isFuga: Bool
    fun isPiyo: Bool
    ...
}

といった具合で評価メソッドが次々と追加されていって、

ドメインオブジェクトをひと目見たときに様相がわからない。

なによりドメインオブジェクトはコードによって自身が何者であるか語る のが重要であるから、こういった評価処理をいくつも羅列するのは避けたい。

アプリケーションサービスに実装

次はアプリケーションサービス に実装する方法を考える。

class StudentApplicationService {
    private val studentRepository: StudentRepository
    private val classRepository: ClassRepository

    fun applyPracticeExam(studentId: Int) {
        val student = studentRepository.find(studentId)
        val studentClass = classRepository.find(student.classId)
         if !(studentClass.type == ADVANCED && student.birthday....省略) {
            ...
        }
    }
}

となるが、

ドメインオブジェクトに定義されていた

studentClass.type == ADVANCED && student.birthday....省略

このロジックがアプリケーション層ににじみ出るのはまずい。

この部分はドメインレイヤが担うべき重要知識だし、サービスレイヤに記載することになると、将来的にドメインロジックがあちこちに散らばることにつながる。

仕様オブジェクトによる解決

そこで仕様オブジェクトの登場である。

まず説明だけすると、

  • 評価するのに必要なドメインオブジェクトを取得できるリポジトリをもった仕様オブジェクトを作成
  • アプリケーションサービスからはこの仕様オブジェクトを使用する

といった具合になる。

先程のコードを修正してみよう。

class StudentSpecification(studentRepository: StudentRepository, classRepository: ClassRepository) {
    private val studentRepository: StudentRepository
    private val classRepository: ClassRepository
    fun isSatisfied(studentId): Bool {
        val student = studentRepository.find(studentId)
        val studentClass = classRepository.find(student.classId)
        return !(studentClass.type == ADVANCED && student.birthday....省略)
    }
}

class StudentApplicationService {
    ...
    fun applyPracticeExam(studentId: Int) {
        val spec = StudentSpecification(studentRepository, classRepository)
        if !spec.isSatisfied(studentId) {
            ...
        }
    } 
}

これで、

ドメインの知識を外部に漏らさず、ロジックを集約

することが出来た。

フィルタリング機能

仕様パターンのもう一つの使用例が、リポジトリと組み合わせたフィルタリング機能である。

まず単純な例として、 「誕生日が早生まれ(1,2,3月)の男子生徒を抽出する」のを考える。

仕様オブジェクト自体は、先程「評価処理」で作成したのと同じような感じで、

class StudentSpecification {
    fun isSatisfied(student: Student): Bool
}

class EarlyBornMaleStudentSpecification: StudentSpecification {
    fun isSatisfied(student: Student): Bool {
        ...
    }
}

を作り、これをリポジトリに渡す。

class StudentRepositoryImpl: StudentRepository {
    ...
    fun getEarlyBornMale(studentSpecification: StudentSpecification) {
        val students: [Student] = sql.findAll()
        val results: [Student] = students.filter {
           studentSpecification.isSatisfied(it)
        }
        return results
    }
}

リポジトリドメインレイヤからデータストアを意識せずにデータを取得できる機能を提供するもので、ドメイン知識を記述してはならない。

そのためリポジトリには仕様のインタフェースを渡してisSatisfied メソッドをディスパッチさせ、結果をフィルタリングする形にする。

これで仕様オブジェクトを渡す形で、ロジックは仕様に隠蔽したまま必要なデータを取得できる。

問題

上記はDDD 的には正しいが、アプリケーション上、大きな問題を孕んでいる。

それはリポジトリで全生徒のリストを取得しているので、パフォーマンスが悪いという点。

SQL のwhere 句を使って取得するほうが遥かにパフォーマンスが良い。

数千ぐらいのデータ量であればさほど問題にはならないが、それでも遅いより早い方がユーザーにとっていいことは間違いない。

この問題に関しては、

の2つが考えられる。

SQL を遅延実行させる

リポジトリの返り値はORMのオブジェクトをラップしたコレクションオブジェクトの形にして、アプリケーションサービスでtoList のようなメソッドを発行したらSQL が実行されるようにする。

リポジトリが返すのは、上の例でいえば、全ユーザーを取得するためのコレクションオブジェクトに留めるという形。

イメージで言うと、例えばRails のMongoid は、

Article.where(user_id: "1")

の時点ではSQL (MongoDB はNO SQLだけど)を発行しない。

これにto_a をして初めてクエリが発行される。

ただ、リポジトリがこのMongoid のコレクションオブジェクトを返却してしまったら、アプリケーション層がデータストアを知ってしまうことになる。

リポジトリは、リポジトリを使うときに何のデータストアを使用しているか分からない形にしなければいけない。(この辺りリポジトリ編でまた書こうと思う)

そのため、特定のデータストアのコレクションオブジェクトはラップしてあげる必要がある。

kotlin でいうと Sequence インターフェースを実装したカスタムクラス を作る感じ。

イメージだけ下記に記載する。

class StudentSequence: Sequence {
    private val db: HogeSQL

    ...

    fun toList() -> [Student] {
        db.execute()
    }
}
class StudentRepository {
    ...
    fun getStudents(): StudentSequence {
    ...
    }
}
class StudentApplicationService {
    ...
    fun showEarlyBoneMale() {
       repository.getStudents.where(/*仕様オブジェクトを使ってSQL組み立て*/).toList()//← クエリ実行
    }
}

という感じのイメージ。


データの抽出にドメイン知識が必要でかつ仕様パターンを使いたい場合は割と凝ったことをしなくてはならない。

設計としての正しさ、費用対効果、パフォーマンス、、

様々な観点を天秤にかけて賢い開発をしたいものである。