Goでリポジトリを実装(「入門ドメイン駆動設計」Chapter5)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「入門ドメイン駆動設計」Chapter5 のリポジトリ(Repository)の実装をおこないます。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
リポジトリ
リポジトリについて説明します。
「入門ドメイン駆動設計」ではリポジトリを以下のように紹介していました。
オブジェクトを繰り返し利用するには、何らかのデータストアにオブジェクトのデータを永続化(保存)し、再構築(復元)する必要があります。リポジトリはデータを永続化し再構築するといった処理を抽象的に扱うためのオブジェクトです。
オブジェクトのインスタンスを保存したいときには直接的にデータストアに書き込み処理を実行するのではなく、リポジトリにインスタンスの永続化を依頼します。また永続化したデータからインスタンスを再構築したいときにもリポジトリにデータの再構築を依頼します。
ドメインオブジェクトから直接永続化層に保存する処理をするのではなく、リポジトリに永続化を依頼します。
そうすることで、ドメインオブジェクトから、DB などの実装の詳細を知ることがなく変更できるようになります。これは、DB の種類を変更するとき便利になります。
その他の注意点は 2 つあります。
1 つめは、リポジトリはドメインオブジェクトに対してリストを操作するようなメソッドを作成します。メソッド名はリストを操作するような名前(Add
、Remove
など)をつけます。
2 つめは、1 集約 1 リポジトリの対応になり整合性を守るようにします。
実装
本記事では、リポジトリUserRepository
によって、エンティティUser
の永続化処理を実装しました。また、UserService
によってユーザーの重複確認処理をおこないます。
ドメインモデル図
ディレクトリ構成
ディレクトリ構成は以下のようにしました。
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 つの関数FindByUserName
とSave
が記述れています。
FindByUserName
とSave
がドメインオブジェクトの永続化処理を抽象化した関数になります。
インタフェースにしている理由は、他のドメインサービスやファクトリなどから参照された際に、依存を分離するためです。UserService
で確認します。
UserRepositorier
の実装クラスUserRepository
に実際の処理を記述します。
FindByUserName
がユーザー名からユーザーを取得する処理、Save
がユーザーを保存する処理になります。
本来であればインタフェースUserRepositorier
をドメイン層に、実装クラスUserRepository
をインフラ層に記述して、レイヤーレベルでの分離をおこないます。
参考にしたサンプルコードこの時点では同じパッケージに配置しているため、簡略化も兼ねて同じパッケージに配置しました。
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
を引数とします。実装クラスではなくインタフェースを渡すことで、具体的な処理に依存しないでコンパイルとテストを実行できます。
ドメインサービスについての詳細な説明は以前の記事を参照してください。
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
)と構成要素の値オブジェクト(UserId
、UserName
)です。
以下の実装をおこないました。
具体的な解説は以前の記事(エンティティ、値オブジェクト)を参照してください。
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
}
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)
}
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
を保存する処理になります。
本来はアプリケーションサービス層にまとめますが、今回は説明のために省略しました。
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
が登録されていないため、登録されます。
> 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
がすでに登録されているため、エラーがログに出力されます。
> 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
適切に動作することを確認しました。
考察
リポジトリを実装したときの考察について記述していきます。
個人的な見解なので、飛ばしてもらってかまわないです。
命名規則ついて
ドメイン駆動設計では、ユビキタス言語をもとにクラス名、関数名、型名、変数名を命名します。そのため、Service
やFactory
のような抽象的で機械的な名前をつけることを避けます。
しかし、Repository
はサフィックスにRepository
をつける例が多いです。「実践ドメイン駆動設計」でもそうでした。
この点については、リポジトリはドメインの詳細よりも技術的な詳細に寄っているため、許容されているのではないかと考えました。
同様の理由で、プレゼンテーション層にController
という名前が許容されていると考えています。
また、メソッド名について考えました。
リポジトリはリストのように扱う必要があるため、メソッド名はリストを操作するような名前であります。
しかし、Save
はリストのような名前ですが、FindByUserName
はドメイン知識を持った名前になります。
原因は、Go はオーバライドを持たない言語だからです。
具体的にはFind(userId UserId ,userName UserName)
とFind(userId UserId)
を両方定義できません。
なので、今回の実装ではFindByUserName
という名前にしました。
オーバーライドを持たない言語全てに共通することなのと、インフラ層に寄せている概念なので、妥協できる範囲だと考えました。
まとめ
サンプルコードを参考しながら、Go で DDD の戦術的パターンの 1 つである、リポジトリを実装しました。
技術的な詳細である DB への保存、確認処理をリポジトリに集約させることで凝集度の高いソースコードになりました。
本記事では省略しましたが、リポジトリのインタフェースをドメイン層、実装クラスをインフラ層に置く(セパレートインタフェース)にすることで、さらに分離できます。
このように戦術的 DDD ではドメインとそれ以外で分離することを推奨しており、実践できました。
O/R マッパを使用する際にも、リポジトリに配置することで、ドメインに影響を与えることなく実装できます。
今後、実装したら記事を投稿します。
Author And Source
この問題について(Goでリポジトリを実装(「入門ドメイン駆動設計」Chapter5)), 我々は、より多くの情報をここで見つけました https://zenn.dev/msksgm/articles/20220408-go-itddd-05-repository著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol