【Go】戦術的DDD ~実装パターンの雰囲気だけ~
DDDって何だ
すごくざっくり言うと、「ドメイン知識の理解とモデリング及び、それらのソフトウェアでの表現手法に焦点を当てた設計アプローチ」のこと。
戦略的DDDと戦術的DDD
戦略は全体的な方針や目標を設定するものであり、戦術はそれらの目標を具体的な行動に変えるための手法を指す。
戦略は戦術の上位概念である。
DDDの文脈において、ドメインに対するモデリングを戦略、ソフトウェアでの表現手法を戦術としている。
言い換えると、以下のような2つに大別される。
- 戦略的DDD: ドメインについて、専門家(ドメインエキスパート)と開発者で共通の認識を持った言葉(ユビキタス言語)としてモデリング
- yappliさんの取り組みが非常に参考になる。
- 戦術的DDD: ドメインをそのままソフトウェアに表現するための、実装パターンやレイヤーの設計パターン。
- 実装パターン: エンティティ、値オブジェクト、リポジトリなど
- レイヤーの設計パターン: ヘキサゴナルアーキテクチャ、クリーンアーキテクチャなど
引用元: The Clean Architecture
本記事では、戦術的DDDのなかでも実装パターンについて解説する。
なお、サンプル実装はGoで書いているが、Goの基礎的な知識やDDDとの相性については言及しない。
あくまで、実装パターンについて淡々と簡潔に解説していくことを目指す。
実装パターン
以下の3つのパターンが挙げられる。
- 知識を表現するパターン
- アプリケーションを実現するためのパターン
- 知識を表現するより発展的なパターン
順に見てみよう。
知識を表現するパターン
以下3つのパターンを紹介する。
- 値オブジェクト
- エンティティ
- ドメインサービス
値オブジェクト
値を独自のクラスや構造体で定義したもの。
stringやintとかではなく、独自のクラスや構造体で定義するため、振る舞いを持たせることができる。
値オブジェクトの性質として3つ挙げられる。
①不変である
②交換可能である
③等価性によって比較される
ざっくり言うと、振る舞いを持つ不変オブジェクトといったところか。
package main
import "fmt"
// Money は値オブジェクト
type Money struct {
amount float64
currency string
}
func NewMoney(amount float64, currency string) *Money {
return &Money{amount, currency}
}
// ChangeCurrency のようなセッターに該当するメソッドは定義しない(①)
// (= 知らないところで勝手に書き換えられている...😭みたいなことを防ぐ)
// func (m *Money) ChangeCurrency(currency string) {
// m.currency = currency
// }
// ③金額の等価性を比較。全ての属性を比較して、全て等しいければtrue()
func (m *Money) Equals(other *Money) bool {
return m.amount == other.amount && m.currency == other.currency
}
func main() {
// 値オブジェクトの作成
money1 := NewMoney(100.0, "USD")
money2 := NewMoney(100.0, "USD")
// ③値オブジェクトは、全ての属性を比較して全て等しければ同一であると判断
fmt.Println(money1.Equals(money2)) // true
// ①一度生成された値オブジェクトは属性を書き換えることができない(以下はできない)
// money1.ChangeCurrency("JPY")
// ②値の交換はしても良い
money1 = NewMoney(100.0, "JPY")
fmt.Println(money1.Equals(money2)) // false
}
エンティティ
エンティティの性質として3つ挙げられる。
①可変である
②同じ属性を持っていても区別される
③同一性により区別される
package main
import (
"errors"
"fmt"
)
type User struct {
id int
name string
}
func NewUser(id int, name string) (*User, error) {
if len(name) < 3 {
return nil, errors.New("Name must be at least 3 characters long")
}
return &User{
id: id,
name: name,
}, nil
}
func (u *User) ChangeName(newName string) error {
if len(newName) < 3 {
return errors.New("Name must be at least 3 characters long")
}
u.name = newName
return nil
}
func (u *User) Equals(other *User) bool {
return u.id == other.id
}
func main() {
// 1人目の田中太郎作成
newUser, err := NewUser(1, "田中太郎")
if err != nil {
fmt.Println("Error:", err)
return
}
// 別の田中太郎を作成
anotherUser, err := NewUser(2, "田中太郎")
if err != nil {
fmt.Println("Error:", err)
return
}
if newUser.Equals(anotherUser) {
fmt.Println("同一人物")
} else {
fmt.Println("別人")
}
err = newUser.ChangeName("田中改名")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("ユーザーの名前が変更されました:", newUser.name)
}
ドメインサービス
値オブジェクトやエンティティに記述すると不自然なふるまいは、ドメインサービスに記述する。
不自然なふるまいとは何か。
不自然なふるまいを持つと、こんなやつが出来上がる。
ユーザの重複チェックのコードを例示する。
package entity
import (
"errors"
"fmt"
"github.com/google/uuid"
)
type User struct {
ID string
Name string
}
func NewUser(name string) (*User, error) {
u := &User{
ID: uuid.NewString(),
}
err := u.ValidateName(name)
if err != nil {
return nil, err
}
return u, nil
}
// 自分自身に問い合わせる不自然な振る舞い
func (u *User) Exists(id string) bool {
//重複を確認するコード
// ...
return false
}
func (u *User) ValidateName(name string) error {
// バリデーションチェック
}
// 省略...
user := entity.NewUser(*userId, *userName)
// 生成したuserオブジェクト自身に重複を確認している...?なんか不自然!
if user.Exists(user.ID) {
// 重複
} else {
// 重複なし
}
この不自然さを解決するのがドメインサービス。
package service
import (
"github.com/wakabaseisei/hoge/domain/repository"
)
type UserService struct {
r repository.Repository
}
func NewUserService(r repository.Repository) UserService {
return &UserService{
r: r,
}
}
func (s *UserService) UserExists(userID string) bool {
//重複を確認するコード
// ...
return false
}
package service
import (
ds "github.com/wakabaseisei/hoge/domain/service"
)
// 省略...
user := entity.NewUser(userId, userName)
if ds.UserExists(user.ID) {
// 省略...
注意点として、「不自然なふるまい」に限定すること。
やろうと思えばドメインサービスに全てのふるまいは記述できてしまう。
ところが、それはデータとふるまいを断絶を生んでしまい、ロジックの点在により、ソフトウェアの変更容易性を妨げてしまう。
可能な限り、ドメインサービスを利用しないことが推奨される。
アプリケーションを実現するためのパターン
以下2つのパターンを紹介する。
- リポジトリ
- アプリケーションサービス
リポジトリ
データを永続化し再構築するといった処理を抽象的に扱うためのオブジェクト。
DBや永続ストレージにアクセスし、ドメインオブジェクト(エンティティや値オブジェクト)の永続性を確保する。
ドメインモデルからデータアクセスの詳細を分離するために使用される。これにより、ドメインモデルはDBの詳細に依存せず、柔軟性と保守性が向上する。
実装そのものではなく、シグネチャだけを公開したインターフェースに依存させることがポイント。
type OrderRepository interface {
Save(ctx context.Context, order *model.Order) error
FindByID(ctx context.Context, id string) (*model.Order, error)
FindAll(ctx context.Context) ([]*model.Order, error)
}
package repository
import (
"context"
"errors"
"sync"
"time"
"github.com/wakabaseisei/order-api/domain/model"
)
type InMemoryOrderRepository struct {
mu sync.RWMutex
orders map[string]*model.Order
}
func NewInMemoryOrderRepository() *InMemoryOrderRepository {
orders := make(map[string]*model.Order)
// 初期サンプルデータ(動作確認用のため消してもよい)
orders["1"] = &model.Order{
ID: "1",
CustomerID: "1",
Items: model.OrderItems{
&model.OrderItem{
ProductID: "1",
Quantity: 1,
Price: 100,
},
},
TotalAmount: 1000,
CreatedAt: time.Now(),
}
return &InMemoryOrderRepository{
orders: orders,
}
}
func (r *InMemoryOrderRepository) Save(ctx context.Context, order *model.Order) error {
r.mu.Lock()
defer r.mu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
default:
if _, ok := r.orders[order.ID]; ok {
return errors.New("order already exists")
}
r.orders[order.ID] = order
return nil
}
}
func (r *InMemoryOrderRepository) FindByID(ctx context.Context, id string) (*model.Order, error) {
r.mu.RLock()
defer r.mu.RUnlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
order, ok := r.orders[id]
if !ok {
return nil, errors.New("order not found")
}
return order, nil
}
}
func (r *InMemoryOrderRepository) FindAll(ctx context.Context) ([]*model.Order, error) {
r.mu.RLock()
defer r.mu.RUnlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
orders := make([]*model.Order, 0, len(r.orders))
for _, order := range r.orders {
orders = append(orders, order)
}
return orders, nil
}
}
リポジトリはinterfaceにより抽象化されているため、InMemoryOrderRepositoryを他の永続化オブジェクトに置き換えることが可能。
以下は、InMemoryOrderRepositoryからgorm(GoのORM)に置き換えた例。
package repository
import (
"context"
"errors"
"time"
"github.com/wakabaseisei/order-api/domain/model"
"gorm.io/gorm"
)
type GormOrderRepository struct {
db *gorm.DB
}
func NewGormOrderRepository(db *gorm.DB) *GormOrderRepository {
return &GormOrderRepository{
db: db,
}
}
func (r *GormOrderRepository) Save(ctx context.Context, order *model.Order) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
var existingOrder model.Order
result := r.db.First(&existingOrder, "id = ?", order.ID)
if result.Error == gorm.ErrRecordNotFound {
err := r.db.Create(order).Error
if err != nil {
return err
}
return nil
}
return errors.New("order already exists")
}
}
func (r *GormOrderRepository) FindByID(ctx context.Context, id string) (*model.Order, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
var order model.Order
result := r.db.First(&order, "id = ?", id)
if result.Error == gorm.ErrRecordNotFound {
return nil, errors.New("order not found")
}
return &order, nil
}
}
func (r *GormOrderRepository) FindAll(ctx context.Context) ([]*model.Order, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
var orders []*model.Order
err := r.db.Find(&orders).Error
if err != nil {
return nil, err
}
return orders, nil
}
}
いずれにしてもinterfaceに依存している呼び出し箇所は実装を変える必要がない。
if err := s.OrderRepository.Save(ctx, order); err != nil {
return err
}
アプリケーションサービス
ユースケースを実現するオブジェクト。
アプリケーションサービスは、これまで登場したエンティティ・値オブジェクト・ドメインサービス・リポジトリを組み合わせて実装。
// 省略...
type TaskService struct {
taskRepo TaskRepository
userRepo UserRepository
}
func NewTaskService(taskRepo TaskRepository, userRepo UserRepository) *TaskService {
return &TaskService{
taskRepo: taskRepo,
userRepo: userRepo,
}
}
func (ts *TaskService) CreateTask(userID UserID, title, description string, dueDate time.Time) (*Task, error) {
user, err := ts.userRepo.FindByID(userID)
if err != nil {
return nil, err
}
task := &Task{
Title: title,
Description: description,
DueDate: dueDate,
IsCompleted: false,
UserName: user.Name,
}
if err := ts.taskRepo.Save(task); err != nil {
return nil, err
}
return task, nil
}
知識を表現するより発展的なパターン
以下も軽く触れておく。
- 集約
集約
データを変更する単位となるオブジェクト。
関連するエンティティと値オブジェクトのグループを表し、トランザクションの境界を定義。通常、集約ルートを持っており、全ての変更はここを介してのみ行われる。
以下の例では、集約ルートのOrderが全ての属性を統括し、整合性を担保している。
集約ルートの定義
// 省略...
type Order struct {
ID int
Customer string
OrderItems []OrderItem
}
// 省略...
func (o *Order) AddOrderItem(productID, quantity int) error {
// バリデーションやビジネスルールの適用
// ...
// OrderItemを追加
o.OrderItems = append(o.OrderItems, OrderItem{ProductID: productID, Quantity: quantity})
return nil
}
集約ルートが所有する他のエンティティを定義
// 省略...
type OrderItem struct {
ProductID int
Quantity int
}
まとめ
本記事では、戦術的DDDの中でも実装パターンに焦点を当ててみた。
実践や理解のしやすい実装パターンからDDDに入門するのは、悪くない選択だろう。
おしまい。
参考文献
Discussion