Carpe Diem

備忘録

Clean Architecture で実装するときに知っておきたかったこと

概要

developers.cyberagent.co.jp

こちらで

  • 課金システムをマイクロサービス化した
  • サービス自体の設計をDDDにした

という対応をしました。
当時は試行錯誤の連続でしたが対応から1年程経ち、ある程度設計もfixされてきたので知見をまとめます。

知見

前提

Clean Architectureの図は多くの人が目にしているように以下の通りです。

f:id:quoll00:20190319074414j:plain

今回話す内容は青色の部分を除いた

です。

ディレクトリ構成

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してくれませんか?

と問いかけてみることです。その上で

  • 文脈がおかしい
  • xxxが複数である
    • 関数は複数に分ける必要がある

といった感じで直していきます。

ただのフィールドか値オブジェクトかエンティティか

モデリングする際に

  • ただのフィールドにするか
  • 値オブジェクトにするか
  • エンティティにするか

で悩むことがあります。僕は以下のように判断します。

  1. ドメインルール、振る舞いを持たない要素であればただのフィールドのまま
  2. 不変(変更するには交換=新しいオブジェクトを生成する)であり、属性が同じであれば同一とみなすのであれば値オブジェクト
  3. ライフスパンを持ち、可変であり、属性が同じでも同一とみなさない(=識別子が必要)場合はエンティティ

ドメインモデルは成長し続ける

ドメインモデルの責務であるかの判断は時に難しく、悩むこともあります。
その場合は一旦その時点での判断に任せます
時間をおいてリファクタしてみたり、新しい仕様によって改善されることは多々あるので、その時に修正すれば良いです。

フィールドはできる限り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本ではどちらもメリット・デメリットあるので適宜判断して、という感じです。

ドメインモデルを公開するメリット
  • DTOの詰め替えによるメモリ仕様・GCが起きない
  • コード量も増えない
ドメインモデルを公開しないメリット
  • 依存が疎になる(ドメインモデルの変更の影響が減る)

インタフェースとして定義しておく

上位レイヤが利用する時に依存が疎になるよう、インタフェースとして定義しておきましょう。
もちろん実装した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}
}

インタフェース層

役割

インタフェース層は外部の技術要素をまとめたものです。ここで言う技術要素というのは以下です。

  • DB操作
  • 外部APIコール
  • APIとしての口

依存するもの

依存するのは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を学ぶ上で参考にした書籍です。どれも良書なので一読をオススメします。