5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DDD(ドメイン駆動設計)について今更まとめてみた

Last updated at Posted at 2023-04-18

目的

今更ながらドメイン駆動設計について何冊か本を読んだだめその内容を自分用のメモとしてまとめます。

もし認識の間違いがありましたらご指摘ください。

全体の概要図

今のところの理解は以下のような感じで考えています。
それぞれの項目についてはこの後説明していきます。

DDD全体図.drawio (9).png

ドメインレイヤー

DDDの核となる部分でエンティティ等はここのレイヤーに所属します。なおORMのエンティティとDDDのエンティティは基本的には別物のです。

値オブジェクト

値オブジェクトは、その値自体が重要であり、その値が同じであれば同一のものと見なされるオブジェクトです。たとえば、長さ、重さ、金額、日付などが値オブジェクトとして考えられます。また値オブジェクトは、イミュータブル(不変)である必要があるため一度作成されたら、その値は変更されることがないようにする必要があります。値の変更を行いたい場合は再度値オブジェクトをインスタンス化しもとの値オブジェクトを上書きすることで値の変更を行います。

例としてお金の概念を値オブジェクトとすると以下のような形になります。

type Money struct {
    amount int64
}

func NewMoney(amount int64) (*Money, error) {
    if amount < 0 {
        return nil, errors.New("金額は0以上である必要があります。")
    }
    return &Money{amount}, nil
}

func (m *Money) Add(other *Money) *Money {
    return &Money{m.amount + other.amount}
}

func (m *Money) Equals(other *Money) bool {
    return m.amount == other.amount
}

なお値オブジェクトを利用するメリットとしては以下のことが挙げられます。

  1. 不変性を保証することができる
    値オブジェクトは、値そのものを表すためのオブジェクトであるため、一度作成されたら変更することができません。このため、値オブジェクトを使用することで、値の変更による副作用がなくなります。これにより、コードの安全性が向上し、バグの発生を防止することができます。

  2. 値そのものの振る舞いを表現できる
    値オブジェクトを利用することによって値自体に振る舞いを持たせることが可能です。上の例で行けばお金は負にならないという振る舞いを値自体に持たせることができています。また逆に値オブジェクトに定義されていない事によってその行為ができないことを明確にすることができます。お金であれば100円+100円=200円であることは上記のAddメソッドで分かりますが、100円*100円=10000円は掛け算するメソッドが定義されていないため成り立たないことがわかります。

  3. ドメインモデルを表現することができる
    値オブジェクトは、ドメインモデルを表現するために使用されます。ドメインモデルに合わせて、適切な値オブジェクトを設計することで、コードがよりビジネスに適合した形になります。これにより、ドメインモデルとコードの間に認識のギャップがなくなり、開発者がビジネスルールを理解することが容易になります。

エンティティ

エンティティは、ユニークな識別子(ID)を持ち、そのIDを用いて同一性が判断されるオブジェクトです。たとえば、顧客、注文、商品などがエンティティとして考えられます。エンティティは、その状態が時間とともに変化することがあります。つまり、エンティティは値オブジェクトとは対象的にミュータブル(可変)である場合があります。主には値オブジェクトをエンティティの各属性として持ちます。

例として顧客のエンティティを以下に記載します。

//値オブジェクト 顧客エンティティで利用される
type CustomerID struct {
    value string
}

func NewCustomerID() (*CustomerID, error) {
    value := generateID()
    return &CustomerID{value}, nil
}

func (id *CustomerID) Value() string {
    return id.value
}

func generateID() string {
    // IDを生成するロジック
}

type FirstName struct {
    value string
}

func NewFirstName(value string) (*FirstName, error) {
    if value == "" {
        return nil, errors.New("value cannot be empty")
    }
    return &FirstName{value}, nil
}

func (fn *FirstName) Value() string {
    return fn.value
}

type LastName struct {
    value string
}

func NewLastName(value string) (*LastName, error) {
    if value == "" {
        return nil, errors.New("value cannot be empty")
    }
    return &LastName{value}, nil
}

