日記マン

動画広告プロダクトしてます。Go, Kubernetesが好きです。

Goにおけるドメインオブジェクト設計の考察

仕事で、PHPで書かれた硬直したWebサーバシステムを、Go言語でリプレイスしています。
Go言語本来のらしさを壊さず、ドメイン駆動のオブジェクトパラダイムを導入する際の、
自分なりの設計方針を公開して、オープンに意見交換などができればと思いブログにしました。

tl;dr

Goでどのようにデータにロジックを寄せたリッチなドメインオブジェクトを設計するか、
また、例として動画配信サービスを開発するというシナリオでモデルを洞察していき、解説を試みます。
最後に、値オブジェクトの設計方針について簡単に言及しました。

参考

グロースさせていくビジネスの概念を隔離する

エバンス本などを読んでると、複雑なビジネスロジックを隔離することが目的とあり、
複雑なビジネスの問題に立ち向かう、
とありますがそもそも複雑なビジネスの問題とはなにか、とあまりピンときてませんでした。

自分は自社プロダクトを手がけており、ドメインでいうとネット広告(特にアドサーバ・自社アドネットワーク)になります。
このドメインは、配信する広告に複雑な条件(配信量をキャップすることや、ターゲティングなど)を設定することができます。
グロースさせていきたいのはこの配信条件などといったビジネスで生まれた概念で、
このビジネスの関心ごとこそがドメイン層にあると考えて開発をしています。

競合他社との差別化要因にリソースを割きたい自社プロダクトでは、
グロースさせていきたいビジネスの概念を型(クラス)に落とし込むことドメインモデリングになる、と考えています。

Goでオブジェクト設計

named type

Goではプリミティブ型に、string, int, uint, float, などがあります。

bool byte complex64 complex128 error float32 float64
int int8 int16 int32 int64 rune string
uint uint8 uint16 uint32 uint64 uintptr

型に、 type 修飾子を用いて、別名の型を定義することができます。 (named type)

例えば、 uint 型(符号なし整数)から独自の CampaignID 型を定義します。

type CampaignID uint

次に CreativeID 型も同じく uint から派生して定義してみます。

type CreativeID uint

同じ uint 出身なのに CreativeIDCampaignID はもはやべつものなので、
CampaignID を引数にとるGetCampaignメソッドには
CreativeIDにキャストしてある変数を入れようとしてもコンパイルエラーでコンパイルすらできません。

// GetCampaign の引数は CampaignID 型である必要がある
func GetCampaign(id CampaignID) Campaign {
    // ...
}

func main() {
    creID := CreativeID(1) // CreativeID型にキャスト(型変換)している
    GetCampaign(creID) // コンパイルエラー!
}

おかげで、間違えて違うID系の値を、違う用途に使ってしまうミスをコンパイル時点で防ぐことができます。
VSCodeなどのリッチなエディタだったらコンパイル通す以前にメチャクチャ赤く光って怒られます

これがもし普通にプリミティブな uint のままだったら、
間違えたままキャンペーンのデータをクリエイティブIDで引っ張ってきてしまうでしょう。

実行時ではなく、コンパイルの時点で気づきを得られるのは、
型機能を取り揃えた言語の恩恵でしょう。

型自身にロジックを持たせる

time パッケージの Weekday車輪の再発明してみます。

type Weekday int

const (
    Sunday Weekday = iota
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
)

iota は連番を降っていくイディオムなので、 Sunday から Saturday まで 0 ~ 6 の数値を定数に定義しました。

func (w Weekday) IsWeekend() bool {
    switch w {
    case Sunday, Saturday:
        return true
    }
    return false
}

Weekday 型にメソッドを生やすことができ、自分自身が週末かどうか?を返すメソッド IsWeekend() bool を定義できました。

ドメインオブジェクト = データ + ビジネスルール

ドメインオブジェクトは、プリミティブなデータ(事実)を加工して表現(情報)するもの、だと思っています。
ドメインエキスパートのメンタルモデル(関心ごとの単位)は、ただのデータの箱ではないことが多いでしょうから。

どういうドメインを対象にしているかによって、モデリングが異なってきます。
動画配信を行うサービスのモデリングを例に考えてみます。

MediaFile は、動画素材を取り扱うモデルです。

