ドメイン駆動開発、別名「DDD」を学ぶ

2024/10/15に公開

概要

この記事は筆者が、ドメイン駆動開発(DDD)を勉強した内容をメモとして記載しています。
ドメイン駆動開発(以降はDDD)は内容が簡単ではないので、わかりづらい部分もあると思いますが、温かい目で読んでいただけると幸いです。

勉強した教材を下記に記載します。

ドメインについて

DDDを学習にあたり、ドメインとは何か理解する必要がありました。
書籍や技術記事から、ドメインとはシステムで実現したい事象を指していると認識しました。
また重要なのはドメインではなく、ドメインに何を含めるのかが重要であり難しいとも感じました。ただ、ここまでは一般的なシステム開発と何も変わらず当たり前のことだなと印象を受けました。

DDDでは以下の流れで開発していくと思いますが、No1と2は今回の学習では扱われませんでした。理由としては、いきなり理解することが難しく混乱する恐れがあるためのようです。そのため、この記事でもNo3のドメインモデルをプログラムに落とし込む、ドメインオブジェクトについて書いて行きたいと思います。
※モデリングについても、後々追記していきたいと考えています。

  1. ドメインを理解する
  2. ドメインの取捨選択(モデリング)
    モデリングをした結果得られるものが、ドメインモデルと言われる
  3. ドメインモデルからプログラムに落とし込む
    これがドメインオブジェクトと言われる

モデリング

只今学習中です。

ドメインオブジェクト

値オブジェクト

概要

値オブジェクトとは、プリミティブ型にシステム固有の値を定義して、扱うことを言います。
例えば、ユーザ名などユーザクラス内に文字列を定義できるものをあえてクラスとして定義することで、ユーザ名に対してのロジックを集約することができ、堅牢で保守しやすいコードを作成することができる。
またユーザクラス内でロジックを作成することがなくなるので、改修しやすくもなる。

/* 文字列型を定義している */
class User1(val name:String)

class UserName(value: String) {
    val name = value

    init {
        if(name.length < 1){
            throw Error("名前の文字数が足りません")
        }
    }
}

/* UserName型を定義している */
class User2(val name:UserName)

性質

値オブジェクトはシステム固有の値であり、3つの性質がある。
それぞれの性質を実現させるように実装する必要がある。

・不変である
・交換が可能である
・等価性によって比較される

不変

これは値オブジェクトを格納する変数を定数にするなど、再導入できないようにする他、
値オブジェクトはシステム固有の値のため、Setterのような値を上書きできてしまうものを定義すべきではありません。

交換

上記に記載した通り、値オブジェクトにはSetterは使用すべきではないのですが、再度インスタンス化することで、値オブジェクトを変更(交換)することができます。

等価性

同じ値をオブジェクト同士を比較する際は、値オブジェクト自身を比較すること。
これは値オブジェクトが、システム固有の値として取り扱っているためです。

値オブジェクトにする基準

プリミティブな値を値オブジェクトにするかどうかは、基本的にドメインモデルとして定義された概念であれば値オブジェクトにしそうでなかった場合は、「ルールやロジックが存在するか」、「単体で取り扱いたいのか」によって判断するのがよさそうです。

値オブジェクトの特徴

値オブジェクトを定義することで定義するクラスやファイルは多くなってしまいますが、以下のメリットもあります。

  • 表現力が増す
    プリミティブ型と比べてクラスに定義することで、値オブジェクトがどういったものなのか早く認識することができる

  • 不正な値を存在させない
    インスタンスの初期化処理で、値オブジェクトのルールを適用することができる

  • 誤った代入を防ぐ
    値オブジェクトを使用することで、関数などの引数に値オブジェクトの型を指定することができ頭った代入を防ぐことができる

  • ロジックの散在を防ぐ
    値オブジェクトに関するルールがクラスに集約されるので、保守しやすくなる

エンティティ

概要

値オブジェクト同様にドメインオブジェクトですが、同一性があるかどうかで値オブジェクトと比較することができます。同一性とは属性が変わった際に、同じか識別できることをここでは指します。
例えば、ユーザという視点で見ると名前・体重・身長が変わってもユーザは同じだと認識できますが、名前の視点で見ると名前が変わってしまうと全くの別のものになってしまいます。
ここでいうユーザが「エンティティ」で、名前や身長などが「値オブジェクト」になります。

特徴

エンティティにも性質が3つ存在します。

  • 可変
  • 同じ属性であっても区別される
  • 同一性によって区別される

可変

値オブジェクトとは異なり、エンティティは同一性を持っているため、可変にすることができます。
※可変にすることは思わぬバグが発生する危険性があるため、可能な限り不変にした方がいい。

class User(var name: String) {

    init {
        validateName(name)
    }

    fun changeName(newName: String) {
        validateName(newName)
        this.name = newName
    }

    private fun validateName(name: String) {
        if (name.length < 3) throw IllegalArgumentException("Name is too short")
    }
}

同じ属性であっても区別される