func (ln *LastName) Value() string {
    return ln.value
}
//顧客エンティティ
type Customer struct {
    id        *CustomerID
    firstName *FirstName
    lastName  *LastName
}

func NewCustomer(id *CustomerID, firstName *FirstName, lastName *LastName) (*Customer, error) {
    if id == nil {
        return nil, errors.New("id cannot be nil")
    }
    if firstName == nil {
        return nil, errors.New("firstName cannot be nil")
    }
    if lastName == nil {
        return nil, errors.New("lastName cannot be nil")
    }
    return &Customer{id, firstName, lastName}, nil
}

func (c *Customer) GetID() *CustomerID {
    return c.id
}

func (c *Customer) GetFirstName() *FirstName {
    return c.firstName
}

func (c *Customer) GetLastName() *LastName {
    return c.lastName
}

func (c *Customer) Equals(other *Customer) bool {
    return c.id.Value() == other.id.Value()
}

値オブジェクトにするべきかエンティティとするべきかの判断基準

ライフサイクルが存在し、そこに連続性があるかどうかが一つの判断基準となります。例えば、ユーザーであれば新規作成され、ユーザー名の変更などの更新が行われ、最終的には利用されなくなり削除されるというライフサイクルが存在します。そのようなモデルはエンティティとして扱います。

一方、あるモデルが、複数の属性から構成され、その値が同じであれば同一視できる場合には、そのモデルは値オブジェクトとして扱います。例えば、日付や時間、金額、住所などが値オブジェクトに当たります。

また、モデルが値オブジェクトにもエンティティにもなりうる場合もあります。例えば、タイヤは、車にとっては単なる部品であり値オブジェクトとして扱われますが、タイヤ工場にとっては生産管理や在庫管理などの業務があり、そのような場合にはエンティティとして扱うことがあります。

ドメインサービス

ドメインサービスは、ドメインモデルに含まれるビジネスロジックや振る舞いではなく、エンティティや値オブジェクトでは表現できない一時的な処理や外部サービスとの連携などを実装するためのクラスです。ドメインサービスを利用することで、ドメインモデルの責務が明確化され、可読性の高いコードを実現できます。以下の例はドメインサービスに顧客の重複を確認するExistメソッドを実装しています。

type CustomerService struct {
    customerRepository CustomerRepository
}

func (cs *CustomerService) Exist(customer *Customer) bool {
    existingCustomer := cs.customerRepository.FindByEmail(customer.Email)
    return existingCustomer != nil && existingCustomer.ID != customer.ID
}

悪い例としてExistメソッドが顧客エンティティに実装されてしまうと以下のようなコードとなり自分自身に重複の確認することになり違和感があるコードとなっていまいます。またエンティティがリポジトリに依存することとなることも問題です。

type Customer struct {
    ID        int
    Name      string
    Email     string
    Birthdate time.Time
}

func (c *Customer) Exist(customerRepository CustomerRepository) bool {
    existingCustomer := customerRepository.FindByEmail(c.Email)
    return existingCustomer != nil && existingCustomer.ID != c.ID
}

func main() {
    // create new customer
    customerID, _ := NewCustomerID()
    firstName, _ := NewFirstName("John")
    lastName, _ := NewLastName("Doe")

    customer, err := NewCustomer(customerID, firstName, lastName)
    if err != nil {
        log.Fatalf("failed to create customer: %s", err)
    }

    repo := // CustomerRepositoryの実装を生成する
    
    //リポジトリを引数に自分自身で重複確認を行なっている。
    customer.Exist(repo)
}

ただし、ドメインサービスを過剰に利用することも問題です。ドメインサービスに集中しすぎると、エンティティや値オブジェクトにあるべき機能が実装されなくなり、ドメイン欠乏症に陥ってしまいます。

ドメイン欠乏症を回避するためには、ドメインモデルにビジネスロジックや振る舞いを表現するための機能を十分に持たせることが必要です。ドメインサービスは、ドメインモデルが持つべきでない責務を担うために使用するべきです。適切にドメインサービスを利用することで、ドメインモデルをより良い状態に保ち、可読性の高いコードを実現できます。