type MediaFile struct {
    URL     string
    BitRate uint
    Width   uint
    Height  uint
}

動画素材モデルというのは、実際の動画ファイルの場所(URL)、
メタデータとしてビットレート(BitRate)や動画の画面サイズ(Width, Height)などを扱うとしましょう。

そこで、実際問題、動画ファイルの保管はなにかしらのCDNサービスを利用することが多いです。
Akamaiや、BrightCove、AWSの Cloud Front やGCPのCloud CDNかもしれません。
ビジネス都合上、利用するCDNプロバイダを変更するケースが起こるとし、
データベースにはURLのホスト箇所を CDN:// という仮文字列で保存させている。という方針だとします。

media_file_id creative_id url bit_rate width height
1 1 CDN://hoge.mp4 1500 640 360
1 2 CDN://fuga.mp4 1500 640 360

そこで、データをDBから引っ張ってきた後、 CDN:// を実際のCDNホストを指す文字列に部分文字列変換が必要です。

func (f MediaFile) ConvertHost(host string) MediaFile {
    f.URL = strings.Replace(f.URL, "CDN://", host, 1)
    return f
}

もはや説明不要な短いコードですが、ホスト箇所を置換しています。
ただの構造データとしてではなく、CDNホストの置換というビジネスルールをモデルに持たせることができました。

コレクション型

type Users []User といったように、User 型の配列を Users 型と定義することも可能です。
これは、オブジェクトの集合を表現する際に有効です。

先ほどの例で MediaFile というモデルを扱いましたが、
動画配信サービスにおいては、同じ動画で異なるビットレートの動画ファイルを用意しておき、
クライアントの環境によって最適なビットレートのほうの動画ファイルを再生する、という方法がよく採用されます。
そのために、動画アップロードされ配信用の動画ファイルにトランスコードする際に、
あらかじめBitRateの異なる同じ動画のファイルをトランスコードし、CDNなどに設置することが多いです。

つまり、 MediaFile は決まって複数のデータが生成されます。

media_file_id creative_id url bit_rate width height
1 1 CDN://hoge-1500.mp4 1500 640 360
2 1 CDN://hoge-750.mp4 750 640 360
3 2 CDN://fuga-1500.mp4 1500 640 360
4 2 CDN://fuga-750.mp4 750 640 360

同じクリエイティブですが、MediaFileはビットレートが1500と750のものが決まって作成される、という設計になりました。

そこで、動画素材モデルは複数必ず扱うことがモデルを洞察してわかってきたので、コレクション型を定義します。

type MediaFiles []MediaFile

アプリケーション層からは、この MediaFiles にCDNホスト変換メソッドがあると便利です。
なぜなら、MediaFileは基本的に複数で扱われるためです。

package service

func GetMediaFiles(mediaFileRepo repository.MediaFile, id model.CreativeID, host string) (model.MediaFiles, error) {
    // MediaFilesを取得
    mediaFiles, err := mediaFileRepo.Find(id)
    if err != nil {
        // DBから取得失敗
        return nil, xerrors.Errorf("could not get MediaFiles from repository: %+v", err)
    }

    // 複数のメディアファイルのホストを一気に置換
    mediaFiles = mediaFiles.ConvertHost(host)
    return mediaFiles, nil
}

これを満たすために、MediaFilesにConvertHostメソッドを実装します。

func (fs MediaFiles) ConvertHost(host string) MediaFiles {
    dst := make(MediaFiles, len(fs))
    for i, f := range fs {
        f = f.ConvertHost(host)
        dst[i] = f
    }
    return dst
}

さて、このようにモデルの洞察が深まってくると、もともとの単独の MediaFile 型に実装した ConvertHost メソッドは、
実際に別パッケージであるアプリケーション層から利用されることがなくなりました。
そこで、privateメソッド(Goではメソッド名の先頭を小文字にする)にすることでメソッドを他パッケージから隠蔽してしまいましょう。

// 公開されたまま
func (f MediaFile) ConvertHost(host string) MediaFile
// private method にして公開しない
func (f MediaFile) convertHost(host string) MediaFile

モデル設計者としては単独の MediaFile を直接扱ってもらうのではなく、
コレクション型の MediaFiles を扱ってもらうことが望ましいので、
望ましくない操作をされないように公開するメソッドを限定した設計をします。

