🧱

Goでドメインサービスを実装(「ドメイン駆動設計入門」Chapter4)

2022/03/06に公開約10,200字

概要

戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter4 のドメインサービスの実装をおこないます。

実装したソースコードは以下です。

https://github.com/Msksgm/go-itddd-04-domainservice

参考にしたサンプルコードは以下です。

https://github.com/nrslib/itddd/tree/master/SampleCodes/Chapter4/_11

ドメインサービス

まず、ドメインサービスについて説明します。
「ドメイン駆動設計入門」ではドメインサービスを以下のように紹介していました。

値オブジェクトやエンティティなどのドメインオブジェクトにはふるまいが記述されます。たとえば、ユーザ名に文字数や利用できる文字種に制限があるのであれば、その知識はユーザ名の値オブジェクトに記述されてしかるべきでしょう。
しかし、システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。

出典:ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 Chapter4

不自然になってしまうふるまいがポイントです。
具体的には、本書では「登録済みのユーザーをユーザーエンティティに問い合わせる」が紹介され、他には「ユーザーが認証済みかユーザーエンティティに問い合わせる」などが例としてよく挙げられます。
それらのふるまいをドメインオブジェクトから切り出した処理がドメインサービスになります。

「ドメイン駆動設計入門」では、ドメインサービスの注意点として以下を挙げていました。

エンティティや値オブジェクトに記述すると不自然なふるまいはドメインサービスに記述します。ここで重要なのは、「不自然なふるまい」に限定することです。実をいうとすべてのふるまいはドメインサービスに記述できてしまいます。
(中略)
先の例からわかるとおり、すべてのふるまいはドメインサービスに移設できます。やろうと思えばいくらでもドメインモデル貧血症を引き起こせてしまいます。
もちろんふるまいの中にはドメインサービスとして抽出しないと違和感のあるものは存在します。ふるまいをエンティティや値オブジェクトに定義するべきか、それともドメインサービスに定義するべきか、迷いが生じたらまずはエンティティや値オブジェクトに定義してください。可能な限りドメインサービスは利用しないでください。
ドメインサービスの濫用はデータとふるまいを断絶させ、ロジック点在を促す行為です。ロジックの点在はソフトウェアの変化を阻害し、深刻に停滞させます。ソフトウェアの変更容易性を担保するためにも、コードを一元的に管理することを早々に諦めることは絶対にしてはいけません。

出典:ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 Chapter4

濫用するとロジックが点在し、ドメインモデル貧血症になります。
結果、ドメインモデルが DB のエンティティをマッピングしただけになり、全ての処理がドメインサービスに記述され、ドメイン駆動設計ではなくトランザクションスクリプトになります。
そのためには、ドメインサービスは不自然なふるまいを解決するために使うので、状態を持たないことも重要だと考えました。

実装

本記事では、ユーザーの重複確認をおこなうドメインサービスUserServiceを実装しました。
処理のために、値オブジェクトUserIdUserNameとエンティティUserを実装しました。

domain_model
ドメインモデル図

ディレクトリ構成

ディレクトリ構成は以下のようにしました。
domain/model配下にドメインごとのオブジェクトを配置します。
本筋から逸れるため、本記事ではテスト、DB(postgresql)、docker、go-migrate の説明を省略します。

ディレクトリ構成
.
├── Makefile
├── db-migration.sh
├── docker
│   ├── go
│   │   └── Dockerfile
│   └── postgres
│       └── Dockerfile
├── docker-compose.yml
├── domain
│   └── model
│       └── user
│           ├── user.go
│           ├── user_test.go
│           ├── userid.go
│           ├── userid_test.go
│           ├── username.go
│           ├── username_test.go
│           ├── userservice.go
│           └── userservice_test.go
├── go.mod
├── go.sum
├── main.go
├── main_test.go
└── migrations
    ├── 000001_user.down.sql
    └── 000001_user.up.sql

7 directories, 19 files

user.go、userId.go、username.go

ドメインサービスUserServiceではエンティティUserを使用します。そのため、エンティティUserを最初に解説します。
ユーザー(user.go)は 2 つの値オブジェクト、ユーザー ID(userId.go)とユーザー名(userName.go)で構成します。

以下が実装になります。
ユーザー(User)はユーザー名(UserName)を引数にとります。
ユーザー ID(UserId)は、User生成時に uuid を自動生成されます。