仕様

仕様オブジェクトを利用することで、複雑な評価処理がリポジトリやドメインサービスに流出してしまうことを防ぐことができます。例えば顧客が優良ユーザーであるかどうかを判定する以下のような条件があったとします。

  • プレミアムユーザーであること
  • 1ヶ月以内の購入額が50000円以上であること

上記を仕様オブジェクトとして実装した例を以下に記載します。

type ExcellentCustomerSpecification struct {
    purchaseRepo PurchaseRepository
}

func NewExcellentCustomerSpecification(purchaseRepo PurchaseRepository) *ExcellentCustomerSpecification {
    return &ExcellentCustomerSpecification{
        purchaseRepo: purchaseRepo,
    }
}

func (s *ExcellentCustomerSpecification) IsSatisfiedBy(customer *Customer) bool {
    if customer.IsPremium == false {
        return false
    }

    purchases, err := s.purchaseRepo.FindByCustomerID(customer.ID)
    if err != nil {
        // リポジトリからのデータ取得に失敗した場合はfalseを返す
        return false
    }

    totalAmount := 0
    oneMonthAgo := time.Now().AddDate(0, -1, 0)
    for _, purchase := range purchases {
        if purchase.CreatedAt.Before(oneMonthAgo) {
            continue
        }
        totalAmount += purchase.Amount
    }

    return totalAmount >= 50000
}

上記の仕様を利用して優良ユーザーだった場合は特典を送るかを判断するメソッドを実装した例を以下に記載します。

type CustomerApplicationService struct {
    customerRepo CustomerRepository
    purchaseRepo PurchaseRepository
}

func NewCustomerApplicationService(customerRepo CustomerRepository, purchaseRepo PurchaseRepository) *CustomerApplicationService {
    return &CustomerApplicationService{
        customerRepo: customerRepo,
        purchaseRepo: purchaseRepo,
    }
}

func (s *CustomerApplicationService) ShouldSendRewardToCustomer(customerID int) bool {
    customer, err := s.customerRepo.FindByID(customerID)
    if err != nil {
        // エラーが発生した場合はfalseを返す
        return false
    }

    excellentCustomerSpecification := NewExcellentCustomerSpecification(s.purchaseRepo)

    return excellentCustomerSpecification.IsSatisfiedBy(customer)
}

このような形で実装することで今回であれば優良ユーザーを判断する振る舞いがドメイン層の外に流出することを防ぐことができました。また上記のような使い方以外にもリポジトリに対して仕様を渡すような実装パターンも存在します。

仕様の利用によるパフォーマンスの課題

仕様を利用するメリットを記載しましたが仕様を利用することによるデメリットもあります。例えば今回の場合であれば。購入履歴が大量にあった場合全ての購入履歴を呼び出してからプログラム上で判定を行っているためパフォーマンスの問題が生じてしまいます。
この問題を解決するためにはリードモデルや遅延実行を利用するなどの方法が考えられます。

リードモデルの利用

インフラレイヤー

リポジトリ

リポジトリは値オブジェクトやエンティティを保存(永続化)したり読み込んだりするために利用します。保存や取得の対象となるデータストアはデータベースだけに限りません。
なおドメインレイヤーにエンティティに対するリポジトリのインターフェースを用意し、インフラ層にインターフェースの実装を記載する形で実装します。
これによってエンティティ(ドメインレイヤー)がインフラレイヤーに依存しなくなり、エンティティ側でインフラレイヤーのことを考慮する必要がなくなります。

以下にCustomerのリポジトリサンプルを記載します。

//顧客リポジトリのインターフェース
type CustomerRepository interface {
    FindByID(id *CustomerID) (*Customer, error)
    Save(customer *Customer) error
    Delete(id *CustomerID) error
}


//顧客リポジトリをInMemoryで実装したサンプル
type InMemoryCustomerRepository struct {
    customers []*Customer
}

