概要
こちらで
- 課金システムをマイクロサービス化した
- サービス自体の設計をDDDにした
という対応をしました。
当時は試行錯誤の連続でしたが対応から1年程経ち、ある程度設計もfixされてきたので知見をまとめます。
知見
前提
Clean Architectureの図は多くの人が目にしているように以下の通りです。
今回話す内容は青色の部分を除いた
です。
ディレクトリ構成
goのリポジトリの構成は以下のようにしています。
. ├── Dockerfile ├── Makefile ├── README.md ├── cmd/ ├── codes/ ├── config/ ├── docker-compose.yml ├── domain/ ├── go.mod ├── go.sum ├── interface/ ├── mock/ ├── proto/ ├── registry/ ├── usecase/ ├── utils/ └── vendor/
各ディレクトリを説明すると以下です。
ディレクトリ | 説明 |
---|---|
cmd | main関数。バイナリ生成用 |
codes | エラーコード。 どのレイヤのエラーだとしてもこのコードに基づく |
config | 環境変数を管理 |
domain | ドメイン層。黄色のやつ |
interface | インタフェース層。緑色のやつ |
mock | 各レイヤで抽象化した(IF化の)テスト用モック |
proto | gRPCで使うprotocol bufferの定義 |
registry | DI用の関数群 |
usecase | ユースケース層。赤色のやつ |
utils | 汎用関数 |
vendor | 依存ライブラリ |
ドメイン層
ドメイン層の要素に基づいたpackage
ドメイン層には大きく以下の要素がありますので、それぞれでpackageを切ります。
要素 | 説明 |
---|---|
model | ドメインモデル。ビジネスロジックをここに書く |
repository | ドメインオブジェクトを取得・保存するインタフェース |
service | ドメインオブジェクトに責務を持たせるものではないケース または 複数のドメインモデルを操作する時に使うシナリオとして |
model
前提
個人的な考えですが、DDDはオブジェクト指向の延長です。なので
を最低限用意する必要があります。
この記事ではディレクトリ構成など戦術面で悩みそうな点を説明しますが、そもそも↑ができていないと結局拡張・修正しにくいコードになりDDDで実装する意味がありません。
適切な責務であるか、を見分けるには?
これを見分けるテストの1つに
{{ドメインモデル}}さん、xxxしてくれませんか?
と問いかけてみることです。その上で
といった感じで直していきます。
ただのフィールドか値オブジェクトかエンティティか
モデリングする際に
- ただのフィールドにするか
- 値オブジェクトにするか
- エンティティにするか
で悩むことがあります。僕は以下のように判断します。
- ドメインルール、振る舞いを持たない要素であれば
ただのフィールド
のまま - 不変(変更するには交換=新しいオブジェクトを生成する)であり、属性が同じであれば同一とみなすのであれば
値オブジェクト
- ライフスパンを持ち、可変であり、属性が同じでも同一とみなさない(=識別子が必要)場合は
エンティティ
ドメインモデルは成長し続ける
ドメインモデルの責務であるかの判断は時に難しく、悩むこともあります。
その場合は一旦その時点での判断に任せます。
時間をおいてリファクタしてみたり、新しい仕様によって改善されることは多々あるので、その時に修正すれば良いです。
フィールドはできる限りprivateに
DDDではドメイン層のデータの整合性を保つことが重要です。
なので他の層で意図しないドメインオブジェクトの破壊が起きないように、不用意にアクセス出来ないようにしてください。
なので多くのオブジェクトでNewXxx
といったコンストラクタを用意することになってくると思います。
package model type Plan struct { id string } func NewPlan(id string) (*Plan, error) { _, found := getPlanMeta(id) if !found { err := errors.New("plan id is invalid") return nil, errorf(codes.BadParams, "%s", err.Error()) } return &Plan{id: id}, nil }
ドメインモデルとデータモデルは違う
注意としてドメインモデルとDBのデータモデル(テーブル設計)は一緒になることもありますが同じではありません。
ドメインモデルでは、「年齢」が業務の関心事であれば、年齢クラスを作ります。年齢クラスは内部的に生年月日をインスタンス変数に持ち、そのインスタンス変数を使って年齢を計算するロジックをメソッドとして持ちます。年齢を知りたいという関心事があり、それを計算するロジックの置き場所が必要だから年齢クラスを作る、というアプローチです。
一方、データモデルでは、年齢は記録すべきデータではありません。計算の結果です。テーブルには計算のもとになる生年月日だけを記録します。
ref: 現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法
このようにドメインモデルは業務ロジックに注目し、それをクラスという単位で設計します。
データの整理を目的とするデータモデルとは本質的に異なります。
またデータモデルはドメインモデルが大きく変更された場合に、同じように変更しようとするとスキーマの変更やらデータ埋めといったマイグレーション作業が大変になります。
なのでドメインモデルとデータモデルは別モノと考え、無理にデータ側をドメインモデルに合わせず後方互換性を保たせるような運用をすると良いです。
repository
リポジトリはドメインモデルを入出力するためのインタフェースです。
なのでロジックは介在せず、インタフェース定義のみ存在します。
例えば以下は課金マイクロサービスで使用しているカード履歴のリポジトリです。
package repository type CardHistory interface { Find(fingerprint string) (history model.CardHistory, err error) Save(history model.CardHistory) error }
入出力が基本なのでこのように取得と保存・更新処理が大半になります。
※外部APIとのインタフェースとしても使うため、↑のようにシンプルに書きにくいケースもあると思います。
インタフェースではプリミティブ型だと何を示しているか分からない時があるので、
- 値オブジェクトとして定義する
- ↑のように分かりやすい変数を一緒に書く
のどちらかをオススメします。
依存するもの
repositoryが依存するのはmodelのみです。それ以外依存するものはありません。
また逆にmodelがrepositoryに依存することはありません。
service
サービスは以下の用途で利用します。
前提としてサービスはステートレスである必要があります。
ドメインオブジェクトに責務を持たせるものではないケース
例えば永続層にあるデータを利用して判定を入れたいケースがあると思います。
以下のように同じEmailアドレスを登録できないようにする処理はよくあると思いますが、これはリポジトリを経由する必要があるのでUser modelにロジックを持たせるのではなくサービスで処理するのが適切です。
type userService struct { repo repository.User } func (s *userService) IsDuplicated(email string) error { user, err := s.repo.FindByEmail(email) if err != nil { return err } if user != nil { err = errors.New("email is duplicated") return errorf(codes.BadParams, "%s", err.Error()) } return nil }
データ整合性を保つために複数のドメインモデルを操作するケース
単一のアトミックな操作で複数のドメインモデルを扱うことは往々にしてあります。
先に述べたようにドメイン層ではデータの整合性を保つことが非常に重要であるため、それが崩れない範囲でのトランザクション処理が必要になってきます。
それを担保する粒度としてドメインサービスが使えます。
例えばiOS, Androidでサブスクリプションの購読をする場合、
- レシートの保存
- ユーザの購読ステータスの更新
はトランザクション処理としてまとまっている必要がある一方、
- レシート
- 購読ステータス
というmodelは別々です。そういった複数のドメインモデルを扱う時にサービスが適切です。
serviceだらけにしないこと
前述の複数のドメインモデルを扱ったりする際に、データ整合性を気にしなくてもいいケースでもserviceを用意したり、1ドメインオブジェクトを取得するだけでもserviceを挟んだりしがちです。
このようなケースはユースケース層の責務であるので、わざわざserviceを挟む必要はないです。
依存するもの
serviceが依存するのはrepository(IF)とmodelだけです。それ以外依存するものはありません。
インタフェースとして定義しておく
上位レイヤが利用する時に依存が疎になるよう、インタフェースとして定義しておきましょう。
もちろん実装したstructも用意します。
package service type Card interface { Add(userID string, token string) error Delete(userID string, cardID string) error } type cardService struct { repo repository.Card } func NewCardService(repo repository.Card) *cardService { return &cardService{ repo: repo, } }
コメント
ドメイン層はドメインロジックを実装したものであり、適切に設計されていればそのコードを読めばおおよその仕様を把握できます。
なので分かりやすくするようにドメイン層ではコメントを厚めにすることをオススメします。
ユースケース層
ユースケースとは
ユースケース層ではユースケースに応じた関数を用意します。
ここで言うユースケースというのは特にアクターが異なるケースを想定しています。
例えば
- ユーザ
- 管理者
- バッチ処理
等によってユースケースは大きく異なります。
ユーザであれば自身のデータを作成・更新したりする事ができます。
管理者であればユーザが取得・更新できないような操作が可能です。
またバッチ処理では↑のアクターが使わないような大量データを操作することを可能にします。
このようにそれぞれのアクターによってユースケース異なるので、ユースケース層で関数を用意します。
他の具体例としては「データを更新して、更新を反映した上でユーザステータスをレスポンスに返してほしい」というユースケースの時に、ドメイン層で
- データを更新する
- ユーザステータスを取得する
と分かれているのをユースケース層でまとめて1関数としたりです。このようにユースケースに沿った関数を用意します。
依存するもの
usecaseが依存するのはservice(IF)とrepository(IF)とmodelです。
ユースケースを満たすために複数のドメインモデルを扱うこと(serviceと違ってデータ整合性を意識しなくてよいケース)も当然あります。
データ変換はすべきか
人によってはmodelはusecaseより上の層(図で言う緑色の部分)に対して隠蔽すべき、としてusecase層で変換することもあると思います。
IDDD本ではどちらもメリット・デメリットあるので適宜判断して、という感じです。
ドメインモデルを公開するメリット
ドメインモデルを公開しないメリット
- 依存が疎になる(ドメインモデルの変更の影響が減る)
インタフェースとして定義しておく
上位レイヤが利用する時に依存が疎になるよう、インタフェースとして定義しておきましょう。
もちろん実装したstructも用意します。
package usecase type Invoice interface { Get(userID, invoiceID string) (model.Invoice, error) List(userID string, limit int64) ([]model.Invoice, error) } type invoiceUsecase struct { svc service.Invoice } func NewInvoiceUsecase(svc service.Invoice) *invoiceUsecase { return &invoiceUsecase{svc: svc} }
インタフェース層
役割
インタフェース層は外部の技術要素をまとめたものです。ここで言う技術要素というのは以下です。
依存するもの
依存するのはusecase(IF)、repository(IF)、modelあたりです。
DB操作/外部APIコール
repository(IF)を実装する形になります。
func (c *cardHistoryDAO) Save(history model.CardHistory) (err error) { session := getSession() defer session.Close() data := mapCardHistryEntity(history) data.CreatedAt = time.Now().Unix() err = session.CardHistories().Insert(data) if err != nil { err = errorf(codes.Database, "failed to insert card history: %s", err) return } return }
内部ではドメインモデルをDBのデータモデル(Entity)に変換して保存しています。
逆に取得ではDBのEntityをドメインモデルに変換する処理があります。
先に述べたようにドメインモデルとデータモデルは似ていますが異なるものなので、正規化などの場合によっては複数のデータモデルに分かれたりすることもありますし、逆に複数のドメインモデルを1つのデータモデルとして管理することもあります。
APIとしての口
usecaseを使ってhandlerを用意します。
package handler type invoiceHandler struct { uc usecase.Invoice } func NewInvoiceHandler(uc usecase.Invoice) *invoiceHandler { return &invoiceHandler{uc: uc} } func (h *invoiceHandler) Get(ctx context.Context, in *pb.InvoiceRequest) (*pb.InvoiceResponse, error) { iv, err := h.uc.Get(in.UserID, in.ID) if err != nil { return nil, toGrpcError(err) } return toInvoiceResponse(iv), nil }
toInvoiceResponse()
ではユースケースから取得したドメインモデルをgRPCのレスポンスに詰め替えています。
まとめ
DDD、Clean Architectureを採用して実装を進めてから悩んだこと・知りたかったことをつらつらと書いてきました。
正直ここで書ききれてない(もしくは書き忘れた)こともあるので、ちょこちょこ追記・修正したりするかもしれません。
参考文献
オブジェクト指向やDDDを学ぶ上で参考にした書籍です。どれも良書なので一読をオススメします。
実践ドメイン駆動設計 (Object Oriented SELECTION)
- 作者:ヴァーン・ヴァーノン
- 発売日: 2015/03/17
- メディア: 大型本
エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)
- 作者:エリック・エヴァンス
- 発売日: 2011/04/09
- メディア: 大型本
- 作者:ダグ・ローゼンバーグ,マット・ステファン
- 発売日: 2016/01/28
- メディア: Kindle版
Clean Architecture 達人に学ぶソフトウェアの構造と設計
- 作者:Robert C.Martin
- 発売日: 2018/07/27
- メディア: 単行本
オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方
- 作者:Sandi Metz
- 発売日: 2016/09/02
- メディア: 大型本