詳細な解説は省きますが、ユーザー(User)はエンティティなので、userNameは可変にしてもいいです。userIdは識別子なので、不変にしないといけません。
ユーザー ID(UserId)とユーザー名(UserName)は不変です。変更したいときには、再代入する必要があります。
興味のある方は、私の過去の記事(エンティティ値オブジェクト)を参考にしていただきたいです。

./domain/model/user/user.go
package user

import (
	"reflect"

	"github.com/google/uuid"
)

type User struct {
	userId   UserId
	userName UserName
}

func NewUser(userName UserName) (*User, error) {
	userId, err := NewUserId(uuid.New().String())
	if err != nil {
		return nil, err
	}
	return &User{userId: *userId, userName: userName}, nil
}

func (user *User) UserName() string {
	return user.userName.Name()
}

func (user *User) UserId() string {
	return user.userId.Id()
}

func (user *User) Equals(other *User) bool {
	return reflect.DeepEqual(user.userId, other.userId)
}

./domain/model/user/userid.go
package user

import "reflect"

type UserId struct {
	id string
}

func NewUserId(uuid string) (*UserId, error) {
	userId := new(UserId)
	userId.id = uuid
	return userId, nil
}

func (userId *UserId) Id() string {
	return userId.id
}

func (userName *UserId) Equals(other *UserId) bool {
	return reflect.DeepEqual(userName.id, other.id)
}

./domain/model/user/username.go
package user

import (
	"fmt"
	"reflect"
)

type UserName struct {
	name string
}

func NewUserName(name string) (*UserName, error) {
	if name == "" {
		return nil, fmt.Errorf("name is required.")
	}
	if len(name) < 3 {
		return nil, fmt.Errorf("name must not be less than 3 characters.")
	}
	return &UserName{name: name}, nil
}

func (userName *UserName) Name() string {
	return userName.name
}

func (userName *UserName) Equals(other *UserName) bool {
	return reflect.DeepEqual(userName.name, other.name)
}

UserUserIdUserNameを属性としてもっていますが、他に同じエンティティが存在するかは知りません。
例えば、user.goに以下のような実装をしたとします。同じUserが存在するかを見つけることが可能になりました。
しかし、Userがもつふるまいとしては不自然です。Userが直接 DB を参照して、Userを検索するのはおかしいです。
それを解決するためにドメインサービスUserServiceが存在します。

./domain/model/user/user.go
func (user *User) Exists(db *sql.DB) (isExists bool, err error) {
    tx, err := userService.db.Begin()
	if err != nil {
		return
	}

	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	rows, err := tx.Query("SELECT * FROM users WHERE username = $1", user.UserName())
	if err != nil {
		return false, fmt.Errorf("userservice.Exists(): %v", err)
	}
	defer rows.Close()

	if rows.Next() {
		return true, nil
	}
	return false, nil
}

userservice.go

以下は、ユーザーの重複を確認するドメインサービスであるUserServiceです。
Exists()が、ユーザーが既に存在してるのか確認をします。
Userにあったら不自然なふるまいをドメインサービスとして切り離すことで自然になりました。
このような場合にドメインサービスを使うことが推奨されます。
本来ドメインサービスから直接永続化の処理を書くことはありませんが、今回はサンプルコードのため許容しています。

./domain/model/user/userservice.go
package user

import (
	"database/sql"
	"fmt"

	_ "github.com/lib/pq"
)

type UserService struct {
	db *sql.DB
}

func NewUserService(db *sql.DB) (*UserService, error) {
	return &UserService{db: db}, nil
}

func (userService *UserService) Exists(user *User) (isExists bool, err error) {
	tx, err := userService.db.Begin()
	if err != nil {
		return
	}

	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	rows, err := tx.Query("SELECT * FROM users WHERE username = $1", user.UserName())
	if err != nil {
		return false, fmt.Errorf("userservice.Exists(): %v", err)
	}
	defer rows.Close()

	if rows.Next() {
		return true, nil
	}
	return false, nil
}

動作確認

今までの実装から動作確認をおこないます。
docker-compose を使用します。

main.go

main.goは以下です。
CreateUser()で、ユーザー(userName=test-user)の作成と永続化をおこないます。
途中のuserService.Exists()で重複確認をおこない、falseのとき永続化、trueのときエラーをリターンして終了します。

./main.go
package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"

	"github.com/Msksgm/itddd-go-04-domainservice/domain/model/user"
	_ "github.com/lib/pq"
)