これまでを踏まえて、最終的にこんな感じになりました。

package model

type MediaFiles []MediaFile

// ConvertHost は他のパッケージにも公開される
func (fs MediaFiles) ConvertHost(host string) MediaFiles {
    dst := make(MediaFiles, len(fs))
    for i, f := range fs {
        f = f.convertHost(host)
        dst[i] = f
    }
    return dst
}

type MediaFile struct {
    URL     string
    BitRate uint
    Width   uint
    Height  uint
}

// 他のパッケージに公開されない
func (f MediaFile) convertHost(host string) MediaFile {
    f.URL = strings.Replace(f.URL, "CDN://", host, 1)
    return f
}

ここまでのまとめ

独自型を定義しメソッドを持たせることでドメインオブジェクトを表現しました。
また、動画配信サービスというシナリオで作られたモデルをもとに説明を試みました。
ここで注意点として、あくまでもこのシナリオでモデリングされたものですので、
他のシナリオとの汎用性が望めるものではないと思います。
なんでも汎用的なモデルの設計を追い求めてしまわないよう、注意したいところです。

複数のモデルを取りまわしたり、文字列の置換などごにょごにょした処理は、
アプリケーション層側に漏らさずにモデルにメソッドを実装することで隠蔽させましょう。
そうすることで、アプリケーション層側はスッキリしますし、
そういったドメインロジックがモデルに一箇所にまとまることで、
複数のユースケースからの利用が増えても、変更箇所は一つだけになります。

これがデータにロジックを寄せるという、ドメインオブジェクト設計の基本のキになります。

値オブジェクト

ドメインオブジェクトの実装パターンとして、洗練されているパターンのひとつに、
値オブジェクト(ValueObject) があります。
Goではどういう風に設計するか、という考察です。

前提: コマンド/クエリ原則

まず、前提として、コマンド/クエリ原則という設計ルールを採用します。

  • コマンドは、オブジェクトの状態に変更がある場合(副作用あり)、返り値を返さない。
  • クエリは、オブジェクトの状態に変更がない場合(副作用なし)、返り値を返す。

ですが、Goにおいて例外は errorオブジェクト で返す、というGo言語の正しさ、を考慮したうえで、
コマンドはerrorオブジェクトを返すことは許容したいと思います。

種類 状態変更 返り値 ex.
コマンド あり なし もしくは error (u *User) ChangeName(name string) error
クエリ なし あり (f MediaFile) ConvertHost(host string) MediaFile

Goでは、コマンドでは必ず (u *User) といったようにポインタを参照すると思います。

値オブジェクト

値オブジェクトは、不変です。ですので、値オブジェクトのメソッドシグネチャは必ずクエリになります。
上記の例にあったように、 MediaFile は値オブジェクトとして設計しました。

type MediaFile struct {
    URL     string
    BitRate uint
    Width   uint
    Height  uint
}

func (f MediaFile) convertHost(host string) MediaFile {
    f.URL = strings.Replace(f.URL, "CDN://", host, 1)
    return f
}

ただし、Goでは上の convertHost メソッドでは返り値 f は値のコピー渡しになります。
Goの言語仕様上、コピーではなく参照を返すためには、ポインタ参照が必要です。

func (f *MediaFile) convertHost(host string) *MediaFile {
    f.URL = strings.Replace(f.URL, "CDN://", host, 1)
    return f
}

ただ、これにより、メソッドを呼び出されたオブジェクトに状態変更が起きないイミュータブル設計に対し、
この場合はメソッドを呼び出されたオブジェクト自身にも、状態変更が発生してしまうので、
可読性や保守性の観点で、実際の開発チームで採用を検討する必要があるかと思います。

ポインタ参照を利用したいケースは、 interface を利用する際が考えられます。
Goでは、 interface は、具象型のポインタを示します。

全体まとめ

Goにおける、独自型とメソッドの言語機能の活用方法を示し、
ドメインオブジェクトのパターンの一つである 値オブジェクト の設計について実践面の考察を述べました。
私はこのような設計方針で、ドメイン層を実装していってます。

今度のブログは、実際にどのようなアプリケーションアーキテクチャで、
ドメイン層の隔離を実現しているか、という話ができたらなーと思います。