😽

Repositoryが肥大化しないためにドメインモデルに状態変更を委ねたほうがいいのではないか

に公開

はじめに

最近、Xで「Repository にupdateStatus のようなメソッドを作るのって、良くないんじゃないか」とという話があったので自分の頭の中を整理する意味合いもありGoで試しました。
あくまで個人的に思ったことを書いたため、この記事の手法が絶対の正解ではないことをご理解いただけると幸いです。

https://x.com/j5ik2o/status/1944580994482511897
https://x.com/t_takuya50/status/1944925282420551859

問題:Repository が肥大化するパターン

よくある書き方

type CompanyRepository interface {
    UpdateStatus(id string, status string) error
    UpdateName(id string, name string) error
    UpdateAddress(id string, address string) error
    // ... こんなにUpdateメソッドがあると、今後の改修でさらに増えていく可能性が高い
}

type companyRepository struct {
    db *sql.DB
}

func (r *companyRepository) UpdateStatus(id string, status string) error {
    // ステータス更新のビジネスロジック
    if status == "inactive" {
        // 非活性化できる条件チェック
        // このロジックがRepositoryに散らばってしまう
        return errors.New("非活性化できる条件チェック")
    }
    
    _, err := r.db.Exec("UPDATE companies SET status = ? WHERE id = ?", status, id)
    return err
}

何が問題なのか

  1. Repository が肥大化する: UpdateStatusUpdateNameUpdateAddress など、フィールドごとに更新メソッドが増え続ける
  2. ビジネスロジックが散らばる: 状態変更の条件チェックが Repository や他の層に散らばる
  3. テストが困難: 外部依存があるのでRepositoryのテストを書くのが難しい。加えて肥大化した Repository のモックを作りづらい(≒テストしにくい)
  4. 保守性が低い: 状態変更ルールの変更時に、複数箇所の修正が必要になる。本来はドメインモデルだけを見て判断したい。

解決策:ドメインモデルに状態変更を委ねる

改善後の実装

package domain

// ドメインモデル
type CompanyAccount struct {
    id       string
    name     string
    status   CompanyStatus
    address  string
    // ... その他のフィールド
}

type CompanyStatus string

const (
    StatusActive   CompanyStatus = "active"
    StatusInactive CompanyStatus = "inactive"
    StatusSuspended CompanyStatus = "suspended"
)

// 状態変更のドメインロジック
// 外部依存していないので、単体テストが実装しやすい
// 新しいCompanyAccount構造体を作成して返す(immutable設計)
func (c *CompanyAccount) ChangeStatus(newStatus CompanyStatus) (*CompanyAccount, error) {
    // ドメインルールによる状態変更チェック
    if c.status == StatusSuspended && newStatus == StatusActive {
        return nil, errors.New("一時停止状態から直接有効化することはできません")
    }
    
    // 新しい構造体を作成して返す
    return &CompanyAccount{
        id:      c.id,
        name:    c.name,
        status:  newStatus,
        address: c.address,
    }, nil
}

type CompanyRepository interface {
    FindById(id string) (*CompanyAccount, error)
    // Repositoryにはドメインモデルを更新するだけのシンプルなメソッドを定義
    Save(account *CompanyAccount) error
}

type companyRepository struct {
    db *sql.DB
}

func (r *companyRepository) FindById(id string) (*CompanyAccount, error) {
    var company CompanyAccount
    err := r.db.QueryRow("SELECT id, name, status, address FROM companies WHERE id = ?", id).
        Scan(&company.id, &company.name, &company.status, &company.address)
    if err != nil {
        return nil, err
    }
    return &company, nil
}

func (r *companyRepository) Save(account *CompanyAccount) error {
    // エンティティの状態をデータベースに保存
    _, err := r.db.Exec("UPDATE companies SET name = ?, status = ?, address = ? WHERE id = ?",
        account.name, account.status, account.address, account.id)
    return err
}

実際の使用例

  • CompanyService という命名にしていますが、個人的にXXXService という命名はあまり好きではありません。
  • 本来であればもっと具体的なビジネス用語を使った命名にするのですが、今回はサンプルコードなのでそこまで気にしていません。
package application

type CompanyService struct {
    repo domain.CompanyRepository
}

func (s *CompanyService) ChangeCompanyStatus(id string, newStatus domain.CompanyStatus) error {
    // 1. ドメインモデルを取得
    company, err := s.repo.FindById(id)
    if err != nil {
        return fmt.Errorf("会社情報の取得に失敗しました: %w", err)
    }
    
    // 2. ドメインモデルで状態変更を実行(新しい構造体を取得)
    updatedCompany, err := company.ChangeStatus(newStatus)
    if err != nil {
        return fmt.Errorf("ステータス変更に失敗しました: %w", err)
    }
    
    // 3. 変更された構造体を永続化
    if err := s.repo.Save(updatedCompany); err != nil {
        return fmt.Errorf("データの保存に失敗しました: %w", err)
    }
    
    return nil
}

なぜこの方法がよいのか

1. 単一責任の原則

  • Repository はデータアクセスのみに専念
  • ドメインモデルはビジネスロジックに専念

2. 変更に強い

状態変更のルールが変わっても、ドメインモデルの一箇所を修正するだけで済みます。
かつ単体テストも実装しやすく、修正した際の動作保証もやりやすくなっています。

3. Immutable設計による予測可能性

新しい構造体を返すimmutableな設計により、以下の利点があります:

  • 元の構造体が変更されないため、予期しない副作用を防げる
  • 並行処理時の安全性が向上する
  • デバッグが容易になる(状態変更の追跡が明確)

4. テストしやすい

func TestCompanyAccount_ChangeStatus(t *testing.T) {
    company := &domain.CompanyAccount{
        // ... 初期化
    }
    
    // ドメインロジックのテストが簡単
    updatedCompany, err := company.ChangeStatus(domain.StatusInactive)
    assert.NoError(t, err)
    assert.Equal(t, domain.StatusInactive, updatedCompany.Status())
    // 元の構造体は変更されていないことを確認
    assert.NotEqual(t, domain.StatusInactive, company.Status())
}

5. 意図が明確

コードを読む人が、ドメインモデルだけをみれば「状態変更にはルールがある」ことを理解しやすくなります。

まとめ

Repository に updateStatus のようなメソッドを見つけたら、以下を検討してみてください。

  1. その処理をドメインモデルに移行できないか
  2. Repository の責務が曖昧になっていないか
  3. ビジネスロジックが散らばっていないか

この改善により、より保守しやすく、テストしやすいコードを書けるようになります。

参考資料

自分はどこからこのあたりの知識や感覚を学んだのかなと考えたのですが、下記の本でした。
個人的にはどれも良書なので、ぜひ読んでみてください。

(実践ドメイン駆動設計やエリック・エヴァンスのドメイン駆動設計も何度か読んだのですが、自分には難しくて…)

Discussion