🧱

Goでリポジトリを実装(「ドメイン駆動設計入門」Chapter5)

2022/04/08に公開

概要

戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter5 のリポジトリ(Repository)の実装します。

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

https://github.com/Msksgm/go-itddd-05-repository

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

https://github.com/nrslib/itddd/tree/master/SampleCodes/Chapter5/_12

リポジトリ

リポジトリについて説明します。
「ドメイン駆動設計入門」ではリポジトリを以下のように紹介していました。

オブジェクトを繰り返し利用するには、何らかのデータストアにオブジェクトのデータを永続化(保存)し、再構築(復元)する必要があります。リポジトリはデータを永続化し再構築するといった処理を抽象的に扱うためのオブジェクトです。
オブジェクトのインスタンスを保存したいときには直接的にデータストアに書き込み処理を実行するのではなく、リポジトリにインスタンスの永続化を依頼します。また永続化したデータからインスタンスを再構築したいときにもリポジトリにデータの再構築を依頼します。

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

ドメインオブジェクトから直接永続化層に保存する処理をするのではなく、リポジトリに永続化を依頼します。
そうすることで、ドメインオブジェクトから、DB などの実装の詳細を知ることがなく変更できます。これは、DB の種類を変更するとき便利になります。

そのほかの注意点は 2 つあります。
1 つめは、リポジトリはドメインオブジェクトに対してリストを操作するようなメソッドを作成します。メソッド名はリストを操作するような名前(AddRemoveなど)をつけます。
2 つめは、1 集約 1 リポジトリの対応になり整合性を守るようにします。

実装

本記事では、リポジトリUserRepositoryによって、エンティティUserの永続化処理を実装しました。また、UserServiceによってユーザーの重複確認処理をします。

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
│           ├── 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, 20 files

UserRepository

まず、UserRepositoryを解説します。
UserRepositoryにはインタフェースUserRepositorierが実装され、2 つの関数FindByUserNameSaveが記述れています。
FindByUserNameSaveがドメインオブジェクトの永続化処理を抽象化した関数になります。
インタフェースにしている理由は、ほかのドメインサービスやファクトリなどから参照された際に、依存を分離するためです。UserServiceで確認します。

UserRepositorierの実装クラスUserRepositoryに実際の処理を記述します。
FindByUserNameがユーザー名からユーザーを取得する処理、Saveがユーザーを保存する処理になります。

本来であればインタフェースUserRepositorierをドメイン層に、実装クラスUserRepositoryをインフラ層に記述して、レイヤレベルでの分離します。
参考にしたサンプルコードこの時点では同じパッケージに配置しているため、簡略化も兼ねて同じパッケージに配置しました。

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

import (
	"database/sql"
	"fmt"

	_ "github.com/lib/pq"
)

type UserRepositorier interface {
	FindByUserName(name *UserName) (*User, error)
	Save(user *User) error
}

type UserRepository struct {
	db *sql.DB
}

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

func (ur *UserRepository) FindByUserName(name *UserName) (user *User, err error) {
	tx, err := ur.db.Begin()
	if err != nil {
		return
	}
	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	rows, err := tx.Query("SELECT id, name FROM users WHERE name = $1", name.value)
	if err != nil {
		return nil, &FindByUserNameQueryError{UserName: *name, Message: fmt.Sprintf("error is occured in userrepository.FindByUserName: %s", err), Err: err}
	}
	defer rows.Close()

	userId := &UserId{}
	userName := &UserName{}
	for rows.Next() {
		err := rows.Scan(&userId.value, &userName.value)
		if err != nil {
			return nil, err
		}
		user = &User{id: *userId, name: *userName}
	}

	err = rows.Err()
	if err != nil {
		return nil, err
	}

	return user, nil
}

type FindByUserNameQueryError struct {
	UserName UserName
	Message  string
	Err      error
}

func (err *FindByUserNameQueryError) Error() string {
	return err.Message
}

func (ur *UserRepository) Save(user *User) (err error) {
	tx, err := ur.db.Begin()
	if err != nil {
		return
	}
	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	_, err = tx.Exec("INSERT INTO users(id, name) VALUES ($1, $2)", user.id.value, user.name.value)
	if err != nil {
		return &SaveQueryRowError{UserName: user.name, Message: fmt.Sprintf("userrepository.Save err: %s", err), Err: err}
	}
	return nil
}

type SaveQueryRowError struct {
	UserName UserName
	Message  string
	Err      error
}

func (err *SaveQueryRowError) Error() string {
	return err.Message
}

UserService

以下はドメインサービスであるUserServiceです。
ユーザーの重複確認を行うメソッドExistsが実装されています。
DB とやりとりする処理をリポジトリに任せているため、UserService.Existsには「重複確認をする」という本質的な処理のみに凝集されていることがわかります。
また、生成時にインタフェースUserRepositorierを引数とします。実装クラスではなくインタフェースを渡すことで、具体的な処理に依存しないでコンパイルとテストを実行できます。
ドメインサービスについての詳細な説明は以前の記事を参照してください。

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

type UserService struct {
	userRepository UserRepositorier
}

func NewUserService(userRepository UserRepositorier) (*UserService, error) {
	return &UserService{userRepository: userRepository}, nil
}

func (us *UserService) Exists(user *User) (bool, error) {
	user, err := us.userRepository.FindByUserName(user.Name())
	if err != nil {
		return false, err
	}
	return user != nil, nil
}

