kanachi-blog

notionでの公開記事をastro-notion-blogを使って公開するよ

知識を表現するためのパターン『ドメイン駆動設計入門 』part1

ドメイン駆動設計とは

ドメイン駆動設計とは、知識に焦点を当てた設計手法。

ソフトウェアを開発する際には開発者はドメインについて学ぶ必要がある。ただそのドメイン知識にも価値あるものと無価値なものがある。ドメイン駆動設計では、利用者を取り巻く世界について知ることで、その価値ある知識を抽出して、実装に落とし込む。

この過程によって、有用な知識がコードに落とし込まれたある種ドキュメントの様相を呈したコードを実装して、役にたつコードを設計する手法をドメイン駆動設計という。

用語説明

  • ドメイン:プログラムを適用する対象となる領域
  • モデル:現実の事象あるいは概念を抽象化した概念
  • ドメインモデル:ドメインの概念をモデリングして得られたモデル
  • ドメインオブジェクト:

    ドメインモデルをソフトウェアで動作するモジュールとして表現したもの。

    ドメインモデルをドメインオブジェクトにする段階で利用者の課題を解決できるモデルかどうかで取捨選択が発生する。

    ドメインの概念自体が変化した時にはドメインモデルを通して変更がドメインオブジェクトまで反映され、ドメインオブジェクトの曖昧さを許さない性質が時として、ドメインモデルの見直しや、ドメインの概念の捉え方の変化も生み出すこともある。すなはち、お互いに影響し合うイテレーティブな関係になる。

ドメイン駆動設計の全体像

本書では2について包括的に学んでいく。

  • ドメイン駆動設計
    1. ソフトウェアにとって重要な概念を抽出するためのモデリング
    2. 概念を実装に落とし込むためのパターン
      1. 知識を表現するためのパターン
        1. 値オブジェクト
        2. エンティティ
        3. ドメインサービス
      2. アプリケーションを実現するためのパターン
        1. リポジトリ
        2. アプリケーションサービス
        3. ファクトリ
      3. 知識を表現する、より発展的なパターン
        1. 集約
        2. 仕様

システム固有の値を表現する「値オブジェクト」

値オブジェクトとは、システム固有の値を表現するために、定義されたオブジェクトのこと。

プリミティブな場合

func PrintFullName() {
    fullName := "taro suzuki"
    fmt.Println(fullName)
}

値オブジェクトの場合

type FullName struct {
    firstName string
    lastName  string
}

func NewFullName(firstName, lastName string) (*FullName, error) {
    if firstName == "" {
        return nil, errors.New("firstName required")
    }

    if lastName == "" {
        return nil, errors.New("firstName required")
    }

    return &FullName{
        firstName: firstName,
        lastName:  lastName,
    }, nil
}

func (m *FullName) FirstName() string {
    return m.firstName
}

func (m *FullName) LastName() string {
    return m.lastName
}

値(値オブジェクト)の性質

  • 不変である

    代入時に変更しているのは変数なので値に関してみると値は不変である。

    FullName はシステム固有の値を表している値オブジェクトです。

    • FullName は値
    • FullName は不変にすべき
    • 値を変更するためのふるまいである ChangeLastName メソッドは FullName クラスに定義されるべきものではない

    値オブジェクトはあくまで「値」であり「値の値」を変更するべきではない。

  • 交換が可能である

    代入こそが値を交換する表現方法です。

    つまり値オブジェクトに値オブジェクトを代入することで交換ができます。

    fullName := model.NewFullName("taro", "suzuki")
    fmt.Println(fullName.FirstName())
    
    // 代入によって交換する
    fullName = model.NewFullName("taro", "sato")
    fmt.Println(fullName.FirstName())
  • 等価性によって比較される

    値オブジェクトはシステム固有の値で、あくまでも値なので、その属性を取り出して比較をするのではなく、値と同じように値オブジェクト同士が比較できるようにする方が自然な記述になります。

    func (m *FullName) Equals(other *FullName) bool {
        if other == nil {
            return false
        }
        return m.firstName == other.firstName && m.lastName == other.lastName
    }

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

氏名に関しては、姓や名もプリミティブな値ではなく値オブジェクトとして実装することも可能。

どこまでを値オブジェクトとするかは、以下の2点を参考にして決定する

  • システム上の制約があるか
  • それ単体で取り扱いたいか

振る舞いを持った値オブジェクト

値オブジェクトは不変ですが、独自のふるまいが定義できます。それにより円とドルを足してしまうなどの不具合を事前にチェックして防ぐことが難しくなります。

type Money struct {
    amount   float64
    currency string
}

func NewMoney(amount float64, currency string) *Money {
    return &Money{
        amount:   amount,
        currency: currency,
    }
}

func (m *Money) Add(arg Money) (*Money, error) {
    if m.currency != arg.currency {
        return nil, errors.New("通過単位が異なります。")
    }
    return NewMoney(m.amount+arg.amount, m.currency), nil
}

func (m *Money) Amount() float64 {
    return m.amount
}

値オブジェクトを採用するモチベーション

  • 表現力を増す
  • 不正な値を存在させない
  • 誤った代入を防ぐ
  • ロジックの散在を防ぐ

まとめ

  • 値オブジェクトのコンセプトは「システム固有の値を作ろう」
  • コードがドキュメントとして機能する、ドメイン駆動設計における基本のパターン
  • ドメインの概念をオブジェクトとして定義しようとするときに、まずは値オブジェクトにあてはめてみることを検討してみる

