Goでアプリケーションサービスを実装(「ドメイン駆動設計入門」Chapter6)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter6 のアプリケーションサービス(ApplicationService)の実装します。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
アプリケーションサービス
アプリケーションサービスについて説明します、
「ドメイン駆動設計入門」ではアプリケーションサービスを以下のように紹介していました。
第 4 章『不自然さを解決する「ドメインサービス」』で予告した 2 つ目のサービスが本性で解説するアプリケーションサービスです。アプリケーションサービスを端的に表現するならば、ユースケースを実現するオブジェクトです。
たとえばユーザ登録の必要なシステムにおいて、ユーザ機能を実現するには「ユーザを登録する」ユースケースや「ユーザ情報を変更する」ユースケースが必要です。ユーザ機能のアプリケーションサービスはユースケースにしたがって「ユーザを登録する」ふるまいや「ユーザ情報を変更する」ふるまいが定義されます。それらのふるまいは実際にドメインオブジェクトを組み合わせて実行するスクリプトのようなふるまいです。
記述してあるとおり「ユースケースを実現するオブジェクト」です。
アプリケーションサービスはユースケースをエンティティ、値オブジェクト、ドメインサービス、リポジトリなどを組み合わせて実装されます。
アプリケーションサービスが集約を取りまとめ、アーキテクチャの一番外側(ポート&アダプタ)にメソッドを提供します。
アプリケーションは、アプリケーションサービスのメソッドを呼び出すことでユースケースを実現します。
実装
本記事では、アプリケーションサービスUserApplicationService
によって、ユースケースが実現されていることを確認します。
ドメインモデル図
ディレクトリ構成
ディレクトリ構成は以下にしました。
domain/model
配下にドメインごとのオブジェクトを配置します。
簡略化のため、リポジトリのインタフェースと実装を分けなかったり、アプリケーションサービスもドメイン層に配置したりしています。
.
├── Makefile
├── db-migration.sh
├── docker
│ ├── go
│ │ └── Dockerfile
│ └── postgres
│ └── Dockerfile
├── docker-compose.yml
├── domain
│ └── model
│ └── user
│ ├── user.go
│ ├── user_test.go
│ ├── userapplicationservice.go
│ ├── userapplicationservice_test.go
│ ├── userid.go
│ ├── userid_test.go
│ ├── username.go
│ ├── username_test.go
│ ├── userrepository.go
│ ├── userrepository_test.go
│ ├── userservice.go
│ └── userservice_test.go
├── go.mod
├── go.sum
├── main.go
└── migrations
├── 000001_user.down.sql
└── 000001_user.up.sql
7 directories, 22 files
UserApplicationService
まず、UserApplicationService
を解説します。
UserApplicationService
には、リポジトリのインタフェースUserRepositorier
と、ドメインサービスのUserService
を使用します。
リポジトリはインタフェースを呼び出すことで、抽象に依存させます。そうすることでアプリケーションサービスはドメイン層の実装の詳細を把握せずに、実装やテストを記述可能です。
ドメインサービスもインタフェースにして問題ありません。本記事ではサンプルコードに従い実クラスにしました。
UserApplicationService
のメソッドはRegister
、Get
、Update
、Delete
を実装します。
このメソッドがアプリケーションの振る舞い(ユーザーの登録、取得、更新、削除)になります。
Get
は戻り値に DTO のUserData
を使用しています。DTO を使用することで、不必要なドメイン知識の流出や途中で変更されドメインに影響することを防げます。
Update
メソッドとDelete
メソッドに引数にはコマンドパターンを使用して制御しています。サンプルコードで使用されていたため実装しましたが、基準があいまいですので、開発者の判断に任せる箇所だと考えています。
それぞれの実装の詳細については、Go の実装方法の話になるので省略します。
サンプルコードではUserId
に UUID を使用していました。
しかし、本記事では動作確認で使いづらかったため、コメントアウトしてtest-id
で固定にしています。
本番利用するときは、UUID を使用します。
package user
import (
"fmt"
"log"
// "github.com/google/uuid"
)
type UserApplicationService struct {
userRepository UserRepositorier
userService UserService
}
func NewUserApplicationService(userRepository UserRepositorier, userService UserService) (*UserApplicationService, error) {
return &UserApplicationService{userRepository: userRepository, userService: userService}, nil
}
func (uas *UserApplicationService) Register(name string) (err error) {
defer func() {
if err != nil {
err = &RegisterError{Name: name, Message: fmt.Sprintf("userapplicationservice.Register err: %s", err), Err: err}
}
}()
userName, err := NewUserName(name)
if err != nil {
return err
}
// uuidV4 := uuid.New().String()
uuidV4 := "test-id"
userId, err := NewUserId(uuidV4)
if err != nil {
return err
}
user, err := NewUser(*userId, *userName)
if err != nil {
return err
}
isUserExists, err := uas.userService.Exists(user)
if err != nil {
return err
}
if isUserExists {
return fmt.Errorf("user name of %s is already exists.", name)
}
if err := uas.userRepository.Save(user); err != nil {
return err
}
log.Printf("user name of %s is successfully saved", name)
return nil
}
type RegisterError struct {
Name string
Message string
Err error
}
func (err *RegisterError) Error() string {
return err.Message
}
type UserData struct {
Id string
Name string
}
func (uas *UserApplicationService) Get(userId string) (_ *UserData, err error) {
defer func() {
if err != nil {
err = &GetError{UserId: userId, Message: fmt.Sprintf("userapplicationservice.Get err: %s", err), Err: err}
}
}()
targetId, err := NewUserId(userId)
if err != nil {
return nil, err
}
user, err := uas.userRepository.FindByUserId(targetId)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
userData := &UserData{Id: user.id.value, Name: user.name.value}
return userData, nil
}
type GetError struct {
UserId string
Message string
Err error
}
func (err *GetError) Error() string {
return err.Message
}
type UserUpdateCommand struct {
Id string
Name string
}
func (uas *UserApplicationService) Update(command UserUpdateCommand) error {
targetId, err := NewUserId(command.Id)
if err != nil {
return err
}
user, err := uas.userRepository.FindByUserId(targetId)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("user is not found")
}
if name := command.Name; name != "" {
newUserName, err := NewUserName(name)
if err != nil {
return err
}
user.ChangeName(*newUserName)
isExists, err := uas.userService.Exists(user)
if err != nil {
return err
}
if isExists {
return fmt.Errorf("user name of %s is already exists.", name)
}
}
if err := uas.userRepository.Update(user); err != nil {
return err
}
log.Println("successfully updated")
return nil
}
type UserDeleteCommand struct {
Id string
}
func (uas *UserApplicationService) Delete(command UserDeleteCommand) error {
targetId, err := NewUserId(command.Id)
if err != nil {
return err
}
user, err := uas.userRepository.FindByUserId(targetId)
if err != nil {
return err
}
if user == nil {
return nil
}
if err := uas.userRepository.Delete(user); err != nil {
return err
}
log.Println("successfully deleted")
return nil
}
そのほかのドメインオブジェクト UserRepository、UserService、User、UserId、UserName
実装したソースコードにはほかにもドメインオブジェクトを用意しています。
アプリケーションサービスはこれらを組み合わせて振る舞いを実現しています。
詳細は説明しませんが、もし興味があれば自分の GitHub のリポジトリと過去記事を参考にしてみてください。
ドメインオブジェクト | ドメインオブジェクト名 | 役割 | 過去記事 |
---|---|---|---|
UserRepository |
リポジトリ | DB とUser のやりとり(保存、検索、etc...)を隠蔽する。 |
Go でリポジトリを実装(「ドメイン駆動設計入門」Chapter5) |
UserService |
ドメインサービス |
User が振る舞いとして持つべきでないステートレスな処理を持つ。 |
Go でドメインサービスを実装(「ドメイン駆動設計入門」Chapter4) |
User |
エンティティ | 集約ルートになる可能性がある。一意な識別子を持ち、ほかの属性を可変にしても良い | Go でエンティティを実装(「ドメイン駆動設計入門」Chapter3) |
UserId UserName
|
値オブジェクト | 属性が不変。エンティティの一意な識別子になることがある。 | Go で値オブジェクトを実装(「ドメイン駆動設計入門」Chapter2) |
動作確認
実装から動作確認を行います。
docker-compose を使用します。
main.go
アプリケーションサービスを呼び出さす処理をmain.go
に記述します。
実装の順番は、DB の接続、アプリケーションサービスにドメインサービスとリポジトリの挿入、アプリケーションの振る舞いを実行、です。
コマンドライン引数から、ユースケースを切り替えられるように実装しました。
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
"github.com/msksgm/go-itddd-06-applicationservice/domain/model/user"
)
var command = flag.String("usecase", "", "usercase of application")
func main() {
// DBに接続
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 {
panic(err)
}
if err := db.Ping(); err != nil {
panic(err)
}
log.Println("successfully connected to database")
// アプリケーションサービスにリポジトリとドメインサービスを挿入
userRepository, err := user.NewUserRepository(db)
if err != nil {
panic(err)
}
userService, err := user.NewUserService(userRepository)
if err != nil {
panic(err)
}
userApplicationService, err := user.NewUserApplicationService(userRepository, *userService)
if err != nil {
panic(err)
}
// ユースケースの実行
flag.Parse()
log.Println(*command)
switch *command {
case "register":
if err := userApplicationService.Register("test-user"); err != nil {
log.Println(err)
}
case "get":
userData, err := userApplicationService.Get("test-id")
if err != nil {
log.Println(err)
}
log.Println(userData)
case "update":
userUpdateCommand := &user.UserUpdateCommand{Id: "test-id", Name: "test-updated-user"}
if err := userApplicationService.Update(*userUpdateCommand); err != nil {
log.Println(err)
}
case "delete":
userDeleteCommand := &user.UserDeleteCommand{Id: "test-id"}
if err := userApplicationService.Delete(*userDeleteCommand); err != nil {
log.Println(err)
}
default:
log.Printf("%s is not command. choose in ('register', 'get', 'update', 'delete')", *command)
}
}
コンテナを起動・マイグレーション
実行準備をします。
> make up
docker compose up -d
# 完了までまつ
> make run-migration
docker compose exec app bash db-migration.sh
1/u user (13.817ms)
実行
動作確認をします。
実行の順番は Register(1 回目)、Register(2 回目)、Get(1 回目)、Update、Get(2 回目)、Delete で実施します。
Register の 1 回目の実行です。test-user
が登録されます。
> make run usecase=register
docker compose exec app go run main.go -usecase=register
2022/04/21 23:17:11 successfully connected to database
2022/04/21 23:17:11 register
2022/04/21 23:17:11 user name of test-user is successfully saved
Register の 2 回目の実行です。test-user
はすでに登録されていることがわかります。
> make run usecase=register
docker compose exec app go run main.go -usecase=register
2022/04/21 23:19:07 successfully connected to database
2022/04/21 23:19:07 register
2022/04/21 23:19:07 userapplicationservice.Register err: user name of test-user is already exists.
Get の 1 回目の実行です。登録されたtest-user
を取得できました。
> make run usecase=get
docker compose exec app go run main.go -usecase=get
2022/04/21 23:20:24 successfully connected to database
2022/04/21 23:20:24 get
2022/04/21 23:20:24 &{test-id test-user}
Update の実行です。無事に終了しました。
> make run usecase=update
docker compose exec app go run main.go -usecase=update
2022/04/21 23:21:26 successfully connected to database
2022/04/21 23:21:26 update
2022/04/21 23:21:26 successfully updated
Get の 2 回目の実行です。test-user
がtest-updated-user
になったことが確認できました。
> make run usecase=get
docker compose exec app go run main.go -usecase=get
2022/04/21 23:22:24 successfully connected to database
2022/04/21 23:22:24 get
2022/04/21 23:22:24 &{test-id test-updated-user}
Delete の実行です。正常終了しました。このあと Register を実行すると、test-user
が保存されます。
> make run usecase=delete
docker compose exec app go run main.go -usecase=delete
2022/04/21 23:23:52 successfully connected to database
2022/04/21 23:23:52 delete
2022/04/21 23:23:52 successfully deleted
考察
今回実装するにあたっての考察を記述してきます。
個人的な考察なので飛ばしてもらってかまわないです。
ApplicationService という名前について
「ドメイン駆動設計入門」で、ドメインサービスとアプリケーションサービスの違いについて、解説されていました。
根本的に違うので区別するのは問題ないと考えます。
そもそも Service という名前自体が包括的すぎて、わかりにくい要因になっていると間がています。ですので、オブジェクト自体にはService
という名前を排除する方針を考えています。
たとえば、ドメインサービスのUserService
はUserExists
というオブジェクトを作成すれば、明確になります。また、「ユーザーの重複確認」以外の振る舞いを実装する心配もなくなり、ドメインサービスがファットになるリスクを下げられます。
また、Application という名前も責務が広いと考えました。
「ドメイン駆動設計モデリング/実装ガイド」という書籍では、オニオンアーキテクチャのアプリケーション層をユースケース層と呼び替えています。理由は、ユースケース層という名前にすることで、「ユースケースを実装する層」と明確になるからです。
これはオブジェクトの名前にも当てはまることだと考えています。なのでUserApplication
よりもUserUsecase
のほうがわかりやすいと考えました。
しかし、一般的に使われている名前を使った方が、新規参入したメンバーに伝わりやすい可能性もあります。
結局、命名規則は、最終的には開発者(とステークホルダー全般)の合意がとれればよいと考えました。
アーキテクチャについて
本記事では、サンプルコードに則して、1 つのディレクトリにすべてのドメインオブジェクトを記述しました。
本来であれば、アーキテクチャのレイヤごとに責務を分離して実装したほうが戦術的 DDD のやりたいことが明確になったと考えています。
しかし、Go は変数と関数の公開範囲が package の範囲であるの都合上、ドメインオブジェクトを immutable にするのが難しいです。
無理に分離すると、公開範囲を広げる必要があり、簡単に immutable じゃなくなります。
Go で戦術的 DDD を実装する際に、うまくドメインオブジェクトの属性を非公開にする方法があれば教えていただきたいです。
アプリケーションサービスのほかの責務について
アプリケーションサービスには、本記事で説明した責務以外にも、トランザクションやセキュリティも担います。
セキュリティは今回のユースケースでは扱えませんが、トランザクションに関しては DDD にとってアンチパターンの実装をしてしまいました。
Go では、SQL のすぐ近くでトランザクションを開始する実装が多いので、そちらに合わせました。
DDD のやり方に合わせるのであれば、アプリケーションサービスでトランザクション(tx)を開始して引数のバケツリレーをする必要があります。
まとめ
サンプルコードを参考しながら、Go で DDD の戦術的パターンの 1 つである、アプリケーションサービスを実装しました。
アプリケーションサービスはドメインオブジェクトをまとめ、アプリケーションの振る舞いを実現します。
これらを実装したあと、動作確認しました。
特にアーキテクチャを考えずに実装したため、ディレクトリ構成がよくわからない状態になっています。
考察にも書きましたが Go の package の公開範囲に悩まされているため、もし良い方法を知っていたら、コメントのほどお願いします。
Discussion