2025/06/30

Go言語でのドメインオブジェクト実装を考える

はじめに

生成AIを前提とした業務アプリケーションの構築のために最近Goを学び始めました。

Goは一般的なオブジェクト指向言語とは異なる特性を持つため、ドメインオブジェクトの実装もPHPと同じようにはいかないので、エンティティと値オブジェクトの実装について考えてみます。

ドメインオブジェクトとは、ドメイン駆動設計の用語で、開発者がドメイン(業務領域)の深い知識をソフトウェアに反映させ、変更に強く、保守しやすいシステムを構築するための強力なアプローチが「ドメイン駆動設計(DDD: Domain-Driven Design)」です。

生成AIの登場によって、コードが運用、保守されるのを待たなくてもあっという間に規模が爆発してしまうようになるため、より一層保守性の高いコードの重要性が増していると思います。

エンティティ

エンティティは、その**同一性(Identity)**によって区別されるオブジェクトです。これは、たとえそのエンティティが持つ属性(プロパティ)が時間とともに変化したとしても、そのエンティティが持つ固有の識別子(ID)によって、他のエンティティと明確に区別されることを意味します。エンティティは、そのライフサイクルを通じて状態が変化する可能性があり、ビジネス上の重要な振る舞いをカプセル化します。

特徴としてはいくつかあります。

  • 同一性による識別: エンティティの最も重要な特徴は、そのIDによって一意に識別される点です。例えば、顧客管理システムにおいて、同じ名前や住所を持つ2人の顧客がいたとしても、それぞれの顧客が異なる顧客IDを持っていれば、それらは別々の顧客として扱われます。属性値が同じであっても、IDが異なれば別のエンティティとみなされます。

  • 可変性: エンティティは、そのライフサイクルの中で状態が変化する可能性があります。例えば、顧客の住所が変更されたり、注文の状態が「処理中」から「完了」に変わったりするような場合です。エンティティは、これらの状態変化を適切に管理し、関連するビジネスルールを適用する責任を持ちます。後述する値オブジェクトが不変性を持つので対照的によく可変であることが性質して挙げられます。

  • ライフサイクル: エンティティは、生成されてから消滅するまでの期間を持ちます。この期間中、エンティティは様々なイベントに関与し、その状態を変化させます。

  • ビジネスロジックの保持: エンティティは、ドメイン固有のビジネスルールや振る舞いをカプセル化します。これにより、ドメインの知識がエンティティ内に凝集され、コードの可読性と保守性が向上します。安易なセッター・ゲッターの羅列ではなく、ドメインの振る舞いを表す意味のあるメソッドを持つべきです。

複数のエンティティをまとめる集約ルートと言う考え方もあるんですが一旦ここでは省略します。

値オブジェクト

  • 値による等価性: 値オブジェクトの等価性は、その属性値がすべて同じであるかどうかによって判断されます。例えば、「住所」という値オブジェクトが「東京都新宿区」という値を持っていれば、別の場所で作成された「東京都新宿区」という住所も、属性値が同じであれば同じものとみなされます。これは、プリミティブ型(文字列、数値など)の比較と同じ考え方です。

  • 不変性 (Immutability): 値オブジェクトは一度作成されると、その状態は変更されません。この特性により、予期せぬ副作用を引き起こすリスクが低減されます。変更が必要な場合は、既存のインスタンスをコピーして新しい値を設定した新しいインスタンスを生成します。

  • 副作用がない: 不変であるため、値オブジェクトをメソッドの引数として渡したり、コレクションに格納したりしても、その値が意図せず変更される心配がありません。これにより、コードの予測可能性が高まります。

  • 交換可能: 同じ値を持つオブジェクトは、互いに交換可能です。これは、値オブジェクトがその値によってのみ識別されるためです。

例: 住所、金額、期間、色、電話番号、メールアドレスなど、それ自体が固有の識別子を持たず、その値が意味を持つ概念が値オブジェクトに該当します。

エンティティと値オブジェクトの使い分け

エンティティと値オブジェクトの最も重要な違いは「同一性」の有無です。ドメインモデルを設計する際には、そのオブジェクトが「何であるか(同一性)」が重要なのか、「どのような値であるか(属性)」が重要なのかを考慮して使い分けます。例えば、「顧客」はIDによって識別されるためエンティティですが、顧客の「住所」はIDではなくその値(番地、都市名など)によって識別されるため値オブジェクトです。

値オブジェクトを適切に利用することで、ドメインモデルの表現力を高め、コードの堅牢性と可読性を向上させることができます。また、不変性により、並行処理における問題のリスクを低減し、テストの容易性も向上します。DDDでは、可能な限り値オブジェクトを使用し、エンティティは本当に同一性が必要な場合にのみ使用することが推奨されます。

Go言語におけるエンティティの実装

Go言語は、JavaやC#のような純粋なオブジェクト指向言語とは異なり、クラスや継承といった概念を持ちません。しかし、構造体(struct)とメソッド、そしてインターフェースを組み合わせることで、DDDのエンティティを効果的に表現し、その特性をコードに落とし込むことができます。

すごく簡単に言うとデータと振る舞いを分けてクラスとは違う単位で扱います。