func main() {
	uri := fmt.Sprintf("postgres://%s/%s?sslmode=disable&user=%s&password=%s&port=%s&timezone=Asia/Tokyo",
		os.Getenv("DB_HOST"), os.Getenv("DB_NAME"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_PORT"))
	db, err := sql.Open("postgres", uri)
	if err != nil {
		log.Fatal(err)
	}
	if err := db.Ping(); err != nil {
		log.Fatal(err)
	}
	log.Println("successfully connected to database")

	err = CreateUser(db, "test-user")
	if err != nil {
		log.Println(err)
	}
}

func CreateUser(db *sql.DB, name string) (err error) {
	userName, err := user.NewUserName(name)
	if err != nil {
		return
	}
	newUser, err := user.NewUser(*userName)
	if err != nil {
		return
	}

	userService, err := user.NewUserService(db)
	if err != nil {
		return
	}
	isExists, err := userService.Exists(newUser)
	tx, err := db.Begin()
	if err != nil {
		return
	}

	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()
	if err != nil {
		return err
	}
	if isExists {
		return fmt.Errorf("main.CreateUser(): %s is already exists.", name)
	}

	_, err = db.Exec("INSERT INTO users (id, username) VALUES ($1, $2)", newUser.UserId(), newUser.UserName())
	if err != nil {
		return fmt.Errorf("main.CreateUser(): %v", err)
	}
	log.Println("test-user is successfully added in users table")
	return nil
}

コンテナの起動・マイグレーション

実行準備をします。

コンテナの起動
> make up
docker compose up -d
# 完了までまつ
マイグレーション
> make run-migration
docker compose exec app bash db-migration.sh
1/u user (9.199ms)

実行

動作確認をします。

最初の実行では、test-userが登録されていないため、登録されます。

test-user 登録 1回目
> make run
docker compose exec app go run main.go
2022/03/05 10:20:04 successfully connected to database
2022/03/05 10:20:04 test-user is successfully added in users table

2 回目では、test-userがすでに登録されているため、エラーがログに出力されます。

test-user 登録 2回目
> make run
docker compose exec app go run main.go
2022/03/05 10:22:24 successfully connected to database
2022/03/05 10:22:24 main.CreateUser(): test-user is already exists.

UserServiceに振る舞いを記述しても、適切な挙動をすることがわかりました。
Userに確認処理をしても、振る舞いは変わりませんが、ドメインを正しく反映したモデルになります。

考察

ドメインサービスについて気になったことを考察します。

命名規則について

今回は、サンプルコードに従いドメインサービスの名前をUserServiceにしました。
しかし、この名前はあまりよくない気がします。
Service という単語が包括的なので、名前だけみたときに何をしているかわかりません。
本来はモデリングして作成したユビキタス言語をファイル名や関数名にするべきなのですが、モデリングしていないためUserServiceをそのまま使っています。
全員の合意がとれているのならばいいのですが、新しい開発者が入ったときにソースコードを辿る必要があります。

以上のことを踏まえると、今回はUserExistsServiceのほうが適切な名前だと考えます。
分かりやすくなった上に、重複確認(Exists)以外のふるまいを追加する心配がなくなります。
もっというのであれば、Serviceを除去してUserExistsにするとさらに明確だと考えられます。
しかし、やりすぎると名前だけでドメインサービスや値オブジェクトなどと区別しずらくなります。
どのような名前にするとしても、ユビキタス言語に則った名前であり、振る舞いが明確で、開発者以外(ビジネス職、デザイナー)にもわかる名前にしたほうがいいと考えました。

UserService にインタフェースを使うかについて

以下のソースコードのように、DI のためにインタフェースを UserService に追加するか考えました。
しかし、そのためにはCreateUserも編集する必要があります。
サンプルコードから乖離することと、UserServiceにはメソッドが 1 つなのでやめました。

./domain/model/user/userservice.go
type UserServicer interface {
	Exists(user *User) (bool, error)
}

type UserService struct {
	db *sql.DB
}

func NewUserService(db *sql.DB) UserServicer {
	return &UserService{db: db}
}

まとめ

Go で DDD の戦術的パターンの 1 つである、ドメインサービスを実装しました。
サンプルコードを参考に、ドメインオブジェクトを不自然な振る舞いから切り離すことを意識しながら実装しました。

Service という名前は包括的なので、さまざまな処理を実装しそうです。
ファットな Service からドメインモデル貧血症を避けるためには、適切な名前をつけることが必要です。
最重要なのは、ユビキタス言語からファイル名、関数名、変数名をつけることなので、モデリングを意識しながら実装することが重要だと考えました。

参考

https://www.shoeisha.co.jp/book/detail/9784798150727

https://www.shoeisha.co.jp/book/detail/9784798131610

Discussion

ログインするとコメントできます