func (repo *InMemoryCustomerRepository) FindByID(id *CustomerID) (*Customer, error) {
    for _, c := range repo.customers {
        if c.id.Value() == id.Value() {
            return c, nil
        }
    }
    return nil, errors.New("customer not found")
}

func (repo *InMemoryCustomerRepository) Save(customer *Customer) error {
    for i, c := range repo.customers {
        if c.id.Value() == customer.id.Value() {
            repo.customers[i] = customer
            return nil
        }
    }
    repo.customers = append(repo.customers, customer)
    return nil
}

func (repo *InMemoryCustomerRepository) Delete(id *CustomerID) error {
    for i, c := range repo.customers {
        if c.id.Value() == id.Value() {
            repo.customers = append(repo.customers[:i], repo.customers[i+1:]...)
            return nil
        }
    }
    return errors.New("customer not found")
}

また上記のようにSQLではなくInMemoryのリポジトリを作成することによって、DBがない状態でもテストが書けるようになります。

ファクトリー

ファクトリー(Factory)では、オブジェクトの生成を専門に行ういます。いろいろな実装を行う中で、オブジェクトの生成方法が複雑で、単純なnew演算子では生成できない場合や、オブジェクトの生成に必要なパラメータが複雑である場合にはファクトリーを利用します。

例えば顧客の作成について顧客のIDの生成がDBではなく外部のAPIからのレスポンスで決定する場合その処理をリポジトリやエンティティなどに記載するのは違和感があります。その際はファクトリーを作成し以下のように実装することができます。

type CustomerFactory struct {
    client CustomerAPIClient
}

func NewCustomerFactory(client CustomerAPIClient) *CustomerFactory {
    return &CustomerFactory{client}
}

func (f *CustomerFactory) Create(name, email string) (*Customer, error) {
    customerDto, err := f.client.Create(name,email)
    if err != nil {
        return nil, err
    }
    customer := &Customer{
        ID:    createdDTO.ID,
        Name:  createdDTO.Name,
        Email: createdDTO.Email,
    }
    return customer, nil
}

上記以外にも以下のような場合にファクトリの利用を検討できます。

  1. オブジェクトの初期化に多くのパラメータが必要な場合
    例えば、数十個以上のパラメータを持つクラスのオブジェクトを生成する場合、パラメータを個別に設定するのは面倒でエラーが発生しやすくなります。そのような場合、ファクトリを使用すると、オブジェクトの生成と初期化を行う単一のメソッドを提供できます。

  2. 複数の異なるタイプのオブジェクトを生成する必要がある場合
    複数の異なるタイプのオブジェクトを生成する必要がある場合、ファクトリを使用して共通のインターフェースを定義し、それぞれのオブジェクトを生成する具象ファクトリを定義することができます。これにより、呼び出し元が実際のオブジェクトの生成方法を知る必要がなくなります。

  3. オブジェクトの生成に複雑な条件が必要な場合
    例えば、複数のオブジェクトを組み合わせて生成する場合、または状態によって異なるタイプのオブジェクトを生成する場合、ファクトリを使用することができます。このような場合、ファクトリは内部で必要な条件を評価し、適切なオブジェクトを生成することができます。

  4. オブジェクトの生成に副作用がある場合
    例えば、データベースに接続してオブジェクトを永続化する必要がある場合、ファクトリを使用することができます。ファクトリは内部で必要な副作用を処理し、生成されたオブジェクトを返すことができます。

また実装の際はリポジトリ同じようにドメインレイヤーなどの外側から実装を行うようにすることでドメインレイヤーが外部のレイヤーに依存しないようにすることができます。

アプリケーションレイヤー

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

アプリケーションサービスではドメイン層やインフラ層を利用してユースケースを実装します。例えば顧客を管理する以下のようなユースケースを実装します。

  • 顧客を操作するユースケースとサンプルコード
    DDD全体図.drawio (2).png
type CustomerService struct {
	repo CustomerRepository
}

func NewCustomerService(repo CustomerRepository) *CustomerService {
	return &CustomerService{repo: repo}
}

