この章で行うこと
- ドメインモデルに基づいてドメインオブジェクトを実装する
- エンティティを実装する
- 値オブジェクトを実装する
- ドメインサービスを実装する
- ドメインエラーを実装する
ドメインオブジェクト
ユーザー、タスクについてそれぞれエンティティを実装し、エンティティが持つ属性を値オブジェクトとして実装してきましょう。app/domain/userとapp/domain/taskディレクトリを作成してください。ユーザーから実装します。
ユーザーオブジェクトを実装する
まずは、ユーザーエンティティ・値オブジェクトを実装していきましょう。コードを示してから、ポイントを述べていきます。
エンティティ
まずは、Userエンティティを記述します。app/domain/userディレクトリを作成してください。
// app/domain/user/user.go
package user
type User struct {
id string
email Email
name string
hashedPassword HashedPassword
}
エンティティの性質として、可変であるというものがありました。これは、エンティティの持つ属性が振る舞い(=メソッド等)による変更を許可されているということです。しかしながら、可変なオブジェクトは「厄介」なので、構造体のフィールドを非公開にして直接的な変更を防ぎ、値を変更する振る舞いを持つメソッドを定義せずオブジェクトを不変とする設計としたいと思います。
「可変」はあくまで性質として許可されているに過ぎません。
「厄介」な可変オブジェクト
インスタンスが可変であると、意図せぬ影響を及ぼす可能性を孕みます。
可変だと、仕様変更時などに意図しない副作用を持つ関数を作ってしまい、予期せぬ動作を招いてしまうという事態におちいります。副作用とは、関数が引数を受け取り、戻り値を返す以外に、変数などを変更することです。
端的にいえば、インスタンスが自由に変更される余地を残すと意図せず変更しちゃってバグになるよということです。
golangにおいて、不変を強制するJavaでいうfinal修飾子はありませんが、パッケージ外に対する不変性は以下のようにすることで確保できます。
- 小文字にして非公開(unexport)にする
- セッター等、変更するようなメソッドを定義しない
同パッケージ内からはフィールドの変更が可能になってしまうのですが、パッケージ外からは「操作」のためにアクセスすることができなくなるので比較的安全と言えるでしょう。
値オブジェクト
User構造体のフィールドを見れば分かるとおり、型がプリミティブでないことがわかるでしょうか。こちらを実装します。
まずはEmail値オブジェクトから
// app/domain/user/user_email.go
package user
import (
"net/mail"
"github.com/kakkky/app/domain/errors"
)
type Email struct {
value string
}
func NewEmail(value string) (Email, error) {
// バリデーション
if _, err := mail.ParseAddress(value); err != nil {
return Email{}, errors.ErrInvalidEmail
}
return Email{value: value}, nil
}
func reconstructEmail(value string) Email {
return Email{value: value}
}
func (e Email) Value() string {
return e.value
}
Email型にはvalueフィールドを持たせており、これも非公開であり不変ですね。NewEmail()はファクトリ関数です。golangにはコンストラクタがありませんが、新しいインスタンスを返すように処理を書いたものがファクトリ関数です。
ポイントとして、
- ファクトリ関数の中でバリデーションを行っている
- インスタンスの参照型を返さず、値型で返している
- 再構成用の関数がある
- 独自のエラー型を定義している(もう少し後で説明)
それぞれ説明していきます。
ファクトリ関数の中でバリデーションを行っている
これによって、Userに形式の正しいメールアドレスを持たせることを保証できます。
emailには、メールの形式を守るなどの「ルール」があります。これを例えばUserエンティティにメソッドとして用意してしまうとUserが膨れ上がり、責務を抱え過ぎてしまいます。
ルール・振る舞いを持っている属性は、このように値オブジェクトとしてエンティティから切り出せます。
インスタンスの参照型を返さず、値型で返している
ポインタ型として*Emailを返すようにファクトリ関数を書いていないのには理由があります。等価性によって区別されるという性質が値オブジェクトにはありました。
ポインタ型(参照型)とすると、同じように生成したインスタンスでも等価ではありません。ポインタ型は変数のメモリ上のアドレスを示すものだからです。
func NewEmail(value string) (*Email, error) {
// 省略
return &Email{value: value}, nil
}
email1:=NewEmail("[email protected]")
email2:=NewEmail("[email protected]")
fmt.Println(email1 == email2) // falseになる。
ですので、等価性によって単純に評価できるようにするには、値型として表現するのが良さそうです。これはあくまで、値オブジェクトの性質に則っての設計です。
エンティティとポインタ
値オブジェクトが値型として表現される理由はわかっていただけたかと思います。しかし、値オブジェクトとは打って変わってエンティティにはポインタを使用していましたので、その理由もここに記しておきます。
エンティティは、同一性によって区別される性質がありました。これは、識別子(id)によって区別されるということなので、単純に==で評価することがありません。どれだけ属性が変化しようが、エンティティは識別子により区別されます。また、ポインタは、構造体がデータを多く含んでいる場合に有効のようです。エンティティは基本的にデータを多く含むものと捉えています。これらの理由から、エンティティをポインタとしました。
ポインタか値かについては以下の記事が参考になります。APIの一貫性を保つために、構造体のメソッドはポインタレシーバーを統一的に使うことを推奨していますね。今回のハンズオンでも、統一して基本的には構造体のメソッドをポインタレシーバーとしています。
しかし、値オブジェクトなどのデータが小さい構造体に関しては、先述した「等価性による評価」などの理由に加え、パフォーマンス面を考えて値レシーバーとしました。
参考記事
再構成用の関数がある
reconstructEmail()関数はオブジェクトの再構成を担うメソッドです。
主にリポジトリから使用され、DBからとってきたデータをUserオブジェクトとして復元するためのものです。よくみるとNewUser()ファクトリ関数と同じくUserオブジェクトインスタンスを返していることは同じですね。しかし、バリデーションロジックがありません。これは、データベースに格納されている値はすでにバリデーション済みであるからです。ここでわざわざ検証し直す必要性は薄いです。
また、NewUser()ではidを関数内で作成していたため引数にidを取る必要がありませんでしたが、再構成の際は、すでに存在しているユーザーのUserオブジェクトを復元するためidを入れているという点も異なります。また、この関数は後で記述するユーザーの再構成用の関数ReconstructUser()で使用されるためだけの関数ですので、非公開にしています。
このように、他パッケージ(外部)から使用されない前提がある関数・メソッドは基本的に非公開にして開発を進めています。これは、コードを見ただけで他からも使われているものなのかどうかを認識できるようにするためです。
非公開ならば、「この関数が影響している範囲はこのパッケージに限られるな」とわかるようになります。
同じようにHashedPassword値オブジェクトを実装しましょう。具体的なコードを見る前に、単にPasswordとするのではなくHashedPasswordとした理由を述べておきます。
セキュリティ的にパスワードを平文で管理していいはずもないので、Passwordオブジェクトを用意するならば、そこにハッシュ化の処理を持たせるのが自然です。ただ、パスワードをハッシュ化するということは、ドメイン知識から外れると筆者は思います。ハッシュ化しなければならないのは完全に機能要件であり、アプリケーション由来のものです。
そこで、
元から、ハッシュ化されたパスワードを持つものとしてモデリングすれば、値オブジェクトにハッシュの比較処理等を実装していてもおかしくない
と考え方を変えます。すると、ハッシュ化はドメイン知識となり、自然に実装できるようになるのです。
以下の記事を参考にしました
以下、コードです。HashPasswordの振る舞いとして、ハッシュパスワードと平文パスワードを比較するcompair()メソッドを定義しています。メソッドは非公開にして、Userからこのメソッドを呼び出します。「集約ルート」についてはこの後触れます。
// app/domain/user/user_hashed_password.go
package user
import (
"unicode/utf8"
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/pkg/hash"
)
type HashedPassword struct {
value string
}
const (
minPasswordLength = 6
)
func newHashedPassword(value string) (HashedPassword, error) {
// バリデーション
if minPasswordLength >= utf8.RuneCountInString(value) {
return HashedPassword{}, errors.ErrPasswordTooShort
}
// ハッシュ化する
hashed, err := hash.Hash(value)
if err != nil {
return HashedPassword{}, err
}
return HashedPassword{value: hashed}, nil
}
func reconstructHashedPassword(value string) HashedPassword {
return HashedPassword{value: value}
}
func (hp HashedPassword) Value() string {
return hp.value
}
// ハッシュ化されたパスワードと比較
// 集約ルートUserから呼び出す
func (p HashedPassword) compare(target string) error {
if err := hash.Compare(p.value, target); err != nil {
return errors.ErrPasswordMismatch
}
return nil
}
hashパッケージをpkgから持ってきているのがわかりますでしょうか。
import (
"github.com/kakkky/pkg/hash"
)
ハッシュ化するというのは汎用的な処理なので、pkgモジュールに実装しましょう。
pkg/hash の実装
// pkg/hash/hash.go
package hash
import "golang.org/x/crypto/bcrypt"
func Hash(target string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(target), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func Compare(hashed, target string) error {
err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(target))
return err
}
// pkg/hash/hash_test.go
package hash
import "testing"
func TestHash(t *testing.T) {
tests := []struct {
name string
password string
wantErr bool
}{
{
name: "valid password",
password: "testPassword",
wantErr: false,
},
{
name: "empty password",
password: "",
wantErr: false,
},
{
name: "long password",
password: "verylongpassword123456",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ハッシュ化
hashedPassword, err := Hash(tt.password)
// エラーの検証
if (err != nil) != tt.wantErr {
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
}
// エラーがない場合、ハッシュ化されたパスワードが空でないことを確認
if !tt.wantErr && hashedPassword == "" {
t.Fatal("hashed password should not be empty")
}
})
}
}
func TestCompare(t *testing.T) {
tests := []struct {
name string
password string
targetPassword string
wantErr bool
}{
{
name: "correct password match",
password: "testPassword",
targetPassword: "testPassword",
wantErr: false,
},
{
name: "incorrect password match",
password: "testPassword",
targetPassword: "wrongPassword",
wantErr: true,
},
{
name: "empty password match",
password: "",
targetPassword: "",
wantErr: false,
},
{
name: "empty password mismatch",
password: "testPassword",
targetPassword: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ハッシュ化
hashedPassword, err := Hash(tt.password)
if err != nil {
t.Fatalf("expected no error during hashing, got %v", err)
}
// 比較結果の検証
if err := Compare(hashedPassword, tt.targetPassword); (err != nil) != tt.wantErr {
t.Errorf("Compaire() error = %v,wantErr %v", err, tt.wantErr)
}
})
}
}
テーブルドリブンテスト
golangでは、テーブルドリブンテストというテスト手法が推奨されています。というのも特に特別なパッケージを使用するとかではなく、テストケースを配列(スライス)にまとめるというものです。
func TestAdd(t *testing.T) {
// テストテーブル
tests := []struct {
name string
a, b int
expected int
}{
{name: "正の数同士を足す場合", a: 1, b: 2, expected: 3},
{name: "正の数と0を足す場合", a: 5, b: 0, expected: 5},
{name: "負の数を足す場合", a: -1, b: -2, expected: -3},
}
// テストテーブルにあるテストケースをforループで回す
for _, tt := range tests {
// それぞれのテストケースをサブテストとして実行する
t.Run(tt.name, func(t *testing.T) {
result := calculator.Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("[%s] Add(%d, %d) = %d; want %d",
tt.name, tt.a, tt.b, result, tt.expected)
}
})
}
}
テストテーブルには、テストケース名や入力値・期待値などを含めます。そして、テストケースの配列をforループで回し、t.Run()メソッドにより各テストケースをサブテストとして実行します。
この手法だと、以下のようなメリットがあります。
- テストの抜けを見つけやすい
- テストケースを追加しやすい
- パッと見てわかりやすい
テストテーブルを見ただけでどんなテストケースがあるのかが一目でわかるため、この手法が結構好きです。
公式wiki
ユーザーエンティティにロジックを実装する
値オブジェクトが実装できたので、それを利用しつつ、エンティティにも同じようにファクトリ関数、そして振る舞いを持たせます。
user.go の実装
// app/domain/user/user.go
package user
import (
"github.com/kakkky/pkg/ulid"
)
type User struct {
id string
email Email
name string
hashedPassword HashedPassword
}
// ファクトリー関数
func NewUser(
email string,
name string,
password string,
) (*User, error) {
validatedEmail, err := NewEmail(email)
if err != nil {
return nil, err
}
HashedPassword, err := newHashedPassword(password)
if err != nil {
return nil, err
}
return &User{
id: ulid.NewUlid(),
email: validatedEmail,
name: name,
hashedPassword: HashedPassword,
}, nil
}
// 既存のユーザーを返す
// インスタンスの再構成
func ReconstructUser(
id string,
email string,
name string,
hashedPassword string,
) *User {
return &User{
id: id,
email: reconstructEmail(email),
name: name,
hashedPassword: reconstructHashedPassword(hashedPassword),
}
}
// ユーザーオブジェクト更新
func (u *User) UpdateUser(
email string,
name string,
) (*User, error) {
validatedEmail, err := NewEmail(email)
if err != nil {
return nil, err
}
return &User{
id: u.id,
email: validatedEmail,
name: name,
hashedPassword: u.hashedPassword,
}, nil
}
// 値のゲッターメソッド
func (u *User) GetID() string {
return u.id
}
func (u *User) GetName() string {
return u.name
}
func (u *User) GetEmail() Email {
return u.email
}
func (u *User) GetHashedPassword() HashedPassword {
return u.hashedPassword
}
// パスワードを比較する
func (u *User) ComparePassword(plainPassword string) error {
return u.hashedPassword.compare(plainPassword)
}
テストコード
// app/domain/user/user_test.go
package user
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/kakkky/app/domain/errors"
)
func TestNewUser(t *testing.T) {
t.Parallel()
type args struct {
name string
email string
password string
}
tests := []struct {
name string
args args
want *User
errType error
wantErr bool
}{
{
name: "正常系",
args: args{
name: "test",
email: "[email protected]",
password: "password",
},
want: &User{
email: Email{value: "[email protected]"},
name: "test",
},
wantErr: false,
},
{
name: "準正常系:emailアドレスが不正",
args: args{
email: "test.com",
},
errType: errors.ErrInvalidEmail,
wantErr: true,
},
{
name: "準正常系:パスワードが短い(4文字)",
args: args{
password: "test",
},
errType: errors.ErrPasswordTooShort,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewUser(tt.args.email, tt.args.name, tt.args.password)
if (err != nil) != tt.wantErr && errors.Is(err, tt.errType) {
t.Fatalf("NewUser() error=%v,but wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(User{}, Email{}, HashedPassword{}), cmpopts.IgnoreFields(User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("NewProduct() -got,+want :%v ", diff)
}
})
}
}
func TestUser_UpdateUser(t *testing.T) {
user, _ := NewUser(
"[email protected]",
"testuser",
"password",
)
t.Parallel()
type args struct {
email string
name string
}
tests := []struct {
name string
args args
want *User
errType error
wantErr bool
}{
{
name: "正常系",
args: args{
email: "[email protected]",
name: "test",
},
want: &User{
id: "1",
email: Email{value: "[email protected]"},
name: "test",
hashedPassword: HashedPassword{value: "hashedPassword"},
},
wantErr: false,
},
{
name: "準正常系:emailアドレスが不正",
args: args{
email: "invalid-email",
name: "test",
},
errType: errors.ErrInvalidEmail,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := user.UpdateUser(tt.args.email, tt.args.name)
if (err != nil) != tt.wantErr {
t.Fatalf("UpdateUser() error=%v, wantErr=%v", err, tt.wantErr)
}
if err != nil && !errors.Is(err, tt.errType) {
t.Fatalf("UpdateUser() error type=%v, wantErr type=%v", err, tt.errType)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(User{}, Email{}, HashedPassword{}), cmpopts.IgnoreFields(User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("UpdateUser() -got,+want :%v", diff)
}
})
}
}
func TestCompairePassword(t *testing.T) {
t.Parallel()
// パスワードを持ったユーザーを用意
user, _ := NewUser(
"[email protected]",
"testuser",
"password",
)
tests := []struct {
name string
plain string
wantErr bool
}{
{
name: "正常形:パスワードの比較で成功する",
plain: "password",
wantErr: false,
},
{
name: "準正常形:パスワードの比較で成功する",
plain: "incorrect",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := user.ComparePassword(tt.plain)
if (err != nil) != tt.wantErr && errors.Is(err, errors.ErrPasswordMismatch) {
t.Errorf("ComparePassword() = %v,want %v", err, tt.wantErr)
}
})
}
}
ポイントは、
- ulid生成を
pkgモジュールで実装している - ファクトリ関数はインスタンスを参照型を返している
- 集約ルートに則り、
HashedPasswordの振る舞いであるパスワード比較ロジックcompaire()メソッドをUserが操作している - インスタンスを更新する振る舞いが実装されている
です。それぞれ説明していきます。
ulid生成をpkgモジュールで実装している
Userエンティティの識別子としてULIDを採用しています。
ULIDの生成ははドメイン知識を持たない汎用的な処理のため、pkgモジュールとして切り出しましょう。
pkg/ulid の実装
// pkg/ulid/ulid.go
package ulid
import "github.com/oklog/ulid/v2"
func NewUlid() string {
return ulid.Make().String()
}
func IsValid(id string) bool {
_, err := ulid.Parse(id)
return err == nil
}
// pkg/ulid/ulid_test.go
package ulid
import "testing"
func TestIsValid(t *testing.T) {
type args struct {
s string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "正常系",
args: args{
s: NewUlid(),
},
want: true,
},
{
name: "正常系: 空文字",
args: args{
s: "",
},
want: false,
},
{
name: "正常系: 32文字",
args: args{
s: "01234567890123456789012345678901",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValid(tt.args.s); got != tt.want {
t.Errorf("IsValid() = %v, want %v", got, tt.want)
}
})
}
}
ファクトリ関数はインスタンスを参照型を返している
値オブジェクトのファクトリ関数は、インスタンスを値型で返していました。それは、値オブジェクトが不変であったからです。先述しましたが、エンティティはそれとは反対に可変であるという性質を持ちます。属性が変わったとしても、識別子による同一性により区別できるからですね。なので値型ではなく参照型として返しています。
これも、何度も繰り返し述べてしまうようですが、可変であることが許されているからといって、属性(フィールド)を外部から変更可能にするのは、思わぬ副作用が生じる可能性を孕むため避けましょう。フィールドを非公開にして、domain/userパッケージ以外から操作され得る可能性を一切排除します。
基本的には、エンティティであったとしてもインスタンスの属性を変更することはせずに、新たなインスタンスを生成して返す方針で進めていきます。
集約ルート(CompairePasswordメソッド)
そもそも「集約」という言葉について説明する必要があります。
集約とは
1つのオブジェクトは、複数のオブジェクトが集まって構築されます。「集約」は、関連するオブジェクト同士をまとめる境界線として定義されます。
集約はオブジェクトのグループの不変条件を維持し、オブジェクトの操作に秩序をもたらします。例えば、現段階ではユーザー集約が以下のようになっています。
ユーザー集約の内部には、User、HashedPassword、Emailがあります。集約内部のオブジェクトは外部から操作されてはいけません。
そこで、集約ルートと呼ばれるオブジェクトに集約内部のオブジェクト操作を限定します。今回であれば、Userが集約ルートです。EmailやHashedPasswordを操作するのはUserのみです。非常に納得できる例えがあります。
たとえば車を運転するときタイヤに対して直接命令しないのと同じように、オブジェクトのフィールドに直接命令をするのではなく、それを保持するオブジェクトに対して命令を行い、フィールドは保持しているオブジェクト自身が管理すべきだということです。
成瀬 允宣. ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 (p.408). 株式会社翔泳社. Kindle 版.
この考えは、デメテルの法則としても知られています。
今回の実装の話に戻ります。以下の実装では、メソッドを非公開として、User集約ルートからしか操作されないということの意思表示を行なっていました。パスワードを比較する際には、それを有しているUserオブジェクトに命令するべきなのです。
// app/domain/user/user_hashed_password.go
// ハッシュ化されたパスワードと比較
// 集約ルートUserから呼び出す
func (p HashedPassword) compare(target string) error {
if err := hash.Compare(p.value, target); err != nil {
return errors.ErrPasswordMismatch
}
return nil
}
// app/domain/user/user.go
// パスワードを比較する
func (u *User) ComparePassword(plainPassword string) error {
// UserがHashedPasswordを操作している
return u.hashedPassword.compare(plainPassword)
}
値オブジェクトにおけるメソッドの公開/非公開による意思表示
HashedPasswordのファクトリ関数は非公開です。再構成用関数も非公開です。Emailも再構成用関数は非公開です。
このように、値を「操作する」処理はUserから呼び出すように(外部から操作されないように)コードで意思表示を行なっています。
値オブジェクトからプリミティブな値を取得するValueメソッドは、単なる「取得」なのでメソッドを公開しています。以下のような使われ方をします。
{
// 省略
// uはUserオブジェクト
Email: u.GetEmail().Value(),
}
また、NewEmail()は公開されているじゃないか!という話にもなるんですが、これは実装を進める中で仕方なかった(小声)とでも言いましょうか。次のログインユースケースの実装を見てください。
func (lu *LoginUsecase) Run(ctx context.Context, input LoginUsecaseInputDTO) (
*LoginUsecaseOutputDTO, error,
) {
// emailの検証
email, err := user.NewEmail(input.Email)
if err != nil {
return nil, err
}
// ユーザー検索
u, err := lu.userRepository.FindByEmail(ctx, email)
if err != nil {
return nil, err
}
// 省略
ログインの際には、値としてemailとpasswordを要求します。とても一般的です。
この情報だけではUserオブジェクトを作成できませんので、User集約ルートにEmailを操作させることは難しそうです。そのため、ログインという文脈においては、Emailオブジェクトに一人歩きさせています。ログインするまでUserは決定しないので、Emailオブジェクトはまだユーザー集約として囲われていないということにしておきました。
インスタンスを更新する振る舞い(Updateメソッド)
ユーザー情報を更新するのはドメインルールです。emailとnameを受け取って、emailにはNewEmail()ファクトリ関数を実行することでバリデーションをかけています。
// ユーザーオブジェクト更新
func (u *User) UpdateUser(
email string,
name string,
) (*User, error) {
validatedEmail, err := NewEmail(email)
if err != nil {
return nil, err
}
return &User{
id: u.id,
email: validatedEmail,
name: name,
hashedPassword: u.hashedPassword,
}, nil
}
また、returnで新たなUserインスタンスを返していることに注目してください。インスタンスは使いまわさない設計をするのが安全です。以下に示す例のように、変更しようと思っていなかったインスタンスにも変更の影響が及ぶ可能性があるからです。
インスタンスを使い回す危険なコード
func (u *User) UpdateUser(email string, name string) error {
validatedEmail, err := NewEmail(email)
if err != nil {
return err
}
u.email = validatedEmail // 元のインスタンスを直接変更
u.name = name
return nil
}
func main() {
// 元のインスタンス
user := &User{id: "123", email: MustNewEmail("[email protected]"), name: "John"}
// 同じインスタンスを参照
anotherRef := user
// userの属性を更新
_ = user.UpdateUser("[email protected]", "Jane")
// 元のインスタンスが直接変更され、意図せず影響が出る
fmt.Println(user.email.value) // "[email protected]"
fmt.Println(anotherRef.email.value) // "[email protected]"(意図せず変更)
}
テスト項目における正常系と準正常系
テスト項目を、正常系/準正常系/異常系と分けて考えています。
正常系
期待される入力が与えられ、メソッドが正しく動作することを確認します。
準正常系
不完全・不正な入力が与えられた場合でも、適切に動作することを確認します。
異常系
仕様で想定されていない状況や状態でシステムがどう振る舞うかを確認します。
正常系のテストはわかりやすいです。正しい値を入れて正しく返す、ごく一般の想定した動作フローを考えればいいからです。テストを簡単に分けるなら、「正常系/異常系」の2種に分けられます。正しい状況と正しくない状況を想定するのですが、後者の正しくない状況はもう少し細分化できます。それは、
-
システムが想定している範囲内の不正な状況 本章の一番最後で取り上げているドメインエラーとして、こちら側が想定している不正な入力値の場合を検証します。例えば、「メールアドレスの形式が不正だ」などがそうです。
-
システムが想定していない不正な状況 ネットワーク系のエラーが起きた時や、関数内部における処理が失敗したときなどがそれにあたります。何らかのエラーでレシーバに
nilが入ってしまった(nil pointer deference、いわゆる「ぬるぽ」)場合などもそうです。システムがクラッシュしないかどうかを確かめます。
の2つです。
そこで、今回は主に「正常系」と「準正常系」をテスト項目としてテストを行なっていきます。異常系のテストはしづらいと考えたので、今回は取り扱わないようにしました。
参考記事
ユーザードメインオブジェクトの最後に、users.goを作成しておいてください。これは単に*UserスライスにエイリアスとしてUsersを定義しているだけです。この後のユースケースの実装では、複数の*Userを扱います。単に*Userスライスとして扱っても問題ないのですが、例えば複数の*Userを操作したくなった時には、Usersにメソッドを定義できるなど、拡張性に関してメリットがあると考えています。
// app/domain/user/users.go
package user
type Users []*User
タスクオブジェクトを実装する
ユーザーオブジェクトと同じようにして、エンティティ・値オブジェクトを実装していきましょう。似通った実装となりますので、タスクオブジェクトに特有の実装をしている箇所のみ説明を行います。なお、コードはすべて載せています。
エンティティ・ドメインロジック
task.go の実装
// task.go
package task
import (
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/pkg/ulid"
)
type Task struct {
id string
userId string
content Content
state State
}
func NewTask(
userId string,
content string,
state string,
) (*Task, error) {
validatedContent, err := newContent(content)
if err != nil {
return nil, err
}
validatedState, err := newState(state)
if err != nil {
return nil, err
}
return &Task{
id: ulid.NewUlid(),
userId: userId,
content: validatedContent,
state: validatedState,
}, nil
}
// リポジトリから使用
func ReconstructTask(
id string,
userId string,
content string,
state int, //DBにはint型でタスク状態を保存している
) *Task {
return &Task{
id: id,
userId: userId,
content: reconstructContent(content),
state: reconstructState(state),
}
}
func (t *Task) UpdateState(
state string,
) (*Task, error) {
validatedState, err := newState(state)
if err != nil {
return nil, err
}
return &Task{
id: t.id,
userId: t.userId,
content: t.content,
state: validatedState,
}, nil
}
func (t *Task) IsOperableBy(userId string) error {
if t.userId != userId {
return errors.ErrForbiddenTaskOperation
}
return nil
}
func (t *Task) GetID() string {
return t.id
}
func (t *Task) GetUserId() string {
return t.userId
}
func (t *Task) GetContent() Content {
return t.content
}
func (t *Task) GetState() State {
return t.state
}
テストコード
// task_test.go
package task
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/kakkky/app/domain/errors"
)
func TestNewTask(t *testing.T) {
t.Parallel()
type args struct {
userId string
content string
state string
}
tests := []struct {
name string
args args
want *Task
errType error
wantErr bool
}{
{
name: "正常系",
args: args{
userId: "1",
content: "This is a task",
state: "todo",
},
want: &Task{
userId: "1",
content: Content{value: "This is a task"},
state: Todo,
},
wantErr: false,
},
{
name: "準正常系:contentが空",
args: args{
content: "",
state: "todo",
},
errType: errors.ErrContentEmpty,
wantErr: true,
},
{
name: "準正常系:stateが不正",
args: args{
content: "This is a task",
state: "invalid",
},
errType: errors.ErrInvalidTaskState,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewTask(tt.args.userId, tt.args.content, tt.args.state)
if (err != nil) != tt.wantErr && errors.Is(err, tt.errType) {
t.Fatalf("NewTask() error=%v, but wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreFields(Task{}, "id"), cmp.AllowUnexported(Task{}, Content{})); diff != "" {
t.Errorf("NewTask() -got,+want :%v", diff)
}
})
}
}
func TestTask_UpdateState(t *testing.T) {
// 初期タスクを作成
task, _ := NewTask("1", "This is a task", "todo")
t.Parallel()
type args struct {
state string
}
tests := []struct {
name string
args args
want *Task
errType error
wantErr bool
}{
{
name: "正常系",
args: args{
state: "done",
},
want: &Task{
id: task.id,
userId: task.userId,
content: task.content,
state: Done,
},
wantErr: false,
},
{
name: "準正常系:不正なstate",
args: args{
state: "invalid",
},
errType: errors.ErrInvalidTaskState,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := task.UpdateState(tt.args.state)
if (err != nil) != tt.wantErr && errors.Is(err, tt.errType) {
t.Fatalf("UpdateState() error=%v, wantErr=%v", err, tt.wantErr)
}
if err != nil && !errors.Is(err, tt.errType) {
t.Fatalf("UpdateState() error type=%v, wantErr type=%v", err, tt.errType)
}
if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreFields(Task{}, "id"), cmp.AllowUnexported(Task{}, Content{})); diff != "" {
t.Errorf("UpdateState() -got,+want :%v", diff)
}
})
}
}
ドメインモデルに基づいて、エンティティを記述しています。値オブジェクトをContent(内容)、State(タスク状態)としています。再構成用関数があったり、ファクトリ関数があったり概ねUserと変わりないのですが、以下のポイントに注目します。
UpdateTaskState()メソッドに見られるタスク集約ルートIsOperatedBy()メソッドに見られる集約間のルール
UpdateTaskState()メソッド
以下のメソッドについてです。タスク状態のみを更新するためのものです。
func (t *Task) UpdateState(
state string,
) (*Task, error) {
validatedState, err := newState(state)
if err != nil {
return nil, err
}
return &Task{
id: t.id,
userId: t.userId,
content: t.content,
state: validatedState,
}, nil
}
タスク集約は以下のようになっています。
Content(タスク状態)のみを更新するとはいえ、Contentを操作していいのは、集約ルートであるTaskオブジェクトのみです。Userオブジェクトに定義されたUpdateUserと同じですね。また、インスタンスを新たに生成して返しているのも同じです。
IsOperableByメソッド
ユーザーは、幾つものタスクを持っており、タスクに対する操作(タスク削除、タスク状態更新)を行います。そして、何人ものユーザーがシステムに登録可能となっています。あるタスクはあるユーザーに紐づくものであり、紐づいているユーザー以外に操作されてはいけません。
func (t *Task) IsOperableBy(userId string) error {
if t.userId != userId {
return errors.ErrForbiddenTaskOperation
}
return nil
}
これは、認証のような技術的要件とは違い、現実世界でも同じことがいえます。他人に自分のtodoリスト(メモなど)をなぜか書き換えられたり打ち消し線を引かれるのは恐怖です。
従って、このルールはドメインルールであるとみなしました。
そうなれば、そのようなドメインルールをメソッドとしてUserとTaskのどちらに実装すればいいのか??という話になってくるかと思います。
「ユーザーがこのタスクを持っているのか」を検証するものだからUserにIsHaveTask()メソッドを実装する??といったことが考えられます。
しかし、タスク集約に関する操作・判定は、集約ルートであるTaskオブジェクトが担うべきです。つまり、この設計だと集約のルールに違反していると言えます。