エンティティは同じ属性でも区別できるようにしないといけませんが、現実世界では区別することができてもITの世界では区別することができないパターンがあるかもしれません。
そこでITの世界でも識別できるように、IDという識別子を作成します。IDは識別するために必要なものなので、不変にしておきます。

class User(val id: Int, var name: String) {

    init {
        validateName(name)
    }

    fun changeName(newName: String) {
        validateName(newName)
        this.name = newName
    }

    private fun validateName(name: String) {
        if (name.length < 3) throw IllegalArgumentException("Name is too short")
    }
}

同じ属性であっても区別される

値オブジェクトとは異なり比較する際は、識別できる値を使用して比較します。

エンティティとして取り扱う判断基準

値オブジェクト・エンティティどちらとして取る扱うかは、その概念がライフサイクルがあるかどうかで判断するといい。
例えばユーザを考えると、システムに新規登録され誕生し、システムの利用をやめる際に削除されます。
もしライフサイクルを持たず、システムによってもライフサイクルを持つことが無意味である場合、ひとまず値オブジェクトとして取り扱うのがいいとされています。

ドメインサービス

概念

ドメインサービスとは、ドメインのためのサービスのことです。DDDにはサービスと言われるものが大きく2つ分類されています。一つが「ドメインサービス」、もう一つが「アプリケーションサービス」で、アプリケーションサービスは、システムのためのサービスです。
ドメインサービスは、値オブジェクト・エンティティに記述すると不自然になってしまう振る舞いを解決するためのサービスです。
例えば、ユーザの重複確認を例にしてみます。
ここでは、エンティティのユーザに重複確認のメソッドが追加されていますが、このユーザをインスタンス化した際に自分自身が重複していない確認することになり不自然になってしまいます。
そこでUserServiceクラスを作成し、ここでユーザが重複していないかを行うことで不自然さを解消することができます。

class User(val id: Int, var name: String) {

    init {
        validateName(name)
    }

    fun changeName(newName: String) {
        validateName(newName)
        this.name = newName
    }

    private fun validateName(name: String) {
        if (name.length < 3) throw IllegalArgumentException("Name is too short")
    }

    // 追加した重複確認のふるまい
    // 自分自身が重複しているのか確認することになってしまう。。
    fun exists(user: User): Boolean {
        // 重複を確認するコード
        return true
    }
}

class UserService{
    fun exists(user: User): Boolean {
        // 重複を確認するコード
        return true
    }
}

注意点

ドメインサービスには、値オブジェクトやエンティティのロジックを移し替えることができます。しかし安易にドメインサービスにドメインオブジェクトの内容を記述してしまうと、ロジックが散在して保守しにくいコードになってしまいます。
このように本来ドメインオブジェクトに記述されるべきことが、サービス層に記述されてしまっていることをドメインモデル貧血症といいます。
ドメインサービスは極力使用することは避け、ドメインオブジェクトに記述すると違和感があるもの以外は使用を避けるべきです。

リポジトリ

概要

データベースや外部システムとのやり取りに使用される場所。
ドメインサービスやアプリケーションサービスにも記述することはできるが、それを行なってしまうと処理が複雑になってしまい、本来重要な処理がわかりづらくなってしまう。

依存性の逆転

リポジトリを使用して格サービスクラスから処理を呼び出す際は、interfaceを使用することで柔軟性を上げることができる。
これは呼び出し側は呼び出し元のロジックを気にすることなく期待通りの結果を取得することができるので、極端な話RDMSからNoSQLに呼び出し元の処理を変更したとしても、呼び出し元は何も影響は受けない。そのため、リポジトリを使用する際は依存性の逆転を使用すべきだと思います。

https://zenn.dev/suguru_3u/articles/4457d5105b6458#d-(dependency-inversion)-依存性逆転の原則

アプリケーションサービス

概要

上記に記載したドメインサービスとは異なり、アプリケーションで実現したいユースケースを実現させるためのクラスです。そのため、エンティティを呼び出したり、リポジトリを呼び出したりするなど各クラスの橋渡しを行う場所とも言えると思います。
以下の例は、ユーザを登録したいユースケースを実現するためにプログラムが書かれています。

class UserApplicationService(
    val userService: UserService
    val userRepository: IUserRepository
) {
    fun Register(name: String) {
        val user = new User (new UserName (name));

        if (userService.Exists(user)) {
            throw new CanNotRegisterUserException (user, "ユーザは既に存在しています。");
        }

        userRepository.Save(user);
    }
}

気を付けるべきポイント

アプリケーションサービスは、使用する上でいくつか気を付けるべき点があります。

  • ドメインルールを記述しない
  • メンバーを使用しない関数があった際は、クラスの分割を検討する
    • 分割した際はパッケージを作成して、誰が見てもすぐにわかるようにする
  • レスポンスする際はDTOを使用して、レスポンス先にドメインオブジェクトを渡さないようにする
    • アプリケーションサービス以外でドメインオブジェクトを使用しないようにする