Go言語におけるエンティティの実装では、以下の点を考慮します。

  1. 構造体による表現: エンティティの属性は、Goのstructとして定義します。これは、関連するデータをひとまとめにする基本的な方法です。

  2. IDによる同一性: エンティティの最も重要な特性である同一性を保証するために、一意の識別子(ID)をフィールドとして持ちます。このIDは、エンティティのライフサイクルを通じて不変であるべきです。Goでは、stringやカスタム型(例: type UserID string)としてIDを表現することが一般的です。

  3. ビジネスロジックのメソッド: エンティティが持つべきビジネスロジックや振る舞いは、そのエンティティの構造体に紐づくメソッドとして実装します。これにより、ドメインの知識がエンティティ内にカプセル化され、関連するデータと振る舞いが一体となります。メソッドは、ドメインのユースケースを反映した意味のある名前を持つべきです。

  4. 可変性への対応: エンティティは可変であるため、その状態を変更するメソッドを持つことができます。ただし、安易なセッター(例: SetUserName(name string))は避け、ドメインの振る舞いを表す意味のあるメソッド(例: ChangeName(newName string))を通じて状態を変更するようにします。これにより、不正な状態遷移を防ぎ、ドメインの整合性を保ちます。状態を変更するメソッドは、ポインタレシーバを使用します。

  5. コンストラクタ関数: エンティティの生成時には、その初期状態が有効であることを保証するために、コンストラクタ関数(Goでは慣習的にNewプレフィックスを持つ関数)を提供することが推奨されます。この関数内で、初期値のバリデーションを行うことで、常に有効な状態のエンティティが生成されるようにします。

package domain

import (
	"errors"
	"fmt"
	"regexp"
)

// UserID はユーザーの識別子を表す型
type UserID string

// User はエンティティとしてのユーザーを表す構造体
type User struct {
	id   UserID
	name UserName // UserName は値オブジェクト
}

// NewUser は新しいUserエンティティを生成するコンストラクタ関数
// IDと名前の初期バリデーションを行う
func NewUser(id string, name string) (*User, error) {
	if id == "" {
		return nil, errors.New("user ID cannot be empty")
	}

	userName, err := NewUserName(name)
	if err != nil {
		return nil, err
	}

	return &User{
		id:   UserID(id),
		name: userName,
	}, nil
}

// ID はUserのIDを返すゲッターメソッド
func (u *User) ID() UserID {
	return u.id
}

// Name はUserの名前(値オブジェクト)を返すゲッターメソッド
func (u *User) Name() UserName {
	return u.name
}

// ChangeName はUserの名前を変更するビジネスロジックメソッド
// 新しい名前が有効であることをバリデーションし、エンティティの状態を更新する
func (u *User) ChangeName(newName string) error {
	userName, err := NewUserName(newName)
	if err != nil {
		return err
	}
	u.name = userName
	return nil
}

// UserName はユーザー名を表現する値オブジェクト
type UserName struct {
	value string
}

var userNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)

func NewUserName(name string) (UserName, error) {
	if !userNameRegexp.MatchString(name) {
		return UserName{}, fmt.Errorf("invalid user name format: %s", name)
	}
	return UserName{value: name}, nil
}

func (un UserName) Value() string {
	return un.value
}

func (un UserName) Equals(other UserName) bool {
	return un.value == other.value
}

Go言語における値オブジェクトの実装

Go言語では、値オブジェクトもエンティティと同様に構造体(struct)として表現されます。しかし、値オブジェクトの最も重要な特性である「不変性(Immutability)」と「値による等価性」をGoの言語特性に合わせて表現する必要があります。

Go言語における値オブジェクトの実装では、以下の点を考慮します。

  1. 構造体による表現: 値オブジェクトの属性は、Goのstructとして定義します。関連する複数の属性をまとめて、一つの意味のある概念として扱います。

  2. 不変性: 値オブジェクトは一度作成されると、その状態は変更されません。Goでは、これを強制するために以下の方法が考えられます。

    1. フィールドの非エクスポート: 構造体のフィールドを小文字で始めることで、パッケージ外からの直接アクセスを防ぎます。これにより、外部からフィールドを直接変更されることを防ぎます。

    2. セッターメソッドの不提供: 状態を変更するようなセッターメソッドは提供しません。もし値の変更が必要な場合は、新しい値オブジェクトのインスタンスを生成するようなメソッド(例: WithNewValue())を提供し、既存のオブジェクトは変更しないようにします。

    3. コンストラクタ関数のみ提供: 値オブジェクトのインスタンスは、コンストラクタ関数(例: NewUserName, NewAddress)を通じてのみ生成されるようにします。この関数内で、すべてのフィールドが初期化され、バリデーションが行われます。

  3. 値による等価性: 値オブジェクトの等価性は、その属性値がすべて同じであるかどうかによって判断されます。Goでは、これを実現するためにEqualsのようなメソッドを実装することが一般的です。このメソッドは、比較対象のすべてのフィールドが一致する場合にtrueを返します。

  4. バリデーション: 値オブジェクトが常に有効な状態であることを保証するために、コンストラクタ関数内で厳密なバリデーションを行います。これにより、不正な値を持つオブジェクトが生成されることを防ぎ、ドメインの整合性を保ちます。

  5. 関連する振る舞い: 値オブジェクトに関連する振る舞い(例: 金額の加算、日付の計算など)は、その値オブジェクトのメソッドとして実装します。これらのメソッドは、不変性を保つために、新しい値オブジェクトを返すようにします。