//顧客を登録する
func (s *CustomerService) Register(name string, email string) (*Customer, error) {
	customer := NewCustomer(name, email)
	err := s.repo.Save(customer)
	if err != nil {
		return nil, err
	}
	return customer, nil
}

//顧客一覧を取得する
func (s *CustomerService) GetCustomers() ([]*Customer, error) {
	return s.repo.FindAll()
}

//顧客を削除する
func (s *CustomerService) Delete(id CustomerID) error {
	return s.repo.Delete(id)
}

エンティティを直接返却することによる問題

上記のコードでは顧客エンティティを直接返却しています。そのためアプリケーションサービスを利用するクライアント側で予期せぬ問題が発生する可能性があります。
例えば以下のコードではクライアント側で顧客エンティティのChangeEmailメソッドを呼び出し変更を実施しています。表示されるメールは変化しており変更されたように見えますがリポジトリで変更を保存していないためDBには反映されていません。

func main() {
	// 顧客を登録する
	customerService := customer.NewCustomerService()
	c, _ := customerService.RegisterCustomer("John", "Doe", "johndoe@example.com")

	// 顧客のEmailを変更する
	c.ChangeEmail("newemail@example.com")

	// 変更後のEmailを表示する
	fmt.Println(c.Email)
}

この問題を解決するためにアプリケーションサービスからの返却値をDTOに変更する方法が挙げられます。以下にサンプルコードを記載します。

// 顧客のDTO
type CustomerDTO struct {
    ID    string
    Name  string
    Email string
}

// 顧客アプリケーションサービス
type CustomerService struct {
    repo CustomerRepository
}

func NewCustomerService(repo CustomerRepository) *CustomerService {
    return &CustomerService{
        repo: repo,
    }
}

//顧客を登録する
func (s *CustomerService) RegisterCustomer(name, email string) (*CustomerDTO, error) {
    customer, err := NewCustomer(name, email)
    if err != nil {
        return nil, err
    }

    err = s.repo.SaveCustomer(customer)
    if err != nil {
        return nil, err
    }

    return &CustomerDTO{
        ID:    customer.ID.String(),
        Name:  customer.Name,
        Email: customer.Email,
    }, nil
}

// 顧客の一覧を取得する
func (s *CustomerService) ListCustomers() ([]*CustomerDTO, error) {
    customers, err := s.repo.GetAllCustomers()
    if err != nil {
        return nil, err
    }

    dtos := make([]*CustomerDTO, 0, len(customers))
    for _, c := range customers {
        dtos = append(dtos, &CustomerDTO{
            ID:    c.ID.String(),
            Name:  c.Name,
            Email: c.Email,
        })
    }

    return dtos, nil
}

//顧客を削除する。
func (s *CustomerService) DeleteCustomer(id string) error {
    customerID, err := NewCustomerIDFromString(id)
    if err != nil {
        return err
    }

    err = s.repo.DeleteCustomer(customerID)
    if err != nil {
        return err
    }

    return nil
}

アプリケーションサービスへのドメインルール流出

アプリケーションサービスは、ドメインモデルの操作を提供する責務を持つクラスです。しかしアプリケーションサービスは、ユースケースを処理するためにドメインモデルに依存しており、その依存度が高いため、アプリケーションサービスからドメインルールが流出することがあります。

ドメインルールがアプリケーションサービスから流出すると、ドメインモデルを変更するためのコストが増加し、ドメインモデルの柔軟性が低下する可能性があります。また、ビジネスルールの変更によってアプリケーションサービスが変更される場合、ドメインモデルに依存するため、その変更がドメインモデルにも影響を与える可能性があります。

これを防ぐためには、アプリケーションサービス内でドメインルールを直接記述するのではなく、ドメインモデルに依存することでドメインルールを守り、ドメインモデル内でビジネスロジックを実装するようにします。また、ドメインモデルを変更することで、ドメインルールを変更するようにします。

以下は顧客のEmailにGmailを許可しないというルールがアプリケーションサービスに流失してしまったサンプルコードです。

  • 問題があるコード