従って、「このタスクはユーザーによって操作が可能か?」と言い方を見直して、TaskオブジェクトにIsOperatedBy(user_id string)というふうにメソッドを追加しました。
値オブジェクト
値オブジェクト(タスク集約)の実装
タスク内容のコード
// domain/task/task_content.go
package task
import (
"unicode/utf8"
"github.com/kakkky/app/domain/errors"
)
type Content struct {
value string
}
func newContent(value string) (Content, error) {
if utf8.RuneCountInString(value) == 0 {
return Content{}, errors.ErrContentEmpty
}
return Content{value: value}, nil
}
func reconstructContent(value string) Content {
return Content{value: value}
}
func (c Content) Value() string {
return c.value
}
タスク状態のコード
// domain/task/task_state.go
package task
import "github.com/kakkky/app/domain/errors"
type State int
const (
Todo State = iota
Doing
Done
)
func newState(value string) (State, error) {
switch value {
case "todo":
return Todo, nil
case "doing":
return Doing, nil
case "done":
return Done, nil
default:
return 0, errors.ErrInvalidTaskState
}
}
func reconstructState(value int) State {
switch value {
case 1:
return Doing
case 2:
return Done
default:
return Todo
}
}
func (s State) StrValue() string {
switch s {
case Doing:
return "doing"
case Done:
return "done"
default:
return "todo"
}
}
func (s State) IntValue() int {
return int(s)
}
ポイントは、タスク状態をenum的に表現している点です。
タスク状態のenum的表現
enumとは列挙型のことであり、数値にたいしてプログラムで扱う別名を与えるものです。
タスク状態は、バリエーションが未完了/進行中/完了の3つです。これなら、データベースに"todo","doing","done"と文字列で格納するよりも、数値で扱う方が効率的です。
golangには、enumが用意されていませんが、同様の表現をiotaを使って表現できます。「enum的」と表現したのはこれが理由です。
const (
Todo State = iota // 0
Doing // 1
Done // 2
)
タスク状態を数値として扱うのはデータベースへのI/O付近です。しかし、エンドポイントにリクエストを送る際はJSONで数値を送るとなるととてもわかりにくいですね。レスポンスとしてタスク状態は0だよ!という風に返ってきてもなんのこっちゃですね。エンドポイントにおけるI/Oでは、文字列で扱った方が良さそうです。
つまり、リポジトリでは数値として扱う必要があり、プレゼンテーションにDTOを渡すユースケースでは文字列で扱う必要がありそうです。
そのために、数値を返すIntValue()メソッドと文字列で返すStrValueメソッドの両方を用意しています。
同じように、ファクトリ関数と再構成用関数も見ていきます。newState()のファクトリ関数は、インスタンスの「作成」のために使われるので、プレゼンテーションからDTOを受け取るユースケースにて使用されます。
つまりnewState()の入力値は文字列となります。そのため、文字列→State型(数値)を変換して返すような実装になっています。
再構成用関数であるreconstructState()は、 リポジトリ、つまりデータベースからとってきた値が入力値となります。引数には数値が入るわけですね。そのため、数値型→State型(数値)への変換を行っています。
func newState(value string) (State, error) {
switch value {
case "todo":
return Todo, nil
case "doing":
return Doing, nil
case "done":
return Done, nil
default:
return 0, errors.ErrInvalidTaskState
}
}
func reconstructState(value int) State {
switch value {
case 1:
return Doing
case 2:
return Done
default:
return Todo
}
}
ドメインサービス
ドメインオブジェクトはこれで実装が終わりました。
しかし、まだ実装できていないドメインルールがあります。それは、Userの仕様である、
- 登録・更新の際)同じメールアドレスを持つユーザーが重複してはいけないです。
これは、ユーザー登録の際などに「指定のメールアドレスを持つユーザーがいるのかどうか」を判定できれば実現できます。これをロジックとしてもつメソッドを実装するべきです。
これをユーザーオブジェクトに実装しなかったのには理由があります。それを考えるには、一度、Userに「指定のメールアドレスを持つユーザーがいるのかどうか」を判定するメソッドを追加してみます。
func (u *User)IsExists(ctx context.Context , email Email)bool{
// 省略
}
これを、実際に利用するユーザー登録のユースケースを考えてみます。
func (ru *RegisterUsecase) Run(ctx context.Context, input RegisterUsecaseInputDTO) (*RegisterUsecaseOutputDTO, error) {
// userインスタンスを生成
u, err := user.NewUser(
input.Email,
input.Name,
input.Password,
)
if err != nil {
return nil, err
}
//userがすでに登録済みでないか?
//ここでメソッドを使用する
ok, err := u.IsExists(ctx, u.GetEmail())
if err != nil {
return nil, err
}
// 省略
}
Userインスタンスをレシーバとして、IsExistsを使用していますね。
...なんか違和感に感じませんか。これだと、例えば[email protected]のメールアドレスを持つユーザーに対して「[email protected]を持つユーザーはシステムに存在していますか」と聞いていることになります。
このように、ドメインオブジェクトに実装してしまうと不自然なロジックは、ドメインサービスとして実装します。
ユーザードメインサービスを実装する
user_domain_service.goの実装
ドメインサービスにもインターフェースを定義しておきます。
// interface_user_domain_service.go
package user
import "context"
//go:generate mockgen -package=user -source=./interface_user_domain_service.go -destination=./mock_user_domain_service.go
type UserDomainService interface {
IsExists(ctx context.Context, email Email) (bool, error)
}
// app/domain/user/user_domain_service.go
package user
import (
"context"
"github.com/kakkky/app/domain/errors"
)
type userDomainService struct {
userRepository UserRepository
}
func NewUserDomainService(userRepository UserRepository) UserDomainService {
return &userDomainService{
userRepository: userRepository,
}
}
func (uds userDomainService) IsExists(ctx context.Context, email Email) (bool, error) {
user, err := uds.userRepository.FindByEmail(ctx, email)
// ユーザーが見つからない場合はエラーではない
if err != nil && errors.Is(err, errors.ErrNotFoundUser) {
return false, nil
}
// それ以外ならエラーとして返す
if err != nil {
return false, err
}
return user != nil, nil
}
テストコード
// app/domain/user/user_domain_service_test.go
package user
import (
"context"
"testing"
"github.com/kakkky/app/domain/errors"
gomock "go.uber.org/mock/gomock"
)
func TestUserDomainService_IsExists(t *testing.T) {
t.Parallel()
tests := []struct {
name string
email Email
mockFn func(m *MockUserRepository)
want bool
wantErr bool
}{
{
name: "正常系: ユーザー存在する",
email: Email{value: "[email protected]"},
mockFn: func(m *MockUserRepository) {
// ErrNotFoundUserを返す
m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(&User{}, nil)
},
want: true,
wantErr: false,
},
{
name: "準正常系: ユーザーが存在しない",
email: Email{value: "[email protected]"},
mockFn: func(m *MockUserRepository) {
m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(nil, errors.ErrNotFoundUser)
},
want: false,
wantErr: false,
},
{
name: "異常系:リポジトリからErrNotFoundUser以外のエラーが返る場合",
email: Email{value: "[email protected]"},
mockFn: func(m *MockUserRepository) {
m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(nil, errors.New("Database error"))
},
want: false,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockUserRepository := NewMockUserRepository(ctrl)
// モック呼び出しの設定
tt.mockFn(mockUserRepository)
// ドメインサービスを作成
sut := NewUserDomainService(mockUserRepository)
ctx := context.Background()
got, err := sut.IsExists(ctx, tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("userDomainService.IsExists() want error %v,but got %v : %v", tt.wantErr, got, err)
}
if got != tt.want {
t.Errorf("userDomainService.IsExists()= %v,but want %v", got, tt.want)
}
})
}
}
ドメインサービスを一つのオブジェクトとして扱い、userDomainServiceオブジェクトを用意しています。そして、userDomainServiceオブジェクトのメソッドとしてIsExists()を実装しています。これなら自然ですね。ポイントは、
- リポジトリのインターフェースを利用していること
- テストにリポジトリのモックを使用していること
です。
リポジトリのインターフェースに依存させる
IsExists()のロジックを見てみると、ドメインオブジェクトではみられない実装になっていますね。
「指定のメールアドレスを持つユーザーの存在を確かめる」にはデータベースとのやりとりが必須となります。ということは、userDomainServiceはリポジトリを利用する必要があるのです。
リポジトリは、インターフェースアダプター層にあるコンポーネントです。何も考えずにUserDomainServiceがリポジトリの具体実装を利用してしまうと、依存性の方向が外側を向いてしまいます。
そこで、依存関係逆転の原則でインターフェースを利用しましょう。
UserRepository インターフェースを定義します。
// app/domain/user/interface_user_repository.go
package user
import "context"
//go:generate mockgen -package=user -source=./interface_user_repository.go -destination=./mock_user_repository.go
type UserRepository interface {
FindByEmail(ctx context.Context, email Email) (*User, error)
}
インターフェースには、メールアドレスをキーとしてusersテーブルからレコードを検索し、Userオブジェクトを返すFindByEmail()メソッドを定義しています。
モックを用いた単体テスト
モックを用いたテストの解説は次回に回しますが、このままではテストが通らないので、以下の手順だけ踏んでおいてください。
パッケージをインストール
以下のようにコマンドを打ってください
make get-app name="go.uber.org/mock/gomock"
go-gen
ファイルが作成されるはずです。
説明は次章に譲ります!!(再喝) わからなくてもそのまま次に進んでください。
ドメインエラー
これは一種のエラーハンドリングだと思ってみていただければと思います。
ドメインエラーの必要性
エラーは大きく2種類に分断できると思います。
- クライアント起因のエラー
- システム側のエラー
これは、ステータスコードの仕様からもわかります。エラーは400番台と500番台の二つがありますよね。 400番台がクライアント起因のエラーであり、500番台がシステム側のエラーです。
クライアント起因のエラーとは、入力値などの間違いで「ビジネスロジック(=ドメインロジック、ドメインルール)を成り立たせることができなかった」というエラーです。
こういったエラーは主にドメイン層で発生させるべきものなので、ここではドメインエラーとして扱います。
APIは、エラーが起きたとして、それが「ドメインエラー」なのか「それ以外のシステム側のエラー」なのかを伝える必要があります。

