ドメイン駆動設計をはじめよう
第一部 設計の基本方針
ドメイン駆動設計
- 戦略的設計
- What(何)
- Why(なぜ)
- 戦術的設計
- How(どうやって)
1章:事業活動を分析する
ドメイン駆動の考え方とやり方
以下に分割される
- 業務領域
- 中核
- 補完
- 一般
事業領域(ビジネスドメイン)
- アマゾン
- ネット通販
- クラウド
など必ずしも1つは限らない
業務領域(サブドメイン)
事業活動を細分化したもの。
3つのカテゴリーに分類できる
- 中核の業務領域(コアサブドメイン)
- 一般的な業務領域(ジェネリックサブドメイン)
- 補完的な業務領域(サポーティングサブドメイン)
業務領域 | 競争優位 | 複雑さ | 変化 | 実装 | 課題の特徴 |
---|---|---|---|---|---|
中核の業務領域(コアサブドメイン) | 〇 | 複雑 | 多い | 内部 | 複雑で重要 |
一般的な業務領域(ジェネリックサブドメイン) | × | 複雑 | 少ない | 外部 | 複雑だが解決策が存在 |
補完的な業務領域(サポーティングサブドメイン) | × | 単純 | 少ない | 外部・内部 | 簡単 |
2章:業務知識を発見する
プロジェクトを成功させるには、「業務エキスパート」と「開発者」間での知識の共有(課題の理解)が必要。
↓
効率的な意図の伝達手段が必要
従来の開発では、その過程で多くの変換が行われる。
「業務知識」→「概念モデル」→「解決モデル」→「ソースコード」
この過程で情報が欠落する恐れがある。
この「業務エキスパート」から「開発者」へ情報を伝えるもっといい方法が必要。
↓
「同じ言葉」
「同じ言葉」
従来の方法では「変換」を繰り返していたが、この変換をせずに「同じ言葉」を使う。
一貫性
「同じ言葉」は一貫していることが重要。
- 「曖昧な用語」
- Policy
- regulatory rule
- insurance contract
- Policy
- 「同義語」
- 利用者
- 一般会員
- プレミア会員
- 利用者
「曖昧な用語」と「同義語の違い」
- 「曖昧な用語」は業務エキスパートも同じ言葉を使っている。
- 「同義語の違い」は業務エキスパートは使い分けてるけど、システムでは同じ言葉を使っている
と考えた。(個人的な解釈)
道具を使う
「同じ言葉」は継続的に検証して進化させていく必要がある。
- 「用語集」
- チームでの共有に役立つ
- 全員で更新していくことが重要
- 名詞の共有には役立つが、振る舞いは表現できない
- 「Gherkinテスト」
- システムの期待される振る舞いを具体的な事例(シナリオ)として記述することができる。
他にも色々ツールはある。
- システムの期待される振る舞いを具体的な事例(シナリオ)として記述することができる。
結局適切なコミュニケーションが大事
日本語の「同じ言葉」をどうやって扱うか
- クラス名は英語、コメントで日本語
3章:事業活動の複雑さに立ち向かう
「区切られた文脈」
ある程度の規模になると「同じ言葉」が別の意味で使われることがある。
そこで、「同じ言葉」を複数の小さな同じ言葉に分割する。
そして、それが適用できる範囲「区切られた文脈」に適切に割り当てる。
↓
「同じ言葉」に含まれる用語の意味、業務方針・業務ルールの一貫性を維持できるのは、「区切られた文脈」の内側だけ
↓
「同じ言葉」を再定義
「同じ言葉」は組織全体で通用する言葉ではない。
「同じ言葉」は、「区切られた文脈」ごとの固有のモデルを表現することに焦点を合わせる。
「同じ言葉」を定義するには、その言葉の定要範囲である「区切られた文脈」が必要。
「区切られた文脈」=ソフトウェア開発の対象範囲
「業務領域」と「区切られた文脈」二つの分割は冗長では?
「業務領域」は決めるものではなく「分析・判断するもの」→「発見」
「区切られた文脈」は実際に開発者が決めるもの→「設計」
一つの「区切られた文脈」が複数の論理的な境界「業務領域」を含む場合がある
↓
名前空間・モジュール・パッケージ
第4章 区切られた文脈どうしの連係
「区切られた文脈」は互いに独立しているとは限らない。
「区切られた文脈」を共有する手法が必要。
良きパートナー
関連する「区切られた文脈」の変更をそれぞれのチーム間で共有する。
双方向の協力体制
モデルの共有
複数の「区切られた文脈」で同じモデルを利用する場合もある。
関連するすべての「区切られた文脈」に合わせて実装する。
できる限り範囲を小さくする。
共有部分のコードを変更すると直ちにすべての「区切られた文脈」で反映される必要がある。
モデルの共有を行うかの判断
その「区切られた文脈」の変更をそれぞれの「区切られた文脈」が個別に対応するコストと、それぞれのチームが共有されたコードを調整しながら開発するコストの大きさを判断。
基本的には、コードの変更が頻繁に起こる「中核の業務領域」が対象になる。
従属
使われる側「上流」と使う側「下流」が存在する場合、「下流」が完全に「上流」に合わせにいく関係を「従属」という。
モデル変換装置
これも、基本的には「上流」が強い決定権を持つ。
ただ、「下流」は完全に「上流」のモデルを受け入れるのではなく、「上流」のモデルを自分たちのモデルに合うように変換する仕組み「モデル変換装置」を使用する。
どう言う場合に適用するべきか?
- 「下流」に「中核の業務領域」が含まれる。
- 「上流」が使いにくい。
- 「上流」が頻繁に変更される。
共有サービス
共有サービスは、「下流」が決定権を持つ。
「上流」は「下流」に対して公開するインターフェースを切り離す。→「公開された言葉」
つまり、プロトコルを提供する。
「上流」は「下流」のことを気にせずに変更できる。
「下流」は「上流」のさまざまなバージョンを利用できる。
コードの変更が頻繁な「中核の業務領域」に適している。
互いに独立
つまり、連係しない。
同じ機能を共有するよりも、それぞれの「区切られた文脈」で実装した方が楽。→ログとか
「一般的な業務」でよく使われる。
「中核の業務」をつなぐ場合は、「互いに独立」は適さない。
「文脈の地図」で可視化できる。
第二部 実装方法の選択
実際の実装方法について
5章 単純な業務ロジックを実装する
- トランザクションスクリプト
- アクティブレコード
トランザクションスクリプト
データを操作する手続きの一部として業務ロジックを記述。
実装方法
単純にデータ処理の内容を記述。
ただ、トランザクション管理が必ず必要。
トランザクション管理の欠落
悪い例
func (r *Impl) Execute() {
// トランザクションを使用していないため、途中でエラーが発生しても処理が中断されるだけで、
// それまでに実行された処理(Save)はロールバックされずにデータベースに残ってしまう
if err := db.Save().Error; err != nil {
return err // エラー時にロールバックされない
}
// 2つ目の処理(Create)がエラーになった場合でも、最初の Save() は既に実行されており、
// 途中でデータの不整合が発生する可能性がある
if err := db.Create().Error; err != nil {
return err // ここでエラーになっても Save() の結果は取り消されない
}
return nil // どちらも成功した場合、問題はないが、一貫性が保証されない
}
良い例
func (r *Impl) Execute() {
err := r.db.Transaction(func(tx *gorm.DB) error {
// トランザクションの開始
// このブロック内の処理はすべて成功すればコミットされ、エラーが発生すればロールバックされる
if err := tx.Save().Error; err != nil {
return err // エラーが発生した場合、自動的にロールバックされる
}
if err := tx.Create().Error; err != nil {
return err // ここでエラーが発生した場合も、すべての変更がロールバックされる
}
return nil // すべて成功した場合、コミットされる
})
if err != nil {
// トランザクション内でエラーが発生した場合、ロールバックされ、エラーメッセージを出力
fmt.Println("Transaction failed:", err)
}
}
暗黙的な分散トランザクション
悪い例
- この例だと、RESTの途中でエラーが発生したら、呼び出しもとはDBの更新が成功したのか失敗したのかを特定できない。
func (r *Impl) Execute(userID uint) error {
// ユーザーの訪問回数を更新
if err := r.db.Model(&User{}).Where("id = ?", userID).
Update("visit_count", gorm.Expr("visit_count + 1")).Error; err != nil {
return err // どの段階でエラーが発生したのかが不明
}
return nil
}
良い例1(確実に意図した回数に更新する)
呼び出し元の流れ
- 現在の訪問回数を取得
- 訪問回数を1回増やす
- 増やした訪問回数をメゾットの引数に渡す
func (r *Impl) Execute(userID uint, newVisitCount uint) error {
// ユーザーの訪問回数を指定した値に更新
if err := r.db.Model(&User{}).Where("id = ?", userID).
Update("visit_count", newVisitCount).Error; err != nil {
return fmt.Errorf("failed to update visit count: %w", err)
}
return nil
}
良い例2(楽観的な排他処理)
呼び出し元の流れ
- 現在の訪問回数を取得
- 訪問回数をメゾットの引数に渡す
トランザクション側の処理 - 現在の訪問回数を取得
- 一致した場合のみ訪問回数を更新
func (r *Impl) Execute(userID uint, expectedVisit uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var currentVisit uint
// 行ロックをかけて現在の訪問回数を取得
if err := tx.Raw("SELECT visit_count FROM users WHERE id = ? FOR UPDATE", userID).
Scan(¤tVisit).Error; err != nil {
return fmt.Errorf("failed to fetch visit count: %w", err)
}
// 取得した訪問回数が期待値と異なる場合は更新しない
if currentVisit != expectedVisit {
return fmt.Errorf("visit count mismatch: expected %d, got %d", expectedVisit, currentVisit)
}
// 訪問回数を更新
if err := tx.Model(&User{}).Where("id = ?", userID).
Update("visit_count", gorm.Expr("visit_count + 1")).Error; err != nil {
return fmt.Errorf("failed to update visit count: %w", err)
}
return nil
})
}
トランザクションスクリプトは、補完的な業務に向いている
中核の業務領域には使ってはいけない。
さまざまな場面で重複する記述をすることになる。
アクティブレコード
メモリ上のオブジェクトと、ベータベースの構造をマッピング
トランザクションスクリプトでもある。
func (r *Impl) Execute(userID uint, newVisitCount uint) error {
// ユーザーの訪問回数を更新(アクティブレコードスタイル)
// ❶ メモリ上のオブジェクトとしてUserを取得
user := &User{}
if err := r.db.First(user, userID).Error; err != nil {
return fmt.Errorf("failed to find user: %w", err) // ユーザーが見つからなかった場合のエラーハンドリング
}
// ❷ メモリ上のオブジェクトのフィールドを更新
user.VisitCount = newVisitCount
// ❸ `Save` を実行し、変更内容をデータベースに反映
if err := r.db.Save(user).Error; err != nil {
return fmt.Errorf("failed to update visit count: %w", err) // データ更新時のエラーハンドリング
}
return nil // 正常終了
}
業務モデルが単純な場合に向く
6章 複雑な業務ロジックに立ち向かう
ドメインモデル
ロジックとデータの両方を一体化させた、オブジェクトモデル
部品
- 集約
- 値オブジェクト
- 業務イベント
- 業務サービス
Plain Old Object:ライブラリやフレームワークに依存させない
値オブジェクト
値オブジェクトは、システム内で単に「値」として扱われるオブジェクト。
これらは固有の識別子(ID)を持たず、内部の属性値だけで同一性が判断される。
主な特徴:
- イミュータブル(不変性):
値オブジェクトは一度作成されると、その状態が変更されません。変更が必要な場合は、新しいオブジェクトとして再生成します。 - 値による等価性:
同じ値の組み合わせを持つオブジェクトは、たとえ別のインスタンスであっても等しいとみなされます。 - 副作用のない設計:
状態が変更されないため、予期せぬ副作用がなく、安全かつ予測可能な振る舞いをします。
type Color struct {
r uint8
g uint8
b uint8
}
// 同一性判定
func (c Color) Equal(other Color) bool {
return c.r == other.r && c.g == other.g && c.b == other.b
}
// 値オブジェクトの生成
func NewColor(r, g, b int) (*Color, error) {
if r < 0 || r > 255 {
return nil, fmt.Errorf("invalid red value: %d", r)
}
if g < 0 || g > 255 {
return nil, fmt.Errorf("invalid green value: %d", g)
}
if b < 0 || b > 255 {
return nil, fmt.Errorf("invalid blue value: %d", b)
}
return &Color{r: uint8(r), g: uint8(g), b: uint8(b)}, nil
}
エンティティ
エンティティは、ドメイン駆動設計(DDD)において「同一性」を持つオブジェクトとして定義され、たとえ属性(状態)が変化しても、その一意な識別子(ID)によって同一性が維持される。
値オブジェクトがその値自体で同一性を判断するのに対し、エンティティは固有のIDを基準に同一性が判断する。
主な特徴
-
一意な識別子:
エンティティは通常、IDフィールド(例: id)を持ち、これによって各エンティティを一意に識別。たとえば、ユーザー、注文、商品などはIDにより区別される。 -
状態の変化:
エンティティはライフサイクルを持ち、生成後も状態(属性値)が変更されることがあり、変更されたとしてもIDが同じであれば同一エンティティとみなされる。
// Shoes はエンティティとして定義され、ID によって同一性が判定される
type Shoes struct {
id ShoesID
color Color
}
// NewShoes はエンティティ Shoes の生成を行うファクトリ関数
func NewShoes(id ShoesID, color Color) *Shoes {
return &Shoes{
id: id,
color: color,
}
}
// Equal は、エンティティとしての同一性を ShoesID で判断
func (s *Shoes) Equal(other *Shoes) bool {
if other == nil {
return false
}
return s.id == other.id
}
// ChangeColor は、Shoes エンティティの状態を変更
// 値オブジェクトである Color を新たに設定することで色を変更
func (s *Shoes) ChangeColor(newColor Color) {
s.color = newColor
}
集約
集約はエンティティである。
- 一意に認識できるフィールドを持つ
- 状態が変化する
しかし、集約は単なるエンティティではなく、データの一貫性の保証を目的とする。
一貫性の保証
以下に、DDDにおける集約の一貫性保証を実現するための実装例を示します。
状態の変更は、集約自身が提供する公開メソッドを通じてのみ行われ、集約外部から直接状態を変更することは避けられます。
パターン1:直接メソッド呼び出しによる状態変更
このパターンでは、Ticket集約がメッセージの追加などの直接操作を公開メソッドとして提供する。
外部からはこの AddMessage
メソッドを呼び出すことでのみ Ticket の状態を変更し、集約内の不変条件(ビジネスルール)を確実に守る。
// UserId はユーザー識別子を表す型とする。
type UserId string
// Message はチケット内のメッセージを表す構造体とする。
type Message struct {
From UserId
Body string
}
// Ticket はチケット集約(Aggregate)を表し、内部でメッセージの管理を行う。
type Ticket struct {
messages []Message
}
// AddMessage は Ticket の内部状態を変更し、新しい Message を追加する。
// 集約外部からの状態変更は、この公開メソッドを経由してのみ行う。
func (t *Ticket) AddMessage(from UserId, body string) {
message := Message{From: from, Body: body}
t.messages = append(t.messages, message)
}
func demoPattern1() {
ticket := Ticket{}
ticket.AddMessage("user123", "This is a message body.")
fmt.Println("パターン1:", ticket.messages)
}
パターン2:コマンドオブジェクトを利用した状態変更
このパターンでは、コマンドオブジェクト(AddMessageCmd
)を用いて操作内容を明示的に表現する。
外部から受け取ったコマンドを、集約内部の Execute
メソッドで実行することで、操作の意図をより明確にし、複雑なビジネスルールや検証ロジックにも柔軟に対応する。
// Ticket2 は別のチケット集約を表し、内部でメッセージの管理を行う。
type Ticket2 struct {
messages []Message
}
// AddMessageCmd は、Ticket2 に対するメッセージ追加操作を表現するコマンドとする。
type AddMessageCmd struct {
From UserId
Body string
}
// Execute は AddMessageCmd コマンドを実行し、Ticket2 の状態を変更する。
// この方法では、操作の意図(「メッセージ追加」)を明示する。
func (t *Ticket2) Execute(cmd AddMessageCmd) {
message := Message{
From: cmd.From,
Body: cmd.Body,
}
t.messages = append(t.messages, message)
}
func demoPattern2() {
ticket := Ticket2{}
cmd := AddMessageCmd{From: "user456", Body: "This is another message body."}
ticket.Execute(cmd)
fmt.Println("パターン2:", ticket.messages)
}
共通のポイント
-
一貫性の保証:
集約の状態変更は、Ticket が提供する公開メソッド(AddMessage
またはExecute
)を通じてのみ実施するため、集約外部から直接内部状態にアクセスできない。
これにより、ビジネスルールや不変条件の一貫性を担保する。 -
業務ロジックの集約内集中:
集約に関連する業務ロジックをすべて Ticket 内に実装することで、ドメインモデル全体の理解が容易になり、メンテナンス性を向上させる。
どちらのパターンも有効だが、シンプルなケースではパターン1を採用し、操作の意図を明示したい場合や複雑なビジネスルールが絡むケースではパターン2がより適切だ。
アプリケーション層の簡素化
集約自身に業務ロジックを持たせることで、アプリケーション層の役割をシンプルにすることが可能になる。
実際、アプリケーション層の役割は以下の操作をするだけに留める。
- 集約オブジェクトを作成する
- 生成された集約に対して必要な操作をする
- 変更された集約を永続化する
- 操作の結果を返す
// Escalate は、指定されたチケットをエスカレーションするアプリケーション層の関数とする。
// この関数は、リポジトリからチケットを読み込み、EscalateCmd コマンドを作成して
// 集約内の業務ロジックを実行し、その後チケットを保存する。
func Escalate(repo TicketRepository, id TicketId, reason EscalationReason) ExecutionResult {
// チケットを読み込む
ticket, err := repo.Load(id)
if err != nil {
return ExecutionResult{Success: false, Error: err}
}
// コマンドオブジェクトを作成する
cmd := EscalateCmd{Reason: reason}
// チケット集約内でコマンドを実行する
ticket.Execute(cmd)
// チケットを保存する
if err := repo.Save(ticket); err != nil {
return ExecutionResult{Success: false, Error: err}
}
return ExecutionResult{Success: true}
}
トランザクションの境界
DDDでは、トランザクションの境界を明確に定義することが重要だ。
アプリケーション層でトランザクションを開始し、集約操作(ビジネスロジックの実行)を一括管理した後、コミットまたはロールバックする。
-
集約操作の一括管理:
集約内部の複数操作を1つのトランザクション内で実行し、整合性を維持する。 -
アプリケーション層での制御:
トランザクションの開始や終了(コミット・ロールバック)はアプリケーション層が担当し、ドメイン層はビジネスロジックに専念する。 -
エラー時のロールバック:
問題発生時は、トランザクション全体をロールバックしてデータの一貫性を確保する。
また、各集約は独立した整合性境界であるため、複数の集約にまたがるトランザクションは行わない。
このように、トランザクションの境界を適切に設計することで、システム全体の信頼性と一貫性が確保される。
集約のルート
DDDでは、集約はビジネスルールに基づくオブジェクト群を一貫性のある単位としてまとめる。
その中でも「集約のルート」は、外部から集約内部への唯一のアクセス経路として機能する。
以下のコードサンプルは、Ticket構造体を集約のルートとして実装した例であり、
すべてのビジネスロジックや状態変更がこのエントリーポイント(Executeメソッド)を通じて行われることを示す。
-
責任の集中:
Ticketは集約全体の整合性や業務ロジックを管理する中心的な役割を担う。 -
一貫性の境界:
Ticket内部の状態(例えばメッセージリスト)は、集約ルートを通じて管理され、
単一のトランザクション内で整合性が保証される。 -
外部とのインターフェース:
外部システムや他の集約は、Ticket内部に直接アクセスせず、必ず集約ルートを介して操作する。
// Ticket は集約のルートとして機能する
type Ticket struct {
messages []Message
IsEscalated bool
RemainingTimePercentage float64
AssignedAgent UserId
}
// Execute は自動アクションの評価を行い、外部からの唯一のアクセス経路となる
func (t *Ticket) Execute(cmd EvaluateAutomaticActions) {
if t.IsEscalated && t.RemainingTimePercentage < 0.5 &&
t.GetUnreadMessagesCount(t.AssignedAgent) > 0 {
t.AssignedAgent = t.AssignNewAgent()
}
}
// GetUnreadMessagesCount は、指定されたユーザーIDに対する未読メッセージ数を返す
func (t *Ticket) GetUnreadMessagesCount(id UserId) int {
count := 0
for _, msg := range t.messages {
if msg.To == id && !msg.WasRead {
count++
}
}
return count
}
このサンプルコードは、Ticketが集約のルートとして外部からの操作を一元的に管理する。
業務イベント
業務イベントは、実際に起きたできごとを表現するため、必ず過去形で記述される。
この記法により、イベントが既に発生した事実を示す。
また、業務イベントは集約の公開インターフェースの一部として機能する。
集約は内部状態の変化を外部に通知するため、業務イベントをpublishし、
外部サービスや他のシステムはそのイベントにsubscribeして連携を行う。
例えば、注文が確定した場合、「注文確定済み(OrderConfirmed)」という業務イベントを発行し、
これを受けた外部サービスが発送手続きなどの後続処理を開始する、といった仕組みである。
業務サービス
業務サービスは、ステートレスなオブジェクトとして実装され、
特定のエンティティに所属しない業務ロジックの置き場として利用される。
これにより、サービス自体が状態を持たず、外部からの入力に基づいて処理を実行する。
以下は、Goで実装した業務サービスのサンプルコードです。
package main
import "fmt"
// Ticket はチケットを表すエンティティ
type Ticket struct {
ID string
Priority int
}
// Agent はエージェントを表すエンティティ
type Agent struct {
ID string
}
// TicketService は業務サービスとして、チケットの割当などの業務ロジックを提供する。
// このサービスはステートレスであり、内部に状態を持たない。
type TicketService struct{}
// AssignTicketToAgent は、チケットを指定したエージェントに割り当てる業務ロジックを実装する。
func (s TicketService) AssignTicketToAgent(ticket Ticket, agent Agent) {
// ここに、チケット割当に関する業務ロジックを実装する
fmt.Printf("チケット %s をエージェント %s に割り当てた。\n", ticket.ID, agent.ID)
}
func main() {
// サンプルエンティティの作成
ticket := Ticket{ID: "T123", Priority: 1}
agent := Agent{ID: "A456"}
// 業務サービスのインスタンスはステートレスなため、シンプルに生成する
service := TicketService{}
// 業務サービスを利用して、チケットをエージェントに割り当てる
service.AssignTicketToAgent(ticket, agent)
}
このサンプルコードは、業務サービスが状態を持たず、外部から渡されたエンティティに対して必要な処理(ここではチケットの割当)を実行する例を示している。