「実践ドメイン駆動設計 第 5 章 エンティティ」を Go で実装する
概要
「実践ドメイン駆動設計」という本(以下、IDDD 本)があります。
全 14 章で構成されており、戦略的 DDD が 3 章(1 章 ~ 3 章)、戦術的 DDD が 11 章(4 章 ~ 14 章)で解説されます。
戦略的 DDD によって、エンジニアがドメインエキスパートで協力し、モデリングをすることでユビキタス言語を発見します。戦術的 DDD によって、発見したユビキタス言語とドメインオブジェクトをソースコードに反映します。その後も継続的な改善により、プロダクト(以下、SaaSOvation)が成功するといった内容の本です。
非常に重厚な本(約 500 ページ)で、戦略的 DDD の解説(ドメイン、境界づけられたコンテキスト、コンテキストマップ)はもちろんのこと、戦術的 DDD も非常に詳細な解説をしています。
SaaSOvation はマイクロサービス化された 3 つのアプリケーションから構成されています。
それぞれのアプリケーションはヘキサゴナルアーキテクチャ(ポート&アダプタ)で構築され、戦術的 DDD の実装パターンが組み合わさって実装されます。
サンプルコードが公開されており、Java で実装されています。
複数回に分けて、IDDD 本の SaaSOvation で実装された戦略的 DDD のパターンを Go で実装し、記事を投稿していきます。
本記事はエンティティです。
サンプルコードは以下です。
エンティティ
エンティティとは
IDDD 本では、エンティティとして設計するときは、以下のときであると記述しています。
ドメインの概念をエンティティとして設計するのは、その同一性を気にかけるときだ。つまり、システム内の他のオブジェクトとの区別が必須の制約となっているときである。エンティティは一意なものであり、長期にわたって変わり続けることができる。変わりかたはさまざまなので、オブジェクトが、かつてあった状態からまったく変わってしまうこともあるだろう。しかし、見た目が変わっても、それらは同一のオブジェクトである。
(中略)
エンティティには一意な識別子があって、変化するという特性がある。これが値オブジェクト (6) とは異なる点だ。
キーワードを抜き出すと、エンティティを使うときは以下の場合です。
- 同一性を気にかけるとき
- 長期によって変わり続けることができる
- 見た目が変わっても同一のオブジェクトである
もっと簡単な言葉でまとめると以下になります。
- 一意な識別子(同一性)によって区別される
- 属性が可変でも良い(識別子以外)
- 属性が変化しても、識別子が同じだったら同じエンティティである
モデリング時に変更があるオブジェクトを発見したときには、エンティティとして扱うべきです。
DDD では可能な限り値オブジェクトを使用するべきですが、トレードオフと集約ルートを考えてエンティティを選択します。
一意な識別子について掘り下げていきます。
一意な識別子
エンティティはその性質から一意な識別子が重要になります。
一意な識別子により、同一判定が保証され、ライフサイクルを持つことができます。そのため一意な識別子は不変です。
一意な識別子の生成方法は以下の 4 パターンがあり、それぞれにメリット・デメリットが存在します。
また一意な識別子は不変であるため値オブジェクトが使用されやすいです。
No. | 方法 | メリット | デメリット |
---|---|---|---|
1 | ユーザーが「入力」 | 生成するしくみが不要 | 重複しないようにするしくみが必要。UX が下がる |
2 | アプリケーションが「生成」 | UUID の生成アルゴリズムを使える | 人間には理解しにくい値になる |
3 | 永続化メカニズム(DB)が「生成」 | シーケンスや専用テーブルを使うので楽 | 永続化するまで ID が生成されない |
4 | 他の境界づけられたコンテキストから「割り当て」 | 他のコンテキストに任せる | 同期がたいへん |
生成タイミングは、早期生成(オブジェクト生成時)と遅延生成(永続化時)があります。
早期生成はアプリケーションが一意な識別子を採番します。遅延生成は DB の機能(シーケンスや専用のテーブルを作る)を使って採番します。
IDDD 本では、DB のオートインクリメントによって ID を割り振ることが推奨されておらず、O/R マッパなどで自動的に採番されるキーは代理識別子として扱いラップするようにしています。
実装
実装方法
本記事ではエンティティの性質のほかに以下のことに重視してエンティティを実装します。
- 識別子の早期生成
- 一意な識別子は UUID を使用(代理識別子を使用しない)
- 永続化はしない
SaaSOvation のアプリケーション(境界づけられたコンテキスト)の 1 つ IdOvation からエンティティを実装します。
エンティティはTenant
とUser
を、値オブジェクトはTenantId
を実装します。
ドメインモデル図
Tenant
まずTenant
エンティティを確認します。
Tenant
エンティティはモデリングの結果「TenantId
を持ち、テナント名(name
)と有効(active
)という属性を持つ」になりました。
識別子tenantId
によって同一性が判断され、ほかの属性(name
、active
)が変化しても、同一と判断されます。
active
は可変であるため、専用の関数で更新するようにしています。
package identity
import (
"reflect"
"github.com/Msksgm/go-IDDD-05-entity/iddd_common/ierrors"
)
type Tenant struct {
tenantId TenantId
name string
active bool
}
func NewTenant(aTenantId TenantId, aName string, anActive bool) (_ *Tenant, err error) {
defer ierrors.Wrap(&err, "tenant.NewTenant(%v, %v, %v)", aTenantId, aName, anActive)
// validate name
if err := ierrors.NewArgumentNotEmptyError(aName, "The tenant name is required.").GetError(); err != nil {
return nil, err
}
if err := ierrors.NewArgumentLengthError(aName, 1, 100, "The tenant description must be 100 characters or less.").GetError(); err != nil {
return nil, err
}
return &Tenant{tenantId: aTenantId, name: aName, active: anActive}, nil
}
func (tenant *Tenant) setActive(active bool) {
tenant.active = active
}
func (tenant *Tenant) Activate() {
if !tenant.IsActive() {
tenant.setActive(true)
}
}
func (tenant *Tenant) Deactivate() {
if tenant.IsActive() {
tenant.setActive(false)
}
}
func (tenant *Tenant) IsActive() bool {
return tenant.active
}
func (tenant *Tenant) Equals(otherTenant Tenant) bool {
return reflect.DeepEqual(tenant.tenantId, otherTenant.tenantId)
}
エンティティの特性について確認します。
一意な識別子(同一性)によって区別される
以下の関数Equals
によって、識別子tenantId
が比較されます。
tenantId
が同一性を識別する要因ですので、ほかを比較する必要はありません。
func (tenant *Tenant) Equals(otherTenant Tenant) bool {
return reflect.DeepEqual(tenant.tenantId, otherTenant.tenantId)
}
属性が変化しても、識別子が同じだったら同じエンティティである
一意な識別子(同一性)によって区別されるで比較するのはtenantId
のみだったので、ほかが変化しても同じエンティティとして判断されます。
属性が可変でも良い
active
をActivate
とDeactivate
によって切り替えます。
ですので、以下のコードを用意して、振る舞いを表現しました。
func (tenant *Tenant) setActive(active bool) {
tenant.active = active
}
func (tenant *Tenant) Activate() {
if !tenant.IsActive() {
tenant.setActive(true)
}
}
func (tenant *Tenant) Deactivate() {
if tenant.IsActive() {
tenant.setActive(false)
}
}
TenantId
続いて、エンティティの識別子である、TenantId
について確認します。
TenantId
は値オブジェクトのため、不変になるように記述します。
そして一意な識別子である uuid を識別子に使用します。
package identity
import (
"fmt"
"github.com/Msksgm/go-IDDD-05-entity/iddd_common/ierrors"
"github.com/google/uuid"
)
type TenantId struct {
id string
}
func NewTenantId(uu string) (_ *TenantId, err error) {
defer ierrors.Wrap(&err, "tenantid.NewTenantId(%s)", uu)
// setId
if _, err := uuid.Parse(uu); err != nil {
return nil, err
}
return &TenantId{id: uu}, nil
}
func (tenantId *TenantId) Equals(otherTeanntId *TenantId) bool {
return tenantId.id == otherTeanntId.id
}
func (tenantId *TenantId) String() string {
return fmt.Sprintf("TenantId [id= %s ]", tenantId.id)
}
User
最後に、User
エンティティについて解説をします。
User
は、「テナントに登録されたユーザーでユーザー名(userName)、パスワード(password)、有効性(enablement)を持つドメインオブジェクト」です。
「ユーザー名」「パスワード」「有効性」は変化することがあり、一意な識別子によって判別されます。
以下がサンプルコードです。
NewUser
がコンストラクタの役割を担います。
tenantId
がUser
を識別する識別子です。
package identity
import (
"fmt"
"unicode"
"github.com/Msksgm/go-IDDD-05-entity/iddd_common/ierrors"
"golang.org/x/crypto/bcrypt"
)
type User struct {
tenantId TenantId
userName string
password string
enablement Enablement
}
const STRONG_THRESHOL = 20
func NewUser(aTenantId TenantId, aUserName string, aPassword string, anEnablement Enablement) (_ *User, err error) {
defer ierrors.Wrap(&err, "user.NewUser()")
if err := validateUsername(aUserName); err != nil {
return nil, err
}
user := &User{tenantId: aTenantId, userName: aUserName, password: "", enablement: anEnablement}
if err := user.protectPassword("", aPassword); err != nil {
return nil, err
}
return user, nil
}
// validate userName
func validateUsername(aUserName string) error {
if err := ierrors.NewArgumentNotEmptyError(aUserName, "First name is required.").GetError(); err != nil {
return err
}
if err := ierrors.NewArgumentLengthError(aUserName, 3, 250, "The username must be 3 to 250 characters.").GetError(); err != nil {
return err
}
return nil
}
func (user *User) protectPassword(currentPassword string, changedPassword string) error {
if err := user.assertPasswordNotSame(currentPassword, changedPassword); err != nil {
return err
}
if err := user.assertPasswordNotWeak(changedPassword); err != nil {
return err
}
if err := user.assertUsernamePasswordNotSame(changedPassword); err != nil {
return err
}
bcryptedPassword, err := bcrypt.GenerateFromPassword([]byte(changedPassword), 12)
if err != nil {
return err
}
user.password = string(bcryptedPassword)
return nil
}
func (user *User) assertPasswordNotSame(currentPassword string, changedPassword string) (err error) {
// 略
return nil
}
func (user *User) assertPasswordNotWeak(changedPassword string) (err error) {
// 略
return nil
}
func (user *User) assertUsernamePasswordNotSame(changedPassword string) (err error) {
// 略
return nil
}
func (user *User) Equals(other User) bool {
return user.tenantId == other.tenantId
}
Tenant
と同様に、エンティティの特性について確認していきます。
一意な識別子(同一性)によって区別される
以下の関数Equals
によって、識別子tenantId
が比較されます。
tenantId
が同一性を識別する要因ですので、ほかを比較する必要はありません。
func (user *User) Equals(other User) bool {
return user.tenantId == other.tenantId
}
属性が変化しても、識別子が同じだったら同じエンティティである
一意な識別子(同一性)によって区別されるで比較するのはtenantId
のみだったので、ほかが変化しても同じエンティティとして判断されます。
属性が可変でも良い
サンプルコードではセッターが記述していましたが、本記事では変更するユースケースを想定していないため、セッターを記述しませんでした。
エンティティは属性が可変であるではなく、属性が可変であっても良いと考えています。
変わりに username はバリデーションが必要ですので、以下のバリデーション用の関数を用意しました。
これをコンストラクタ内で呼び出すことによって、userName の完全性が保証されます。
// validate userName
func validateUsername(aUserName string) error {
if err := ierrors.NewArgumentNotEmptyError(aUserName, "First name is required.").GetError(); err != nil {
return err
}
if err := ierrors.NewArgumentLengthError(aUserName, 3, 250, "The username must be 3 to 250 characters.").GetError(); err != nil {
return err
}
return nil
}
仮に、変更したい場合は以下のような関数を用意します。
func (user *User) changeUserName(aUserName string) error {
if err != validateUsername(aUserName); err != nil {
return err
}
user.userName = aUserName
return nil
}
補足 protectPasswrod について
protectPassword
はパスワードの強度を判断する関数です。
詳細は GitHub を参照してもらいたいのですが、パスワードの長さ、文字の種類の数によって強度を判定しています。
しかし、本来であればこの処理はUser
エンティティがするではありません。
User
がパスワードの妥当な条件を知っており、検証するのは不自然な振る舞いだからです。
なのでサンプルコードでは、この処理をドメインサービスとして切り出しています。
func (user *User) protectPassword(currentPassword string, changedPassword string) error {
if err := user.assertPasswordNotSame(currentPassword, changedPassword); err != nil {
return err
}
if err := user.assertPasswordNotWeak(changedPassword); err != nil {
return err
}
if err := user.assertUsernamePasswordNotSame(changedPassword); err != nil {
return err
}
bcryptedPassword, err := bcrypt.GenerateFromPassword([]byte(changedPassword), 12)
if err != nil {
return err
}
user.password = string(bcryptedPassword)
return nil
}
Go でエンティティを実装した所感
今回 Go でエンティティを実装しましたが、Java と性質が異なるため実装に困る点がありました。
2 点あったので記述します。
公開非公開の範囲
Go にはアクセス修飾子が存在しないため、非公開(先頭を小文字)にしても protected な状態になってしまいます。
そのため、エンティティの識別子を private な状態にできず、ほかのファイルからメソッドを用いずに更新できてしまいます。
今回は IDDD 本の一部を実装したため、あまり問題にはなりませんが、ファイルが増加するにつれて、管理できなくなっていきます。
ドメインオブジェクト毎に package(ディレクトリ)を作成する手段もありますが、意味のない階層構造になっていまい DDD の趣旨から外れてしまいます。
しかし、この問題は Java で protect 修飾子を使うときと同様ですので、「1 つの package に詰め込みすぎない」、というのが解決策の 1 つだと考えました。
コンストラクタが存在しない
コンストラクタがないので、New()
で代替します。
New()
を実行したときには、構造体が生成されないので、以下のいずれかの方法で作成する必要があります。
func NewUser(name string) *user {
return &user{}
}
func NewUser(name string) *user {
user = new(user)
return user
}
Go のサンプルコードでは前者の方が多いのですが、以下の場合に困ります。
構造体はオブジェクト指向のthis
を表現できないため、セッターを構造体の生成の前に使えません。
func NewUser(name string) *user {
user.setName(name) // 実行できない
return &user{}
}
func NewUser(name string) *user {
user = new(user)
user.setName(name) // 実行できる
return user
}
戦術的 DDD の文脈では、セッターを記述しないようにいわれています。
しかし、セッターを使えないとコンストラクタ内でバリデーションが増加します。
ですので、サンプルコードではprivate
なセッターを記述する例も散見されます。
func NewUser(id string, name string, address string, password string) (*user, error) {
if id != ... {
return error
}
if id != ... {
return error
}
if name != ... {
return error
}
...
if address != ... {
return error
}
...
if password != ... {
return error
}
...
return &user{id, name, address, password}
}
本記事では、バリデーションを関数に切り出すことで、解決しました。
しかし、バリデーション関数の名前が同一パッケージで衝突する可能性があるので、一概に解決策とは言えません。
func NewUser(id string, name string, address string, password string) (*user, error) {
if err := validateId(id); err != nil {
return nil, err
}
if err := validateName(name); err != nil {
return nil, err
}
if err := validateAddress(address); err != nil {
return nil, err
}
if err := validatePassword(password); err != nil {
return nil, err
}
return &user{id, name, address, password}
}
まとめ
エンティティについて、IDDD 本のサンプルコードで実践しました。
以下のことを意識しながら、再現しました。
- 一意な識別子(同一性)によって区別される
- 属性が可変でも良い(識別子以外)
- 属性が変化しても、識別子が同じだったら同じエンティティである
今後の課題には以下があります。
- 一部、再現するのに省略した箇所を実装する
- Go で実装するのに課題の解決策を考える
Go でエンティティを実装するうえで、疑問に思ったことについて、コメントをいただけるとうれしいです。
参考
Discussion