「どうやってそれを伝えるか」はプレゼンテーション層を取り扱う9章でまた触れます。
そして、発生したエラーが2つのうちどちらなのかを伝えるにはプレゼンテーション(「伝える」役目を持つ)がエラーを判別できる必要があります。
そこで、ここでは、「独自のエラー型」を定義してエラーを型で判別できるようにしましょう。
独自エラー型
golangでは、errorインタフェースが定義されており、これを満たせば独自にエラーを定義できます。
type error interface {
Error() string
}
errorsパッケージ
この後たくさん使っていくパッケージになりますのでここで取り上げておきます。errorsパッケージは、エラーを扱う上で便利な関数を提供してくれる標準のパッケージです。
本ハンズオンでも使用する便利な関数を取り上げておきます。
エラー型オブジェクトを作成する
errors.New(text string)は、テキストメッセージを含むエラー型のオブジェクトを作成し返します。
package main
import (
"errors"
"fmt"
)
// カスタムエラーを生成する関数
func emitError() error {
return errors.New("emit macho dwarf: elf header corrupted")
}
func main() {
err := emitError()
if err != nil {
fmt.Println(err) // 出力: emit macho dwarf: elf header corrupted
}
}
エラー型オブジェクトのインスタンスを比較する
errors.Is(err,target error)は、第一引数と第二引数のインスタンスが一致しているかどうかの真偽値を返します。エラーの値を比較していると考えるとわかりやすいです。
package main
import (
"errors"
"fmt"
)
// カスタムエラー
var customError = errors.New("emit macho dwarf: elf header corrupted")
// カスタムエラーを生成する関数
func emitError() error {
return customError
}
func main() {
err := emitError()
// エラーの値を比較
if errors.Is(err, customError) {
fmt.Println("The error matches the customError")
} else {
fmt.Println("The error does not match")
}
// 出力: The error matches the customError
}
エラー型オブジェクトのインスタンスの型を比較する
errors.As(err error, target any) は、第一引数に渡されたエラー型オブジェクトに対して、第二引数の型で「型アサーション(型変換が可能かどうかを確認)」を行い、型の一致をチェックします。結果として、真偽値を返します。厳密には「型を比較している」という表現は少し誤解を招くかもしれませんが、直感的に扱うならこのように理解するのが良いと思います。
// fsパッケージにて定義されているエラー型
type PathError struct {
Op string
Path string
Err error
}
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
if _, err := os.Open("non-existing"); err != nil {
// 期待するエラー型
var pathError *fs.PathError
if errors.As(err, &pathError) {
fmt.Println("Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}
}
//出力
//Failed at path: non-existing
簡単に書いてしまえば、2つのメソッドは以下のように整理できます。
errors.Is()はエラーの値そのもの(インスタンス)を比較errors.As()はエラーの型を比較
参考
ドメインエラーを実装
app/domain配下に/errorsディレクトリを作成してください。
domain/errors の実装
// app/domain/errors/errors.go
package errors
import (
"errors"
"fmt"
)
// ユーザー関連のドメインエラー
var (
ErrAlreadyRegisterd = newErrDomain("ErrAlreadyRegisterd", "you have been already registerd")
ErrInvalidEmail = newErrDomain("ErrInvalidEmail", "invalid email address")
ErrPasswordMismatch = newErrDomain("ErrPasswordMismatch", "the password is incorrect")
ErrPasswordTooShort = newErrDomain("ErrPasswordTooShort", "password is too short")
ErrNotFoundUser = newErrDomain("ErrNotFoundUser", "user not found")
)
// タスク関係のドメインエラー
var (
ErrNotFoundTask = newErrDomain("ErrNotFoundTask", "task not found")
ErrContentEmpty = newErrDomain("ErrContentEmpty", "Do not empty the content")
ErrInvalidTaskState = newErrDomain("ErrInvalidTaskState", "invalid task state , please select todo/doing/done")
ErrForbiddenTaskOperation = newErrDomain("ErrForbiddenTaskOperation", "can't operate others tasks")
)
// ドメインエラー
type ErrDomain struct {
err error
}
// ドメインエラーのコンストラクタ
func newErrDomain(errType, message string) *ErrDomain {
return &ErrDomain{
err: fmt.Errorf("errType : %v , Message : %v", errType, message),
}
}
// ドメインエラーかどうか
func IsDomainErr(target error) bool {
var errDomain *ErrDomain
return errors.As(target, &errDomain)
}
// errorインターフェースを満たすため
func (e *ErrDomain) Error() string {
return e.err.Error()
}
// errors.Isをラップ
// パッケージ名の衝突を考慮
func Is(err, target error) bool {
return errors.Is(err, target)
}
func New(message string) error {
return errors.New(message)
}
テストコード
// app/domain/errors/errors_test.go
package errors
import "testing"
func TestErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fn func() error
err *ErrDomain
want bool
}{
{
name: "ErrDomain型としても、個別のエラー型としても判別できる",
fn: func() error {
return ErrNotFoundUser
},
err: ErrNotFoundUser,
want: true,
},
{
name: "異なるエラー型オブジェクトは判別しない",
fn: func() error {
return New("other err obj")
},
err: ErrNotFoundUser,
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.fn()
if err != nil && Is(err, tt.err) != tt.want {
t.Errorf("Is() want %v,but got%v", tt.want, Is(err, tt.err))
}
if IsDomainErr(err) != tt.want {
t.Errorf("IsDomainErr() want %v,but got%v", tt.want, IsDomainErr(err))
}
})
}
}
ポイントは以下の通りです。
- 独自エラー型としてドメインエラーを定義している
- エラー型オブジェクトのインスタンス(エラーの値)をパッケージ変数として定義している
errors.As()でErrDomain型かどうかを判別errorsのメソッドをラップしてパッケージの衝突を回避
それぞれ説明していきます。
独自エラー型としてドメインエラーを定義している
独自エラー型の作り方は先述しました。構造体ErrDomainの中にerrフィールドを持たせています。
// ドメインエラー
type ErrDomain struct {
err error
}
//エラーインターフェースを満たす
func (e *ErrDomain) Error() string {
return e.err.Error()
}
エラーの値をパッケージ変数として定義
このやり方は、sqlパッケージでもみられます。
// ErrNoRows is returned by [Row.Scan] when [DB.QueryRow] doesn't return a
// row. In such a case, QueryRow returns a placeholder [*Row] value that
// defers this error until a Scan.
var ErrNoRows = errors.New("sql: no rows in result set")
実装では、以下のようにユーザー関連、またはタスク関連に分けてエラーの値を一元管理しています。まだ使用していないエラー値もありますが、ここで全て記載しておきます。
// ユーザー関連のドメインエラー
var (
ErrAlreadyRegisterd = newErrDomain("ErrAlreadyRegisterd", "you have been already registerd")
ErrInvalidEmail = newErrDomain("ErrInvalidEmail", "invalid email address")
ErrPasswordMismatch = newErrDomain("ErrPasswordMismatch", "the password is incorrect")
ErrPasswordTooShort = newErrDomain("ErrPasswordTooShort", "password is too short")
ErrNotFoundUser = newErrDomain("ErrNotFoundUser", "user not found")
)
// タスク関係のドメインエラー
var (
ErrNotFoundTask = newErrDomain("ErrNotFoundTask", "task not found")
ErrContentEmpty = newErrDomain("ErrContentEmpty", "Do not empty the content")
ErrInvalidTaskState = newErrDomain("ErrInvalidTaskState", "invalid task state , please select todo/doing/done")
ErrForbiddenTaskOperation = newErrDomain("ErrForbiddenTaskOperation", "can't operate others tasks")
)
値を全てErrDomain型とするために、ファクトリ関数を定義しています。
// ドメインエラーのファクトリ関数
func newErrDomain(errType, message string) *ErrDomain {
return &ErrDomain{
err: fmt.Errorf("errType : %v , Message : %v", errType, message),
}
}
Err-から始まるエラー値をエラータイプとして表し、エラーメッセージから一目でどんなエラーかを認識できるようにしました。
errors.As()でErrDomain型かどうかを判別する
以下の関数では、内部でerrors.As()を使用しています。
// ドメインエラーかどうか
func IsDomainErr(target error) bool {
var errDomain *ErrDomain
return errors.As(target, &errDomain)
}
ドキュメントの例に倣って実装しました。
この関数は主にプレゼンテーションで扱い、エラーがErrDomainかどうかでステータスコードが400番台になるか500番台になるかを決定できるようにします。
errorsパッケージの関数をラップする
errors.New()とerrors.Is()は、普通に使用していきたいのですが、domain/errorsパッケージとしているせいで、errorsパッケージと衝突する可能性があります。
import (
"errors"
"github.com/kakkky/app/domain/errors" //errorsというパッケージ名が衝突
)
プロジェクトにおいてエラーを扱うパッケージをgithub.com/kakkky/app/domain/errorsに集約して管理するために、errors.Is()とerrors.New()を実装しました。内部でerrorsパッケージを使っているだけ(つまりラップしているだけ)です。
// パッケージ名の衝突を考慮して関数をラップしている
func Is(err, target error) bool {
return errors.Is(err, target)
}
func New(message string) error {
return errors.New(message)
}
参考
現段階のディレクトリ
.
├── .github
│ └── workflows
│ └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│ ├── .air.toml
│ ├── domain
│ │ ├── errors
│ │ │ ├── errors.go
│ │ │ └── errors_test.go
│ │ ├── task
│ │ │ ├── task.go
│ │ │ ├── task_content.go
│ │ │ ├── task_state.go
│ │ │ └── task_test.go
│ │ └── user
│ │ ├── interface_user_domain_service.go
│ │ ├── interface_user_repository.go
│ │ ├── mock_user_repository.go
│ │ ├── user.go
│ │ ├── user_domain_service.go
│ │ ├── user_domain_service_test.go
│ │ ├── user_email.go
│ │ ├── user_hashed_password.go
│ │ ├── user_test.go
│ │ └── users.go
│ ├── go.mod
│ ├── go.sum
│ ├── infrastructure
│ │ └── db
│ │ └── mysql
│ │ └── conf.d
│ │ └── my.conf
│ ├── main.go
│ └── tmp
│ ├── build-errors.log
│ └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
├── go.mod
├── go.sum
├── hash
│ ├── hash.go
│ └── hash_test.go
└── ulid
├── ulid.go
└── ulid_test.go
現時点でgit pushしてみてください。Run Unit Testsは成功しますが、Run Integration Testsは失敗します(まだ実装していないので)。CIが失敗になるのが嫌な場合は、integration-testのjobを削除してもらってかまいません。
15章になったら、またintegration-testのjobを戻しておいてください。
次の章では、アプリケーション層としてユースケースの実装をしていきます。