ライフサイクルのあるオブジェクト「エンティティ」

エンティティと値オブジェクトの違い

両者ともにドメインモデルを実装したオブジェクトであるが、違う点は同一性によって識別されるかどうか

  1. 同一性により識別される「エンティティ」

    属性が変化しても同一性によってオブジェクトが識別される

  2. 同一性により識別されない「値オブジェクト」

    属性が変化すれば違うオブジェクトとして見られる

エンティティの性質

  • 可変である

    値オブジェクトは不変なオブジェクトですが、エンティティは可変なオブジェクトです。値オブジェクトとは異なり、ChangeName で名前の変更が可能です。

    type User struct {
        id   string
        name string
    }
    
    func NewUser(name string) (*User, error) {
        u := &User{
            id: uuid.NewString(),
        }
        err := u.ChangeName(name)
        if err != nil {
            return nil, err
        }
        return u, nil
    }
    
    func (m *User) ChangeName(name string) error {
        if name == "" {
            return fmt.Errorf("ユーザ名は必須です。")
        }
        if len(name) < 3 {
            return fmt.Errorf("ユーザ名は3文字以上です。%s", name)
        }
        m.name = name
        return nil
    }
  • 同じ属性であっても区別される

    システムのユーザ名を変更しても ID が変わらないように、属性が同じでもユーザAとユーザBが区別されるように ID(同一性)によって区別されるのがエンティティです。同じ名前のユーザを作成しても userA と userB は区別されます。

    func (u *UserUsecase) Compare() (bool, error) {
        userA, err := model.NewUser("taro")
        if err != nil {
            return false, err
        }
    
        userB, err := model.NewUser("taro")
        if err != nil {
            return false, err
        }
        // false となる
        return userA == userB, nil
    }
  • 同一性によって区別される

    では何によって区別されるかそれは同一性です。実装的にはreadonlyのIDなどによってその同一性を区別します。

エンティティか値オブジェクトかの判断基準

値オブジェクトとエンティティはどちらもドメインの概念を表現するオブジェクトとして似通っていますが、エンティティにするかの基準は、ライフサイクルが存在しそこに連続性が存在するかが大きな基準となります。

ただこの判断基準はシステムによって異なります。現在のドメインにおいてドメインモデルがライフサイクルあるものとして表現すべきならエンティティ、そうでないなら値オブジェクトとして実装します。

ドメインオブジェクトを定義するメリット

  • コードのドキュメント性が高まる
  • ドメインにおける変更がコードに伝わりやすい

不自然さを解決する「ドメインサービス」

ドメインサービスとは

システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。

例えば、ユーザの重複確認をする処理をする場合、生成したオブジェクト自身に問い合わせをすることになるので、ユーザ自身に重複するかを確認するのは不自然な振る舞いとなります。

package model

import (
    "errors"
    "fmt"

    "github.com/google/uuid"
)

type User struct {
    ID   string
    Name string
}

func NewUser(name string) (*User, error) {
    u := &User{
        ID: uuid.NewString(),
    }
    err := u.ChangeName(name)
    if err != nil {
        return nil, err
    }
    return u, nil
}

// 自分自身に問い合わせる不自然な振る舞い
func (m *User) Exists(name string) bool {
    //重複を確認するコード
    // ...
    return false
}

このような場合はドメインサービスを利用する

package service

import (
    "github.com/taisa831/go-ddd/domain/model"
    "github.com/taisa831/go-ddd/domain/repository"
    "github.com/taisa831/go-ddd/domain/service"
)

type UserService struct {
    r repository.Repository
}

func NewUserService(r repository.Repository) service.UserService {
    return &UserService{
        r: r,
    }
}

// 不自然さを解決する
func (s *UserService) Exists(name string) (bool, error) {
    _, err := s.r.FindUserByName(name)
    if err != nil {
        return false, err
    }
    return true, nil
}

可能な限りドメインサービスを避ける

ドメインモデルの処理もドメインサービスに書けてしまいますが、無機質な Getter と Setter だけを持った、何も語らないドメインモデルとなるため(ドメインモデル貧血症)、可能な限りドメインサービスの利用は避けるようにします。

物流システムにみるドメインサービスの例

package main

type Baggage struct {
    // 省略
}

type PhysicalDistributionBase struct {
    // 省略
}

// Ship はBaggageを発送し、その結果のBaggageを返すメソッドです。
func (pdb *PhysicalDistributionBase) Ship(baggage Baggage) Baggage {
    // 発送のロジックをここに実装
}

// Receive はBaggageを受け取るメソッドです。
func (pdb *PhysicalDistributionBase) Receive(baggage Baggage) {
    // 受け取りのロジックをここに実装
}

func main() {
    pdb := PhysicalDistributionBase{}
    baggage := Baggage{} 

    shippedBaggage := pdb.Ship(baggage) // Baggageを発送
    pdb.Receive(shippedBaggage)         // Baggageを受け取る
}

ここでShipReceiveを行うTransportというメソッドを作成したい時には、PhysicalDistributionBaseの振る舞いとして記述するのではなくドメインサービスに切り出す。

type TransportService struct{}

func (ts *TransportService) Transport(from, to PhysicalDistributionBase, baggage *Baggage) {
	shippedBaggage := from.Ship(baggage)
	to.Receive(shippedBaggage)
}