はじめに
「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」(以下、本書)を読みながら、学んだ内容をアウトプットしていきます。
本書はC#で書かれていますが、この記事ではGo言語で実装していきます。ドメイン駆動設計の実践にオブジェクト指向言語が必須というわけではないため、あえてGoで挑戦することで、C#(クラスベース)とGo(構造体+インターフェース)の設計思想の違いも一緒に整理できればと思います。
架空の 「宿泊予約システム」 を題材にして、15章分の学びを1つのアプリケーションとして積み上げていく形で書いていきます。旅行が好きなので、自分がイメージしやすいドメインを選びました。
※本記事は本書の要約ではなく、自分の理解のアウトプットです。本の内容と完全には一致しない部分、独自の解釈を加えている部分がある点はご了承ください。
1章 ドメイン駆動設計とは
ドメイン駆動設計(DDD)とは、ビジネスの問題を解決するために、ビジネスへの理解を深め、その理解をコードに表現していく考え方です。
ビジネスとコードを結びつけることで、継続的かつ反復的にソフトウェアを改良できる枠組みを作り、ソフトウェアをより役に立つものにしていく、というのが目的だと理解しました。
ポイントは以下の3つです。
- ソフトウェアの利用者を取り巻く世界(業務・ルール)と、実装を結びつけることが目的
- ドメインとは「領域」のことで、ここではプログラムを適用する対象となる業務領域を指す
- ドメインに何が含まれるか(=何をモデリングすべきか)を見極めることが重要
DDDというと難しい設計パターン(値オブジェクト、エンティティ、リポジトリなど)の話に目が行きがちですが、本質は「業務をどう理解し、どうコードに落とし込むか」という考え方そのものだと理解しています。
2章 値オブジェクト
値オブジェクトとは、システム固有の値を表現するために定義されたオブジェクトです。
特徴として、以下の点が印象に残りました。
- 代入ではなく交換するという考え方。値を書き換えるのではなく、新しいインスタンスを生成して置き換えるイメージ
- 値オブジェクトにまとめておくことで、属性の追加や仕様変更による影響範囲を局所化できる(プリミティブ型のまま扱っていると、属性が増えるたびに関連する処理を全部書き換える必要が出てくる)
- 値オブジェクトは独自のふるまい(メソッド) を持てる。例えば「◯日後を計算する」、「2つの時刻を比べる」など
最初、「値オブジェクトって結局ただのデータの入れ物では?」と思っていたのですが、「ふるまいを持てる」という点を理解して、単なるデータ構造ではなくロジックを内包した部品であると理解しました。
GoにはJavaのようなクラスがない。structでどう表現する?
本書はC#(クラスベースの言語)を前提にしており、値オブジェクトは「クラスを作る」という説明がされています。ただGoにはクラスがないので、最初どういう構造にしていくかというところで悩みました。
1. structで表現し、フィールドは非公開にする
type StayPeriod struct {
checkInDate time.Time
checkOutDate time.Time
}
フィールド名を小文字にすることで、パッケージ外から直接書き換えられなくなります。Javaでいうprivateフィールド + getter のようなイメージで、Goではフィールド名を小文字にすることで、パッケージ外からの直接変更を防げます。ただし、参照型を持つ場合は完全な不変性を保証できるわけではないため注意が必要です。
2. コンストラクタ関数(New〜)でバリデーションする
Goには本物のコンストラクタがないため、NewXxxという名前の関数を自分で用意するのが慣習です。ここでバリデーションを行い、不正な値オブジェクトが生まれないようにします。
func NewStayPeriod(checkInDate, checkOutDate time.Time) (StayPeriod, error) {
if !checkOutDate.After(checkInDate) {
return StayPeriod{}, errors.New("チェックアウト日はチェックイン日より後でなければなりません。")
}
return StayPeriod{checkInDate: checkInDate, checkOutDate: checkOutDate}, nil
}
C#やJavaだと不正な値の場合に例外(Exception)を投げますが、Goには例外がないためerrorを返す設計になります。
3. 「交換」は値レシーバのメソッドで表現する
本書でいう「代入ではなく交換」は、Goではレシーバのフィールドを直接書き換えず、新しいインスタンスを返すメソッドとして表現します。
// NG: 既存のstructを直接変更してしまう(値オブジェクトらしくない)
func (s *StayPeriod) ExtendCheckOut(days int) {
s.checkOutDate = s.checkOutDate.AddDate(0, 0, days)
}
// OK: 新しいStayPeriodを返す(交換のイメージ)
func (s StayPeriod) ExtendCheckOut(days int) StayPeriod {
return StayPeriod{
checkInDate: s.checkInDate,
checkOutDate: s.checkOutDate.AddDate(0, 0, days),
}
}
レシーバをポインタにするか値にするかは設計意図を表現するうえで重要です。値オブジェクトでは、状態を変更せず新しいインスタンスを返すため、値レシーバを採用することが多いと感じました。
4. 等価性の比較は==で自然に書ける
Goのstructは、フィールドがすべて比較可能な型であれば==で構造的な等価性比較ができます。Javaのようにequals()をオーバーライドする必要がないのは地味に楽な点でした。
p1, _ := NewStayPeriod(checkIn, checkOut)
p2, _ := NewStayPeriod(checkIn, checkOut)
fmt.Println(p1 == p2) // true(値が同じなら同じとみなされる)
宿泊予約ドメインで値オブジェクトを作ってみる
ここからは、宿泊予約システムを題材に、実際に値オブジェクトを作っていきます。
ディレクトリ構成
ddd-hotel-booking
├── go.mod
├── go.sum
├── README.md
├── main.go
└── internal
└── domain
├── stay_period.go
├── stay_period_test.go
├── checkin_policy.go
└── checkin_policy_test.go
StayPeriod(宿泊期間)
「チェックイン日〜チェックアウト日」を表す値オブジェクトです。持たせたふるまいは以下の2つです。
-
Nights():宿泊日数(泊数)を計算する -
Overlaps():他の宿泊期間と重複しているかを判定する
package domain
import (
"errors"
"time"
)
// StayPeriod は宿泊期間(チェックイン日〜チェックアウト日)を表す値オブジェクト
type StayPeriod struct {
checkInDate time.Time
checkOutDate time.Time
}
// NewStayPeriod はStayPeriodを生成するコンストラクタ関数。
func NewStayPeriod(checkInDate, checkOutDate time.Time) (StayPeriod, error) {
// チェックアウト日がチェックイン日より前(同日含む)の場合は不正な値として
// エラーを返し、不正なStayPeriodが生成されないようにする。
if !checkOutDate.After(checkInDate) {
return StayPeriod{}, errors.New("checkOutDate must be after checkInDate")
}
return StayPeriod{
checkInDate: checkInDate,
checkOutDate: checkOutDate,
}, nil
}
func (s StayPeriod) CheckInDate() time.Time { return s.checkInDate }
func (s StayPeriod) CheckOutDate() time.Time { return s.checkOutDate }
// Nights は宿泊日数(泊数)を返す
func (s StayPeriod) Nights() int {
return int(s.checkOutDate.Sub(s.checkInDate).Hours() / 24)
}
// Overlaps は他の宿泊期間と重複しているかを判定する
func (s StayPeriod) Overlaps(other StayPeriod) bool {
return s.checkInDate.Before(other.checkOutDate) && other.checkInDate.Before(s.checkOutDate)
}
CheckInPolicy(チェックイン・チェックアウトの時刻ルール)
宿泊期間(StayPeriod)とは別に、「何時からチェックインできるか」というルールを持つ値オブジェクトも用意しました。ホテルによってチェックイン可能時刻は異なるため、「いつからいつまで泊まるか」 と 「何時から入れるか」 は別の関心事として分けています。
package domain
import "time"
// CheckInPolicy はホテルごとのチェックイン・チェックアウト時刻のルールを表す値オブジェクト
type CheckInPolicy struct {
checkInTime time.Time // 例: 15:00
checkOutTime time.Time // 例: 11:00
}
// NewCheckInPolicy はCheckInPolicyを生成するコンストラクタ関数。
func NewCheckInPolicy(checkInTime, checkOutTime time.Time) CheckInPolicy {
return CheckInPolicy{
checkInTime: checkInTime,
checkOutTime: checkOutTime,
}
}
// IsLateCheckIn は実際のチェックイン時刻が通常より遅いかを判定する
func (p CheckInPolicy) IsLateCheckIn(actualCheckInTime time.Time) bool {
standard := time.Date(
actualCheckInTime.Year(), actualCheckInTime.Month(), actualCheckInTime.Day(),
p.checkInTime.Hour(), p.checkInTime.Minute(), 0, 0,
actualCheckInTime.Location(),
)
return actualCheckInTime.After(standard)
}
テストコード
値オブジェクトの不変性・ふるまいはテストで示すと分かりやすいので、テストコードも書いてみました。
package domain
import (
"testing"
"time"
)
// テスト用に年月日だけを指定してtime.Timeを生成
func date(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// チェックイン日とチェックアウト日から正しく宿泊日数(泊数)が計算できるかを検証する
func TestStayPeriod_Nights(t *testing.T) {
period, err := NewStayPeriod(date(2024, 8, 1), date(2024, 8, 4))
if err != nil {
t.Fatal(err)
}
if got := period.Nights(); got != 3 {
t.Errorf("want 3 nights, got %d", got)
}
}
// 2つの宿泊期間が重複しているかの判定が正しく行われるかを検証する
func TestStayPeriod_Overlaps(t *testing.T) {
a, _ := NewStayPeriod(date(2024, 8, 1), date(2024, 8, 5))
b, _ := NewStayPeriod(date(2024, 8, 3), date(2024, 8, 6)) // aと期間が重なるケース
c, _ := NewStayPeriod(date(2024, 8, 5), date(2024, 8, 7)) // aのチェックアウト日と隣接するだけのケース
if !a.Overlaps(b) {
t.Error("expected overlap between a and b")
}
if a.Overlaps(c) {
t.Error("expected no overlap between a and c")
}
}
// 不正な値でStayPeriodを生成しようとした場合、不正な値オブジェクトが作られないことを検証する
func TestNewStayPeriod_InvalidRange(t *testing.T) {
_, err := NewStayPeriod(date(2024, 8, 5), date(2024, 8, 1)) // チェックアウト日<チェックイン日
if err == nil {
t.Error("expected error when checkOutDate is before checkInDate")
}
}
package domain
import (
"testing"
"time"
)
// テスト用に時刻だけを指定してtime.Timeを生成する
func checkInPolicyTime(h, m int) time.Time {
// 時刻のみを扱うため、年月日はダミーの値(0年1月1日)を使う
return time.Date(0, 1, 1, h, m, 0, 0, time.UTC)
}
// 実際のチェックイン時刻が通常のチェックイン時刻より遅い場合にtrueを返すかを検証する
func TestCheckInPolicy_IsLateCheckIn(t *testing.T) {
policy := NewCheckInPolicy(
checkInPolicyTime(15, 0), // 通常のチェックインは15:00から
checkInPolicyTime(11, 0), // 通常のチェックアウトは11:00まで
)
actual := time.Date(2024, 8, 1, 16, 30, 0, 0, time.UTC) // 16:30にチェックイン
if !policy.IsLateCheckIn(actual) {
t.Error("expected late check-in to be true")
}
}
// 実際のチェックイン時刻が通常のチェックイン時刻と同じか早い場合にfalseを返すかを検証する
func TestCheckInPolicy_NotLateCheckIn(t *testing.T) {
policy := NewCheckInPolicy(
checkInPolicyTime(15, 0),
checkInPolicyTime(11, 0),
)
actual := time.Date(2024, 8, 1, 14, 0, 0, 0, time.UTC) // 14:00にチェックイン(通常より早い)
if policy.IsLateCheckIn(actual) {
t.Error("expected late check-in to be false")
}
}
実装していて気づいたこと・つまずいたこと
Nights()の計算方法について
Hours() / 24で日数を計算していますが、この方法はタイムゾーンをまたぐ場合や夏時間(DST)がある地域では不正確になり得ます。単純に見えて意外と落とし穴があるポイントでした。実務では日付をUTCに正規化してから計算する、といった対策が必要になりそうです。
Overlaps()の境界値の扱い
テストのc(8/5チェックアウト = 8/5チェックイン)を「重複なし」としているのは意図的な設計判断です。現実のホテル運用でも「チェックアウトの日にちょうど別のゲストがチェックインする」のは普通にあり得るため、境界を含まない(Before)判定にしました。ここは単なる技術的な実装というより、ドメイン(業務ルール)をどう解釈するかが問われる部分だと感じました。
CheckInPolicyが時刻のみ使う理由
time.Time型を使っていますが、年月日の部分は無視して時刻だけを見ています。Goには「時刻のみ」を表す専用の型(JavaでいうLocalTime相当)がないため、こういった書き方になります。ここもGoならではの癖として気づいた点でした。
まとめ
- ドメイン駆動設計とは、ビジネスの理解をコードに反映させ続けるための考え方
- 値オブジェクトは「交換可能」「ふるまいを持てる」「変更の影響範囲を局所化できる」という特徴を持つ
- Goでは、値オブジェクトはstruct + 非公開フィールド + Newコンストラクタ + 値レシーバで表現する
次回は3章(エンティティ)に進み、StayPeriodを使う側のReservation(予約)エンティティを作っていきます。値オブジェクトとの違い(同一性による区別)を、実際にコードで確認していく予定です。
ソースコード
参考文献
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本