User、UserId、UserName

今回のサンプルで操作の対象となったエンティティ(User)と構成要素の値オブジェクト(UserIdUserName)です。
以下の実装しました。
具体的な解説は以前の記事(エンティティ値オブジェクト)を参照してください。

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

type User struct {
	id   UserId
	name UserName
}

func NewUser(userId UserId, userName UserName) (*User, error) {
	return &User{id: userId, name: userName}, nil
}

func (user *User) Id() *UserId {
	return &user.id
}

func (user *User) Name() *UserName {
	return &user.name
}

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

import (
	"fmt"
	"reflect"
)

type UserId struct {
	value string
}

func NewUserId(value string) (*UserId, error) {
	return &UserId{value: value}, nil
}

func (userId *UserId) Equals(other *UserId) bool {
	return reflect.DeepEqual(userId.value, other.value)
}

func (userId *UserId) String() string {
	return fmt.Sprintf("UserId [value: %s]", userId.value)
}

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

import (
	"fmt"
	"reflect"
)

type UserName struct {
	value string
}

func NewUserName(value string) (*UserName, error) {
	if len(value) < 3 {
		return nil, fmt.Errorf("UserName is more than 3 characters.")
	}
	if len(value) > 20 {
		return nil, fmt.Errorf("UserName is less than 20 characters.")
	}
	return &UserName{value: value}, nil
}

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

func (userName *UserName) String() string {
	return fmt.Sprintf("UserName: [value: %s]", userName.value)
}

動作確認

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

main.go

main.goを動作確認のためだけに作成しました。
DB との接続を main 関数の中で実装し、User の作成処理はCreateUserで実装します。
ユーザー名user-name、ユーザー IDuser-idを保存する処理になります。

本来はアプリケーションサービス層にまとめますが、今回は説明のために省略しました。

./main.go
package main

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

	"github.com/msksgm/go-itddd-05-repository/domain/model/user"
)

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", "test-user-id")
	if err != nil {
		log.Println(err)
	}
}

func CreateUser(db *sql.DB, name string, id string) (err error) {
	defer func() {
		if err != nil {
			err = &CreateUserError{Message: fmt.Sprintf("main.CreateUser err: %v", err), Err: err}
		}
	}()
	userName, err := user.NewUserName("username")
	if err != nil {
		return err
	}
	userId, err := user.NewUserId("userid")
	if err != nil {
		return err
	}
	newUser, err := user.NewUser(*userId, *userName)
	if err != nil {
		return err
	}

	userRepository, err := user.NewUserRepository(db)
	if err != nil {
		return err
	}

	userService, err := user.NewUserService(userRepository)
	if err != nil {
		return err
	}

	isExists, err := userService.Exists(newUser)
	if err != nil {
		return err
	}

	if isExists {
		return fmt.Errorf("the user %v is already exists", newUser)
	}

	if err := userRepository.Save(newUser); err != nil {
		return err
	}
	log.Println("test-user is successfully added in users table")
	return nil
}

type CreateUserError struct {
	Message string
	Err     error
}

func (err *CreateUserError) Error() string {
	return err.Message
}

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

実行準備をします。

コンテナの起動
> 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/04/07 22:19:24 successfully connected to database
2022/04/07 22:19:24 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/04/07 22:19:34 successfully connected to database
2022/04/07 22:19:34 main.CreateUser err: the user &{{userid} {username}} is already exists

適切に動作することを確認しました。

考察

リポジトリを実装したときの考察について記述していきます。
個人的な見解ですので、飛ばしてもらってかまわないです。

命名規則ついて

ドメイン駆動設計では、ユビキタス言語をもとにクラス名、関数名、型名、変数名を命名します。そのため、ServiceFactoryのような抽象的で機械的な名前をつけることを避けます。
しかし、Repositoryは接尾辞にRepositoryをつける例が多いです。「実践ドメイン駆動設計」でもそうでした。
この点については、リポジトリはドメインの詳細よりも技術的な詳細に寄っているため、許容されているのではないかと考えました。
同様の理由で、プレゼンテーション層にControllerという名前が許容されていると考えています。

また、メソッド名について考えました。
リポジトリはリストのように扱う必要があるため、メソッド名はリストを操作するような名前であります。
しかし、Saveはリストのような名前ですが、FindByUserNameはドメイン知識を持った名前になります。
原因は、Go はオーバーライドを持たない言語だからです。
具体的にはFind(userId UserId ,userName UserName)Find(userId UserId)を両方定義できません。
ですので、今回の実装ではFindByUserNameという名前にしました。
オーバーライドを持たない言語すべてに共通することなのと、インフラ層に寄せている概念ですので、妥協できる範囲だと考えました。

まとめ

サンプルコードを参考しながら、Go で DDD の戦術的パターンの 1 つである、リポジトリを実装しました。
技術的な詳細である DB への保存、確認処理をリポジトリに集約させることで凝集度の高いソースコードになりました。
本記事では省略しましたが、リポジトリのインタフェースをドメイン層、実装クラスをインフラ層に置く(セパレートインタフェース)にすることで、さらに分離できます。
このように戦術的 DDD ではドメインとそれ以外で分離することを推奨しており、実践できました。

O/R マッパを使用する際にも、リポジトリに配置することで、ドメインに影響を与えることなく実装できます。
今後、実装したら記事を投稿します。

Discussion