ドメインルールを記述しない

ドメインルールをアプリケーションサービスに記述すると、複数の箇所で似たような処理が実装される可能性があり修正を行なった際に、漏れが発生する可能性があります。

メンバーを全て使用しない関数がある場合、クラスの分割を検討する

ユースケースを実現するためにアプリケーションサービスを利用しますが、メンバーを全て使用していないメソッドがあった際は、ファイル分割を検討するいい機会です。
ファイル分割を行うことでクラスの肥大化を抑えることができますし、クラス名をより具体的な名前にすることでより保守しやすいクラスを作成することができます。

レスポンスする際はDTOを使用する

原則ドメインオブジェクトはアプリケーションサービスとドメインサービス以外では、使用するべきではないとされています。理由は、どこでもドメインオブジェクトを使用することができてしまうと思わぬバグが発生したり、読みづらいプロジェクトになってしまいます。
そのためアプリケーションサービスのレスポンスは、DTO(Data Transfer Object:エンティティの中から必要な値だけを取得したクラス)を使用することで、保守しやすいようにする必要があります。

集約

概要

集約とはデータやロジックが集合しているオブジェクトのことで、エンティティが一つの集約と考えることができます。集約には、集約の外部から内部のオブジェクトを操作してはいけないルールがあり、集約の不変条件を守る必要があります。難しい言葉が出てきましたが、具体例を下記に記載します。
もしこのルールが守れなかった場合、UserクラスのロジックがUserMemberクラスに集まってしまい、クラスの責務が煩雑かし保守しづらいプログラムになってしまいます。
また集約のルールを守るためには、無闇にgetterを使用しないことが大切になります。

// 集約できていない例
class User(
    val userId: UserId,
    var userName: UserName
)

class UserMember(
    val userList: List<User>,
){
    fun changeUserName(userId: UserId, newUserName: UserName) {
        userList.forEach() {
            if (it.userId == userId) {
                it.userName = newUserName // ユーザ名の変更をUserクラス外で行なっている
            }
        }
    }
}

// 集約できている例
class User(
    val userId: UserId,
    val userName: UserName
){
     fun cheangeUserName(newUserName: UserName): User {
        if(newUserName == null) {
            throw IllegalArgumentException("ユーザ名がnullです")
        }
        return User(userId, newUserName)
    }
}

class UserMember(
    val userList: List<User>,
){
    fun cheangeMemberName(userId: UserId, newUserName: UserName): UserMember {
        return UserMember(
            userList.map {
                if (it.userId == userId) {
                    it.cheangeUserName(newUserName) // Userクラス内でユーザ名の変更を行なっている
                } else {
                    it
                }
            }
        )
    }
}

仕様

概要

仕様とはオブジェクトの複雑な評価をメソッドではなく仕様専用のクラスに抽出することで、評価ロジックをドメイン内に閉じ込める方法です。そうすることで、サービス層にロジックの侵入を許すことなく実装することができる。

// 単調な評価
class User(
    val userId: UserId,
    var userName: UserName
){
    fun isAdult(): Boolean {
        return userId.value >= 20
    }
}

class UserMember(
    val userList: List<User>,
){
    fun countAdultMenber(): Int {
        var count = 0
        userList.forEach() {
            if (it.isAdult()){
                count += 1
            }
        }
        return count
    }
}

// 複雑な評価
// ユーザにはプレミアムユーザと呼ばれるタイプが存在する
// メンバーに所属するユーザの最大数は30名まで
// プレミアムユーザが10名以上所属しているメンバーの最大数が50名に引き上げられる
class User(
    val userId: UserId,
    var userName: UserName,
    val age: Int
    val isPremium: Boolean
){
    fun isAdult(): Boolean {
        return age >= 20
    }
}

class UserMember(
    val list: List<User>,
){
    fun countMenber(): Int {
        return list.count()
    }
}

// 必要であればデータベースからデータを取得する。
// その場合、引数にリポジトリを含んでもいい
class MenberCountLimitSpecification {
    fun isSatisfiedBy(userList: UserMember): Boolean {
       val premiumUserCount = userList.list.filter { it.isPremium }.count()
        val menberLimitNum = if(premiumUserCount >= 10) 50 else 30
        return userList.countMenber() <= menberLimitNum
    }
}

注意点

仕様は複雑な評価全てを対象にする銀弾丸ではないことを注意しておきたいです。
仕様にはリポジトリを組み合わせて仕様するパターンもありますが、検索を行う時は注意が必要です。
ドメインの流出を防ぐことを目的に仕様を使用した場合、検索のSQLには条件指定せずに全件取得を行うと思います。その場合、検索ルール、つまりドメインのルールは守られますが、SQLで全件取得後にプログラムで頑張って検索条件のデータを探すことになり、パフォーマンスの問題が発生します。
この問題を解消するには、ドメインルールをリポジトリのSQL文を使用している箇所に持っていく必要ありますので、ドメインルールの流出をどこまで守るのかケースバイケースを考える必要があります。

Discussion