DDD入門:コーディング編
はじめに
こんにちは、クラウドエース Backend Division 所属の秋庭です。
この記事はDDD入門:用語解説・モデリング編に続くコーディング部分の記事となっています。
対象
想定読者
DDD におけるコーディング部分についてこれから学ぼうと考えている方。
用語解説・モデリング編を既に読んでいただいた方。
記事内容
「用語解説・モデリング編」を踏まえ、ここからはコーディングを行って実装に落とし込んでいきます。
前提としているドメインモデル図や用語の情報は前記事をご覧ください。
コーディング
作成したドメインモデル図を元に、コーディングを行っていきます。
作成したドメインモデルをただコーディングするだけではなく、前記事のモデリングとコーディングの関係に記述したように「ソフトウェアに反映させやすい」ドメインモデルであるかなど、ドメインモデルをブラッシュアップさせる事も意識しながらコーディングを行うと良いと思います。
DDD の実装に当たって重要なのは、ドメイン部分の実装が特定の技術に依存していない ことです。なぜなら、ドメインの実装が DB などの技術レイヤーの実装に依存していると変更が容易ではなくなり、ビジネスの変更とソフトウェアの変更の歩調が併せにくくなってしまうからです。
実装をする上でのこのような問題を解決するアーキテクチャとして、オニオンアーキテクチャやヘキサゴナルアーキテクチャなどが挙げられます。
呼称や階層の細分化の粒度の違いはありますが、挙げたどのアーキテクチャもドメインの表現を中心に置き、その周囲にインフラストラクチャや UI 、ユースケースなどを配置するといった構造は共通しており本質的には同じです。
今回はその中でも構造がシンプルなオニオンアーキテクチャを参考に、ユースケース層とドメイン層のみ実装を行います。アプリケーション全体の構造は以下のようになっています。
インターフェイスという抽象に依存することで、実装方法など特定の技術に依存する事がなくなります。
この、使う側と実装の双方を抽象に依存させるテクニックを依存性逆転の原則と言い、DDD において重要な役割を果たすテクニックです。
ドメイン層とユースケース層のディレクトリは以下のようになりました。
.
├── domain
│ ├── entity
│ │ ├── preregistrationinfo
│ │ └── user
│ ├── repository
│ │ ├── preregistrationinfo
│ │ └── user
│ └── service
│ ├── preregistrationinfo
│ └── user
└── usecase
├── interface
│ └── notification
└── register
├── full
└── provisional
登録のユースケースは仮登録・本登録に分かれているので、ユースケース内で更にディレクトリを分けています。
この先の実装は仮登録のユースケースを例に取って進めていきます。
ドメイン層の作成
ドメイン層は主にエンティティと値オブジェクトの生成を担い、制約の表現を行っていきます。
値オブジェクトの作成
ユーザ属性の値オブジェクトを例に取って実装していきます。
重要なのは、ドメインモデル図上に記された制約に則ったオブジェクトが作成される事です。制約に則った値を作成し、かつそれらが制約のチェックを回避して更新されないよう、値をプライベートにする必要があります。
Go では構造体のフィールド名を小文字で始めることでプライベートな値となり、別パッケージ内でフィールドが書き換えられることを制限できます。
制約に則った値以外は受け取らず、作成した値は制約に沿わない値で書き換えられないようにします。それによって制約に沿った正しい値のみを存在させ、安全に扱うことが出来ます。
社内では制約を持つフィールドを分かりやすくするため、制約がある場合は独自型を定義するコーディングルールを定めています。
まずはユーザ属性の構造体を定義してみます。
package user
type Email string
type Id string
type Password string
type BirthDate time.Time
type Attribute struct {
email Email
userId *Id
password *Password
birthDate *BirthDate
}
ユーザ属性は全てのフィールドが制約を持っているので、独自型となりました。
任意項目はポインタ型としています。ユーザ属性のフィールドはすべて必須ですが、仮登録ではメールアドレスだけを受け取るので、メールアドレス以外はポインタ型にしています。
次に値オブジェクトを作る上での制約を作成していきます。
Email
のフィールドを例に取ってみます。ドメインモデル図に記載した Email
の制約は以下です。
- 未入力でない
- 254 文字以下である
- "@" が1つ含まれている
- ローカル部は1文字以上64文字以下である
- ローカル部が英数字とピリオドのみから構成される
- ローカル部の先頭がピリオドで始まっていない
- ローカル部の末尾がピリオドで終わっていない
- ローカル部でピリオドが連続していない
- ドメイン部がドメインの形式として正しい
受け取った値が制約に沿っている値かどうかチェックする関数、newEmail
を作成します。
func (ab AttributeBuilder) newEmail(email string) (Email, error) {
// 1. 未入力でない
if email == "" {
return "", errors.New("メールアドレスは必須です。")
}
// 2. 254 文字以下である
const maxLength = 254
if utf8.RuneCountInString(email) > maxLength {
return "", fmt.Errorf("メールアドレスは%d文字以下で入力してください。", maxLength)
}
// 3. @が1つだけ含まれている
if strings.Count(email, "@") != 1 {
return "", errors.New("メールアドレスには「@」が1つだけ含まれている必要があります。")
}
// @前後で分割する
splitEmail := strings.Split(email, "@")
localPart := splitEmail[0]
domainPart := splitEmail[1]
// 4. ローカル部は1文字以上64文字以下である
const (
minimumLength = 1
maximumLength = 64
)
if utf8.RuneCountInString(localPart) < minimumLength || maximumLength < utf8.RuneCountInString(localPart) {
return "", fmt.Errorf("「@」の前は%d文字以上%d文字以下で入力してください。", minimumLength, maximumLength)
}
// 5. ローカル部が英数字とピリオドのみから構成される
r := regexp.MustCompile(`^[\w.]+$`)
if !r.MatchString(localPart) {
return "", errors.New("「@」の前は半角英数字、ピリオドのみで入力してください。")
}
// 6. ローカル部の先頭がピリオドで始まっていない
if strings.HasPrefix(localPart, ".") {
return "", errors.New("「@」の前の先頭にピリオドは使用できません。")
}
// 7. ローカル部の末尾がピリオドで終わっていない
if strings.HasSuffix(localPart, ".") {
return "", errors.New("「@」の前の末尾にピリオドは使用できません。")
}
// 8. ローカル部でピリオドが連続していない
if strings.Contains(localPart, "..") {
return "", errors.New("「@」の前でピリオドは連続して使用できません。")
}
// 9. ドメイン部がドメインの形式として正しい
r = regexp.MustCompile(`^[a-zA-Z0-9-]{1,63}(\.[a-zA-Z0-9-]{1,63})*\.[a-zA-Z]{2,}$`)
if !r.MatchString(domainPart) {
return "", errors.New("「@」の後はドメインの形式で入力してください。")
}
return Email(email), nil
}
独自型を作成する関数、newEmail
は以下の AttributeBuilder
をレシーバとして定義しています。
type AttributeBuilder struct{}
func NewAttributeBuilder() AttributeBuilder {
return AttributeBuilder{}
}
独自型を作成する関数が意図しない場所で使用される事を防ぐため、レシーバと紐づけるようにしています。
レシーバを作成する NewAttributeBuilder
関数のみパブリックな関数とし、それ以外の関数はレシーバに紐づいているか、もしくはプライベートな関数とします。
Email
と同様に他のフィールドも制約を適用した独自型を作成する関数を作成します。
最後にそれらをまとめ、ユーザ属性の構造体を作成する関数 newUserAttribute
を作成します。
func (ab AttributeBuilder) newUserAttribute(
email string,
userId *string,
password *string,
birthDate *time.Time,
) (*Attribute, error) {
// 1. メールアドレスが制約を満たしているかチェックする。
checkedEmail, err := ab.newEmail(email)
if err != nil {
return nil, err
}
// 2. ユーザIDが制約を満たしているかチェックする。
checkedUserId, err := ab.newUserId(userId)
if err != nil {
return nil, err
}
// 3. パスワードが制約を満たしているかチェックする。
checkedPassword, err := ab.newPassword(password, userId)
if err != nil {
return nil, err
}
// 4. 誕生日が制約を満たしているかチェックする。
checkedBirthDate, err := ab.newBirthDate(birthDate)
if err != nil {
return nil, err
}
return &Attribute{
email: checkedEmail,
userId: checkedUserId,
password: checkedPassword,
birthDate: checkedBirthDate,
}, nil
}
受け取った値は全てチェックを行い、制約に則った値だけで構成されたユーザ属性のドメインオブジェクトを作ることが出来ました。
値オブジェクトの一部だけ変更したい場合は、もう一度値オブジェクトを作り直す必要があります。作り直す必要があることによって、常に制約に則ったドメインオブジェクトのみが存在するようになります。
ユーザ属性に含まれる一部のフィールドだけ変更したい場合、ユーザ属性も値オブジェクトなのでユーザ属性自体を新たに作成する必要があります。
何故作り直す必要があるかと言うと、値オブジェクトは用語解説の中でも紹介した通り不変であるからです。「構成要素の一部だけを取り替える」という振る舞いは次に作成するエンティティの持つ振る舞いです。
エンティティの作成
フィールドに対する制約の適用とそれに基づいたユーザ属性の作成が出来ました。
これらを組み合わせてエンティティを作成します。
package user
type Builder struct {
ab *AttributeBuilder
sb *StatusBuilder
}
type User struct {
userUUID uuid.UUID
userAttribute *Attribute
status *Status
}
// NewUser は、制約に沿った値を持つ仮登録状態のユーザを作成する。
func (b Builder) NewUser(
email string,
) (*User, error) {
// 識別子である userUUID を生成
userUUID, err := uuid.NewRandom()
if err != nil {
return nil, err
}
// ユーザ属性を作成
userAttribute, err := b.ab.newUserAttribute(
email,
nil,
nil,
nil)
if err != nil {
return nil, err
}
// ステータスを作成
status := b.sb.newStatus(false, false)
return &User{
userUUID: userUUID,
userAttribute: userAttribute,
status: status,
}, nil
}
値オブジェクトの組み合わせで、エンティティを作成することが出来ました。
ユーザ属性の作成で「「構成要素の一部だけを取り替える」という振る舞いはエンティティの持つ振る舞い」と記述しました。エンティティはライフサイクルを持っています。例えば、ユーザエンティティにおいてはユーザ属性の更新がライフサイクルの一つとして挙げられます。その場合、ユーザエンティティは自身の持つユーザ属性を更新します。そういった属性の変更を行うことができるのはエンティティの振る舞いとなります。
なので、ユーザ属性の中の一部だけを更新してしまうのは、値オブジェクトとして適さない振る舞いとなってしまうのです。
// ユーザエンティティの持つユーザ属性更新のメソッド例
// ユーザ属性の一部のみの更新であってもユーザ属性を新たに作成し、既存のユーザ属性と差し替える
func (u *User) UpdateUserAttribute(
email *string,
userId *string,
password *string,
birthDate *time.Time) error {
ab := NewAttributeBuilder()
ua, err := ab.newUserAttribute(
*email,
userId,
password,
birthDate)
if err != nil {
return err
}
u.userAttribute = ua
return nil
}
集約の用語説明にて、「集約への操作は全て集約ルートを経由して行う」と記述しました。この定義の目的は、集約内の整合性を保つためです。
そのため集約ルートではないユーザ属性を作成するメソッドである newUserAttribute
は、別パッケージから単体で使用されないようにプライベートにしています。
上に載せた UpdateUserAttribute()
が集約ルートを経由した操作だとすると、集約ルートを経由していない操作は u.userAttribute.update()
のようなものです。(値がプライベートならば、このような操作は別パッケージからそもそも行えないのですが)
集約の用語説明内で挙げた「カート内の商品の点数に応じて割引率が変わる」も例に取ってみます。
Cart.Items[0].ItemNum = 2
のように外から自由な値で書き換えられる状態だと、割引率との整合性が取れなくなってしまいます。なので、カート内にある商品の個数を変更する時、cart.UpdateItemNum(itemId, num)
のように集約ルートのオブジェクトが更新の責任を持つようにします。
値をプライベートにすることと、集約ルートが集約に含まれるオブジェクトの更新を行うことで集約内の整合性が保たれます。
ドメインサービスの作成
まだ、ドメインモデル図にあって実装されていない制約があります。エンティティに紐づいた振る舞いの制約です。
振る舞いの制約は、ドメインサービスに実装されます。
通常の制約と振る舞いの制約の違いは、自身がその場で判断を出来るかと用語説明では記述しました。
ユーザエンティティの振る舞いの制約のうち
- 既に存在するメールアドレスではない
- 既に存在するユーザ UUID ではない
が仮登録のドメインサービスでチェックされるものです。
ユーザネームは本登録の段階で入力される想定なので、仮登録の段階で重複確認のチェックはありません。
ドメインサービスにて、上記の制約をチェックしていきます。
package user
type Service struct {
ub *user.Builder
ur user.Repository
}
func (s *Service) NewUniqueUser(
email string,
) (*user.User, error) {
for {
u, err := s.ub.NewUser(email)
if err != nil {
return nil, err
}
// Email が既に使用されていないか確認する。
if exists, err := s.ur.IsEmailExists(u.Email()); err != nil {
return nil, err
} else if exists {
return nil, errors.New("このメールアドレスは既に使用されています。")
}
// ユーザ UUID が既に使用されていないか確認する
if exists, err := s.ur.IsUserUUIDExists(u.UserUUID()); err != nil {
return nil, err
} else if !exists {
return u, err
}
}
}
IsEmailExists
や IsUserUUIDExists
といったメソッドはリポジトリのインターフェイスが持っています。"何をしたいか"がメソッドとして記され、具体的な実装はインフラストラクチャ層に委ねられています。
package user
// 仮登録で使用しているものだけ表記
type Repository interface {
IsEmailExists(email *user.Email) (bool, error)
IsUserUUIDExists(userUUID *uuid.UUID) (bool, error)
Add(fullUser *user.User) error
}
インターフェイスを使用したドメイン層とインフラストラクチャ層の分離によってモックの差し替えの容易さや、使用する DB 変更などの柔軟性が DDD の特徴の一つとなっています。
こうして、ドメインサービスから返されるユーザエンティティは仮登録内でチェックする制約を全て満たしたドメインオブジェクトとなりました。
ユースケース層の作成
制約を満たすユーザが作成出来たので、ユースケース層に仮登録のユースケースを作成します。
package provisional
type UseCase struct {
nr notification.Repository
ur user.Repository
us *user.Service
prir preregistrationinfo.Repository
pris *preregistrationinfo.Service
}
func (pu *UseCase) Execute(email string) error {
// 仮登録状態のユーザを作成する。
u, err := pu.us.NewUniqueUser(email)
if err != nil {
return err
}
// 仮登録情報を作成する。
pri, err := pu.pris.NewUniquePreRegistrationInfo(u.UserUUID())
if err != nil {
return err
}
// ユーザを保存する。
err = pu.ur.Add(u)
if err != nil {
return err
}
// 仮登録情報を保存する。
err = pu.prir.Add(pri)
if err != nil {
return err
}
// ユーザ情報を元に本登録案内の通知を作成する。
newNotification := ¬ification.Dto{
Email: *u.EmailStr(),
Subject: "仮登録完了のお知らせ",
Body: []string{
"以下のURLから本登録を完了してください。",
fmt.Sprintf("https://example.com/fullregistration?code=%s", pri.VerificationCode()),
fmt.Sprintf("有効期限は%sです。", formatExpDateTime(pri.ExpirationDatetime())),
},
}
// 通知を送信する。
err = pu.nr.Send(newNotification)
if err != nil {
return err
}
return nil
}
ユースケース層の実装を行いました。
仮登録のユースケースを構成する処理は
- ユーザを作成する
- 仮登録情報を作成する
- ユーザを保存する
- 仮登録情報を保存する
- 通知を作成する
- 通知を送信する
であることがとても分かりやすくなっています。
ユーザで扱うフィールドには多くの制約がありましたが、その部分はドメイン層に閉じ込められています。ユースケース上では制約に沿ったユーザの作成が NewUniqueUser
の一行で完結しており、ユースケース層とドメイン層で責務が分離されていることが分かります。
ユースケース層には、本来はトランザクション処理も記述されます。
重複確認を行ってから保存するまでには短いですがタイムラグがあり、その間に重複する値が入ってしまう可能性があります。なので実際はトランザクション処理が必要となりますが、今回の記事で説明したい範囲からは外れているので省いています。
終わりに
2つの記事を通して、簡単にですが DDD の基本となるモデリングとコーディングの流れを行ってみました。
DDD において問題解決能力の高いソフトウェアの作成には、モデリングとコーディングのイテレーションが大切です。特にドメインモデルは最初から完成することはないので時間をかけすぎず、コーディングしてブラッシュアップしていくアジャイルな姿勢を念頭に置き、進めることが大切だと感じました。
こうして DDD について学ぶ機会やアウトプットの機会を頂けたことをありがたく思います!
ファクトリや境界づけられたコンテキストなど載せきれなかった概念もありますので、私自身引き続き学習をしてより発展的な DDD の記事を書くことができたら良いなと思います。
この記事を通して DDD に興味が湧いたり、理解の手助けになっていたら嬉しいです!
ここまで読んでいただきありがとうございました。
Discussion