//アプリケーションサービスへのドメインルール流出
type CustomerService struct {
    repo *CustomerRepository
}

func (s *CustomerService) RegisterCustomer(name string, email string) (*Customer, error) {
    // Emailのバリデーション
    if !strings.Contains(email, "gmail.com") {
        return nil, errors.New("Only gmail.com is allowed as email domain")
    }

    customer := NewCustomer(name, email)
    err := s.repo.Save(customer)
    if err != nil {
        return nil, err
    }
    return customer, nil
}
  • 正しく書き直した例
//上記ルールをドメイン層に書き直した正しい例
type Customer struct {
    id    *CustomerID
    name  string
    email *Email
}

func NewCustomer(name string, email string) (*Customer, error) {
    customer := &Customer{
        id:   NewCustomerID(),
        name: name,
    }
    err := customer.SetEmail(email)
    if err != nil {
        return nil, err
    }
    return customer, nil
}

func (c *Customer) SetEmail(email string) error {
    e, err := NewEmail(email)
    if err != nil {
        return err
    }
    if !e.IsAllowedDomain() {
        return errors.New("Only gmail.com is allowed as email domain")
    }
    c.email = e
    return nil
}

type CustomerService struct {
    repo *CustomerRepository
}

func (s *CustomerService) RegisterCustomer(name string, email string) (*CustomerDTO, error) {
    customer, err := NewCustomer(name, email)
    if err != nil {
        return nil, err
    }
    err = s.repo.Save(customer)
    if err != nil {
        return nil, err
    }
    return NewCustomerDTO(customer), nil
}

アプリケーションサービスのインターフェース化

ここまでの話とは少し違う話になりますが、アプリケーションサービスをインターフェース化することには以下のようなメリットがあります。

  1. テスト性の向上: インターフェースを使うことで、テスト用のモックやスタブを簡単に作成することができます。これにより、ユニットテストや結合テストをより簡単に実行できます。

  2. 柔軟性の向上: インターフェースを使うことで、アプリケーションサービスの実装を切り替えることが容易になります。例えば、データベースや外部APIの切り替えが必要になった場合に、インターフェースを介して実装を差し替えることができます。

ただし、インターフェースの過剰使用は、コードの複雑性を増やしてしまう可能性があるので、どこまで利用するかはプロジェクトごとにバランスをとる必要があります。

コマンドオブジェクト

アプリケーションサービスのメソッドの引数を直接受け取る場合、引数の数やデータ型が変更されるたびにメソッドのシグネチャーが変更されてしまいます。これに対して、各メソッドの引数をコマンドオブジェクトとして別クラスに置き換えることで、メソッドのシグネチャーが変更されることを回避できます。コマンドオブジェクトを利用することで、引数の追加や変更があってもコマンドオブジェクトのメンバー変数を変更するだけで済み、メソッドのシグネチャーを変更する必要がなくなります。また、コマンドオブジェクトに必要なバリデーション処理をまとめることで、コードの重複を避けることもできます。

type UpdateCustomerCommand struct {
    ID    CustomerID
    Name  *string
    Email *string
}

func (s *CustomerApplicationService) UpdateCustomer(cmd UpdateCustomerCommand) (*CustomerDTO, error) {
    c, err := s.repo.FindByID(cmd.ID)
    if err != nil {
        return nil, err
    }
    if cmd.Name != nil {
        c.ChangeName(*cmd.Name)
    }
    if cmd.Email != nil {
        if err := c.ChangeEmail(*cmd.Email); err != nil {
            return nil, err
        }
    }
    updated, err := s.repo.Update(c)
    if err != nil {
        return nil, err
    }
    return toDTO(updated), nil
}

まとめ

今回はGoで書いて見ていますが、DDDの各種概念は言語に関係なく利用できると感じました。筆者は普段VBでプログラミングすることがほとんどですが、そこでも利用できそうな気がしています。
勉強する中で追加のことがあれば記事にしてみようと思います。

主な参考文献

5
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?