はじめに
- この記事では3層アーキテクチャからドメイン層を切り出した話を軸に具体的なドメイン層のメリットに関して話していきます。
目次
1.なぜそもそもドメイン層を切り出そうとしたか
2.3層アーキテクチャの問題点
3.ドメイン層とは何か
4.切り出しステップ
5.ドメイン層を実装して得られたメリットとデメリット
なぜそもそもドメイン層を切り出そうとしたか
- 理由はビジネスロジックの層が肥大化して、コードを変更する際やテストを行う際の責務の分類が難しく、手間がかかるようになっていったためです。
- 実際に下記がドメイン層を切り出す前のSignUpに関する処理のビジネスロジック層の部分です。
auth_service.go
func (s *AuthService) SignUp(email, password, verificationToken string) error {
// ── 1. 入力値のバリデーション ──
// 1-1. メールアドレスの必須チェック
if email == "" {
return fmt.Errorf("メールアドレスは必須です")
}
// 1-2. メールアドレスの形式チェック
if _, err := mail.ParseAddress(email); err != nil {
return fmt.Errorf("無効なメールアドレス形式です")
}
// 1-3. パスワードの必須チェック
if password == "" {
return fmt.Errorf("パスワードは必須です")
}
// ── 2. 既存ユーザー確認 ──
_, err := s.repository.FindUserByEmail(email)
if err == nil {
return errors.New("user already exists")
}
// ── 3. パスワードハッシュ化 ──
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
hashedPasswordStr := string(hashed)
// ── 4. ユーザーモデルの組み立て ──
user := models.User{
Email: email,
Password: &hashedPasswordStr,
IsVerified: false,
VerificationToken: &verificationToken,
VerificationExpiresAt: time.Now().Add(7 * 24 * time.Hour),
// 他のフィールドは省略…
}
// ── 5. 永続化 ──
return s.repository.CreateUser(user)
}
この状態ではビジネスロジック層の中に「入力検証/エンティティ組み立て/トークン設定/永続化」が合わさっているので、主に以下のような問題点が生じます。
- コードが多く肥大化しているので、変更が生じた際に行いにくい
- テストを行うにしても大量にユースケースを書く必要がる
3層アーキテクチャの問題点
- 先ほども軽く述べましたが、まずはなんと言ってもビジネスロジックの肥大化が挙げられます。原因としてビジネスロジック層では以下の処理を一手に担っています。
- 入力バリデーション
- トランザクション処理
- 永続化呼び出し
- この肥大化から以下のような問題点が発生します。
- 変更に弱くなる
- テストの粒度が大きくなる
- 責務が不明瞭になる
ドメイン層とは何か
- 今回はビジネスロジック層からドメイン層を切り出したわけですがそもそもドメイン層とはどんな責務を持つかを確認します。
- ドメイン層が持つ役割はドメインモデルのルールを定義する層です。ドメインモデルとは以下のようなものです。
この画像はこちらの記事から引用させていただきました。
このドメインモデルの持つ情報に対して情報のバリデーションチェックであったり、ドメインモデルの持つ情報の状態遷移を行ったりします。
切り出しステップ
- まず前提としてドメイン層は今までの3層アーキテクチャに追加して4層になるという認識ではなく、ビジネスロジックを鵜が2つに分かれると言った認識になります。
- 一番最初に提示したビジネスロジック層のSignUp関数を例に取ってどのように切り出すかを示します。まず以下のコードが切り出す前のビジネスロジック層にあるSignUp関数です。
auth_service.go
func (s *AuthService) SignUp(email, password, verificationToken string) error {
// ── 1. 入力値のバリデーション ──
// 1-1. メールアドレスの必須チェック
if email == "" {
return fmt.Errorf("メールアドレスは必須です")
}
// 1-2. メールアドレスの形式チェック
if _, err := mail.ParseAddress(email); err != nil {
return fmt.Errorf("無効なメールアドレス形式です")
}
// 1-3. パスワードの必須チェック
if password == "" {
return fmt.Errorf("パスワードは必須です")
}
// ── 2. 既存ユーザー確認 ──
_, err := s.repository.FindUserByEmail(email)
if err == nil {
return errors.New("user already exists")
}
// ── 3. パスワードハッシュ化 ──
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
hashedPasswordStr := string(hashed)
// ── 4. ユーザーモデルの組み立て ──
user := models.User{
Email: email,
Password: &hashedPasswordStr,
IsVerified: false,
VerificationToken: &verificationToken,
VerificationExpiresAt: time.Now().Add(7 * 24 * time.Hour),
// 他のフィールドは省略…
}
// ── 5. 永続化 ──
return s.repository.CreateUser(user)
}
-
ここで切り取れる部分は2箇所です。どちらもドメインモデルの持つ情報に対しての制御という点で共通しているので切り出す対象になります。
- ひとつ目は以下のバリデーションチェックです。
auth_service.go// 1-1. メールアドレスの必須チェック if email == "" { return fmt.Errorf("メールアドレスは必須です") } // 1-2. メールアドレスの形式チェック if _, err := mail.ParseAddress(email); err != nil { return fmt.Errorf("無効なメールアドレス形式です") } // 1-3. パスワードの必須チェック if password == "" { return fmt.Errorf("パスワードは必須です") } // ── 2. 既存ユーザー確認 ── _, err := s.repository.FindUserByEmail(email) if err == nil { return errors.New("user already exists") }
- もう一つはユーザーモデルの組み立てです。
auth_service.go// ── 4. ユーザーモデルの組み立て ── user := models.User{ Email: email, Password: &hashedPasswordStr, IsVerified: false, VerificationToken: &verificationToken, VerificationExpiresAt: time.Now().Add(7 * 24 * time.Hour), // 他のフィールドは省略… }
-
これらを層ごとに分けて切り出してInterfaceを介してうまく繋げてあげると以下のような2つの層が作成できます。
- 以下はビジネスロジックを扱う層です
auth_service.gofunc (s *AuthService) SignUp(email string, password string, verificationToken string) error { // ユーザーが既に存在するか確認 _, err := s.repository.FindUserByEmail(email) if err == nil { // ユーザーが存在する場合はエラーを返す return errors.New("user already exists") } // 新しいユーザーを作成 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } hashedPasswordStr := string(hashedPassword) user, err := domainUser.NewUser(email, hashedPasswordStr) if err != nil { return err } user.VerificationToken = &verificationToken user.VerificationExpiresAt = time.Now().Add(7 * 24 * time.Hour) return s.repository.CreateUser(user) }
- 以下はドメイン層です
entity.gofunc NewUser(email, password string) (*UserModel, error) { // email 空チェック & フォーマットチェック if email == "" { return nil, fmt.Errorf("メールアドレスは必須です") } if _, err := mail.ParseAddress(email); err != nil { return nil, fmt.Errorf("無効なメールアドレス形式です") } // password はすでにハッシュ化された文字列として受け取り、空文字は許さない if password == "" { return nil, fmt.Errorf("パスワードは必須です") } now := time.Now() pw := password return &UserModel{ Email: email, Password: &pw, IsVerified: false, VerificationToken: nil, VerificationExpiresAt: time.Time{}, PasswordResetToken: "", PasswordResetExpires: time.Time{}, FirstName: "", LastName: "", FirstNameKana: "", LastNameKana: "", ProfileImageURL: "", SchoolName: "", Department: "", Laboratory: "", GraduationYear: "", DesiredJobTypes: []string{}, Skills: []string{}, CreatedAt: now, UpdatedAt: now, }, nil }
これらを確認するとドメインモデルの情報の制御を担当するドメイン層とユースケースの司令塔の役割を担うビジネスロジック層の2つに綺麗に分かれていること分かると思います。
なぜドメイン層切り出しを選択したか
- そもそも3層アーキテクチャでビジネスロジック層の肥大化の解消のためであれば、今回のようなドメイン層の切り出し以外にも様々な手法があります。
- しかしその中でも今回の手法を選んだのは以下の理由があります。
- ドメインモデルの持つ情報が多かったので確実に1つの場所にドメインモデルの振る舞いを定義したかった
- ドメイン層の切り出しは外部副作用やトランザクションを一切持たないためユニットテストが非常に明快である
- そこまで規模の大きすぎない小~中規模のアプリケーションであるため
これ以外にも今回のような問題に対してのアプローチはいくつかあるので、思考ロックして今回の手法を使用し続けることなく、要件に合わせて様々な水疱を比較することが重要です。