Goでドメインサービスを実装(「ドメイン駆動設計入門」Chapter4)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter4 のドメインサービスの実装します。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
ドメインサービス
まず、ドメインサービスについて説明します。
「ドメイン駆動設計入門」ではドメインサービスを以下のように紹介していました。
値オブジェクトやエンティティなどのドメインオブジェクトにはふるまいが記述されます。たとえば、ユーザ名に文字数や利用できる文字種に制限があるのであれば、その知識はユーザ名の値オブジェクトに記述されてしかるべきでしょう。
しかし、システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。
不自然になってしまう振る舞いがポイントです。
具体的には、本書では「登録済みのユーザーをユーザーエンティティに問い合わせる」が紹介され、ほかには「ユーザーが認証済みかユーザーエンティティに問い合わせる」などが例としてよく挙げられます。
それらの振る舞いをドメインオブジェクトから切り出した処理がドメインサービスになります。
「ドメイン駆動設計入門」では、ドメインサービスの注意点として以下を挙げていました。
エンティティや値オブジェクトに記述すると不自然なふるまいはドメインサービスに記述します。ここで重要なのは、「不自然なふるまい」に限定することです。実をいうとすべてのふるまいはドメインサービスに記述できてしまいます。
(中略)
先の例からわかるとおり、すべてのふるまいはドメインサービスに移設できます。やろうと思えばいくらでもドメインモデル貧血症を引き起こせてしまいます。
もちろんふるまいの中にはドメインサービスとして抽出しないと違和感のあるものは存在します。ふるまいをエンティティや値オブジェクトに定義するべきか、それともドメインサービスに定義するべきか、迷いが生じたらまずはエンティティや値オブジェクトに定義してください。可能な限りドメインサービスは利用しないでください。
ドメインサービスの濫用はデータとふるまいを断絶させ、ロジック点在を促す行為です。ロジックの点在はソフトウェアの変化を阻害し、深刻に停滞させます。ソフトウェアの変更容易性を担保するためにも、コードを一元的に管理することを早々に諦めることは絶対にしてはいけません。
濫用するとロジックが点在し、ドメインモデル貧血症になります。
結果、ドメインモデルが DB のエンティティをマッピングしただけになり、すべての処理がドメインサービスに記述され、ドメイン駆動設計ではなくトランザクションスクリプトになります。
そのためには、ドメインサービスは不自然な振る舞いを解決するために使うので、状態を持たないことも重要だと考えました。
実装
本記事では、ユーザーの重複確認するドメインサービスUserService
を実装しました。
処理のために、値オブジェクトUserId
、UserName
とエンティティUser
を実装しました。
ドメインモデル図
ディレクトリ構成
ディレクトリ構成は以下のようにしました。
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
)は不変です。変更したいときには、再代入する必要があります。
興味のある方は、私の過去の記事(エンティティ、値オブジェクト)を参考にしていただきたいです。
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)
}
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)
}
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)
}
User
はUserId
とUserName
を属性としてもっていますが、ほかに同じエンティティが存在するかは知りません。
たとえば、user.go
に以下のような実装をしたとします。同じUser
が存在するかを見つけることが可能になりました。
しかし、User
がもつ振る舞いとしては不自然です。User
が直接 DB を参照して、User
を検索するのはおかしいです。
それを解決するためにドメインサービスUserService
が存在します。
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
にあったら不自然な振る舞いをドメインサービスとして切り離すことで自然になりました。
このような場合にドメインサービスを使うことが推奨されます。
本来ドメインサービスから直接永続化の処理を書くことはありませんが、今回はサンプルコードのため許容しています。
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
のときエラーをリターンして終了します。
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
が登録されていないため、登録されます。
> 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
がすでに登録されているため、エラーがログに出力されます。
> 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 つなのでやめました。
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 からドメインモデル貧血症を避けるためには、適切な名前をつけることが必要です。
最重要なのは、ユビキタス言語からファイル名、関数名、変数名をつけることですので、モデリングを意識しながら実装することが重要だと考えました。
参考
Discussion