目次

  • はじめに
  • 対象読者
  • 値オブジェクトパターンとは
  • Goでドメインオブジェクトを実装する時の注意点
  • 具体的な課題
  • 試したこと
  • 最終的な意思決定
  • 終わりに

はじめに

AmebaLIFE事業本部でバックエンドエンジニアをしています、23新卒のみねしんです。

最近のニュースはAmebaLIFE事業本部とCAのCyberOwlからなるライフスタイル管轄が株式会社AmebaLIFEとして子会社化することが発表されたことです。

この規模の経営統合はCA全体でも初めてのことなのだとか。驚き…!

 

本記事の内容

私の所属するチームでは、Goを使用し「DDD(ドメイン駆動開発)」の考え方に則ったアプリケーション開発をしています。

後述しますが、GoでDDDを実践する場合には言語仕様の問題で実装方法に幾つかの選択肢が存在します。

本記事では、DDDにおける「値オブジェクト」をGoで実装する際に直面した課題についてまとめ、最終的に私たちのチームでどのような意思決定を行なったのかをご紹介いたします。

対象読者

Goで値オブジェクトパターンを利用しようとされている方

(DDDを実践されている方など)

値オブジェクトパターンとは

値オブジェクトパターンとは、デザインパターンの一種であり特にDDD(ドメイン駆動開発)ではドメインの概念に対応したドメインモデルの一種として扱われるコアの概念とされています。

(値オブジェクトパターン自体はDDDを利用しない場合でも利用可能です)

 

DDDにおける値オブジェクトの位置付けの説明画像
DDDにおける値オブジェクトの位置付け

 

ここでは、DDDや値オブジェクト以外のドメインモデルについての詳細な説明は割愛しますが、たくさんの名著が存在するので気になった方はそちらをご参照いただけますと幸いです。

 

改めて、値オブジェクトに話を戻します。

極力、DDDの知識を必要とせずに値オブジェクトの性質・役割について説明を試みると以下のようなものになるかと思います。

 

💡 値オブジェクトとは?

システム固有の値(文字列, 数値…)のうち、

  • 不変性
  • 同種のオブジェクト間で交換が可能
  • 同種のオブジェクト間で等価性による比較が可能

といった性質を持つものを、専用オブジェクトとしてとり扱うデザインパターン

 

ex. 名前, 価格, 色 etc.

 

値オブジェクトパターンの目的は、これらの性質をもつデータや振る舞いを一つの構造体やそのメソッドとしてまとめることです。

これによりコードの凝集性を高め、表現力と保守性のあるコードを書くことができます。

Goでドメインオブジェクトを実装する時の注意点

Goで値オブジェクトパターンを実現しようとする場合、幾つかの注意点が存在します。

ここでは特に、前述の値オブジェクトの性質の一つ目の条件である 不変性 について考えます。

 

まず前提として、DDDを実践する場合には実装言語としてはJavaなどのオブジェクト指向言語の方が実装が容易な場合が多いです。

これは、DDDがモデルをコードを落とし込むための方法論としてオブジェクト指向設計的な手法を下敷きにしている関係上、オブジェクト指向言語が言語レベルで使用している機能を利用できる場合が多いためです。

しかしながらGoはオブジェクト志向言語ではないため、そうした言語的サポートに頼れない部分があります。

 

例えばGo言語では、

  • クラスが存在しない
  • 継承が存在しない
  • コンストラクタが存在しない

などなど、多くのオブジェクト指向言語が備えている機能が存在しません

(ですが、そうしたシンプルな言語仕様によりGoは高い生産性とパフォーマンスを実現しています)。

 

もっとも、構造体とレシーバメソッドを使ってデータと振る舞いのまとまりを表現したり、継承の代わりに構造体の埋め込みを使用して元の構造体のメソッドやフィールドを利用したりなど、Goでもオブジェクト指向的な設計を行うことはできます。

 

しかしながら、言語仕様でサポートされていない部分に関しては開発者側の判断に委ねられており、ここに設計の余地があります。

具体的な課題

自分が値オブジェクトの実装で悩んだのは主に以下の部分です。

  • オブジェクトの生成時の不変条件の保証
  • オブジェクトの生成後に不変条件が破られないことの保証

それぞれの課題感について説明するにあたり、例として以下のような値オブジェクトについて考えます。

 


ユーザ名(`UserName`)

  • データ形式: 文字列
  • 不変条件
    • ユーザ名は、1文字以上の文字からなる
  • ふるまい
    • ユーザ名を逆順に反転させた文字列を表示する

(不変条件や振る舞いについてはあくまで参考のための仮定です)


 

ここで、値オブジェクトの原則である不変性について考えると値オブジェクトUserName は生成時・生成後の両方で不変条件を常に満たしている必要があります。

 

出発点として、プリミティブ型  string  を使った以下のコードを用意しました。

値オブジェクトを使用する場合、このコードをどのようにリファクタリングできるかについて考えていきます。

package main

...

// ユーザ名を反転させる処理
func reverseUserName(userName string) (string, error) {
  // 文字列が空の場合はエラーを返す
  if userName == "" {
	  return "", err
  }
  // userNameの文字列を反転させる処理
  runes := []rune(s)
  for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
      runes[i], runes[j] = runes[j], runes[i]
  }
  return string(runes), nil
  ...
}

func main() {
  userName = "佐藤太郎"
  reversedUserName, err := reverseUserName(userName)
  ...
}

試したこと

① 素朴な方法

package value

...

// stringを基底型としてUserNameを型定義
type UserName string

// 反転処理をUserNameの振る舞いとして定義
func (u UserName) Reverse() UserName {
  runes := []rune(s)
  for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
      runes[i], runes[j] = runes[j], runes[i]
  }
  return string(runes)
}

// UserNameを生成するファクトリ
func NewUserName(s string) (UserName, err) {
  // 
  if s == "" {
	  return "", errors.New("input is empty")
  }
  
  return UserName(s)
}

上記では、値オブジェクトUserName について次のように定義しました。

  • 単純にstringを基底型にしたUserName型をPublicな型として定義
  • 文字列の反転処理を値オブジェクトの振る舞いとして定義 → Reverse 関数
  • ファクトリパターンを使用し、バリデーション付きでUserName を生成 → NewUserName 関数

 

Goではコンストラクタが存在しないためオブジェクトの生成用にファクトリメソッド(上記でいうところのNewUserName)を作成することが一般的です。

ファクトリを利用することの利点としては、オブジェクトの生成方法を制御できることが挙げられます。

例えば、バリデーションなどをファクトリ内で実行すれば(ファクトリを使用する限り)不正なオブジェクトが作成されることを防ぐことができます。

 

上記の値オブジェクトを利用する場合、利用側のコードは次のように書き換えられます。

package main

...

func main() {
  // ファクトリを使って値を生成するように
  userName, err := value.NewUserName("佐藤太郎")
  ...
  reversedUserName := userName.Reverse()
  ...
}

バリデーションや反転処理がカプセル化され、利用側のコードがすっきりとしました。

また、ユーザ名をフィールドに持つ構造体や引数にとる関数が増えた場合も

package main

...

type User struct {
 userName value.UserName
}

func SomeMethod(userName value.UserName) {
 ...
}

のように定義した型を使用することができ、単なるstringを使うよりも扱うデータを明確に表現できます。

 

しかしながら、このコードにはまだ課題が存在します。

UserNameがPublicに定義されており、以下のようにパッケージ外から直接オブジェクトを作成することができてしまいます。

従ってファクトリの使用を強制できず、開発者のミスによって不正なオブジェクトが生成できてしまう危険性を孕んでいます。

package main

...

func main() {
  // ファクトリを使わずに直接オブジェクトが作成できてしまう → バリデーションが強制できない!
  userName := value.UserName("") // 空文字は本来不正だがオブジェクトが生成できてしまう
  ...
  reversedUserName := userName.Reverse()
  ...
}

 

② UserName型をPrivate定義に変更

先ほどのコードでは、UserName型がPublicに定義されていたためファクトリを介さず直接構造体を生成することができてしまっていました。

では、次のようにUserNameをPrivateな型として定義してみるとどうでしょうか?

package value

...

// stringを基底型としてuserNameを型定義 (Private)
type userName string

...

// userNameを生成するファクトリ
func NewUserName(s string) (userName, err) {
  // 不変条件をチェックするバリデーション
  if s == "" {
	  return "", errors.New("input is empty")
  }
  
  return UserName(s)
}

Privateな型になったことで、userNameを生成する術はファクトリのみに限定されました。

しかし、パッケージ外で型を参照していた箇所では型を参照できなくなってしまいますので、Private定義だけでは不十分そうです。

package main

...

type User struct {
 // value.userNameはPrivate定義されているのでパッケージ外から参照できない → ビルドエラーが発生する
 userName value.userName
}

 // value.userNameはPrivate定義されているのでパッケージ外から参照できない → ビルドエラーが発生する
func SomeMethod(userName value.userName) {
 ...
}

 

💡 補足

今回はstring型に対する型定義として値オブジェクトを定義していますが、基底型として構造体を使用している場合はフィールドがPublicだとパッケージ外からフィールド値の変更をすることができ、不変条件に違反する不正オブジェクトを作ることができてしまうので構造体を基底型にする場合はフィールドの可視性もPrivateにして、パッケージ外からの不正な変更を防止するのが良さそうです…!

 

③ インタフェースを導入してみる

先ほどの実装では、定義型をPrivate定義に変更したため、パッケージ外で型を指定したい場合に直接定義型を参照することができなくなってしまっていました。

この問題の解決策としては、外部パッケージからの参照用にインタフェースを作成することが考えられます。

package value

...

// 外部パッケージからの参照用にインタフェースを定義(Public)
type UserName interface {
  Reverse() string
}

// stringを基底型としてuserNameを型定義 (Private)
type userName string

...

// userNameを生成するファクトリ
func NewUserName(s string) (userName, err) {
  // 不変条件をチェックするバリデーション
  if s == "" {
	  return "", errors.New("input is empty")
  }
  
  return UserName(s)
}

インタフェースUserNameを導入し、定義型userNameUserNameの実装として扱うようにしてみました。

すると、パッケージ外からの参照はインタフェースUserNameを介して行えるようになります。

package main

...

type User struct {
 // value.UserNameはインタフェース
 userName value.UserName
}

 // value.UserNameはインタフェース
func SomeMethod(userName value.UserName) {
 ...
}

一見良さそう??

 

が…!

 

検討を重ねてみるとインタフェースを利用した場合でも、以下の課題があることがわかりました。

  • 値オブジェクトが汎用的なメソッドしか持たない場合、オブジェクトに対して複数のインタフェースがマッチしてしまう
    • 例えば、関数の引数にインタフェースを使用している場合に、同じメソッドを持ってさえいれば全く異なる意味合いを持つ値オブジェクトでも渡せてしまう
  • テストを書くのが辛い
    • テストコードのパッケージを <package名>_test のようにしていると、値オブジェクトの生成をファクトリ経由で行う必要があり取り回しが悪い(エラーハンドリングを記載する必要があるなど)

 

うーん、一筋縄にはいかないですね…

最終的な意思決定

検討の結果、どの方法でもトレードオフが存在し、明確な正解はないように感じました。

そこで、チーム内で相談し以下の方式を採用することに決定しました。

  • パッケージ外からの型参照のため、値オブジェクトの定義はPublicに行う
  • パッケージ外での値オブジェクトの生成にはファクトリを使用する(テストコード以外を除く)
    • パッケージ外から定義型を直接生成できてしまう問題に対しては、ファクトリ不使用を検知するlinterなどで対応する
    • テストコードがアプリケーションコードに対して密結合するのは避けられないので、ファクトリを経由しないオブジェクト生成を許容

 

💡 補足

ファクトリの不使用の検知には gofactory というカスタムリンターが使用できそうでした。
ただし、2025年2月現在、golangci-lintへの正式導入の提案PRに動きがないためgolangci-lintの「Module Plugin System」を利用してカスタムプラグインとして動かしてみたところ、残念ながらオプションがうまく伝播できず所望の動作はまだ実現できていません。
そのため、現状は開発メンバー間の紳士協定で凌いでいます。

終わりに

値オブジェクトの実装を通してGoならではの表現上の制約を知り、実装パターンについての検討を行いました。

 

検討の結果として私たちのチームでは、一部の型の可視性などについては妥協しつつ、linterなどのツールも活用していくことが良さそうという判断に至りました。

結論として、各表現方法にはトレードオフが存在するためチームの状況や方針に合わせた方針決定を行うのが良さそうです。

(今回示した例もあくまで実装方法の一つだと思うので、より良い方法などをご存知の方がいましたらご教授いただけますと幸いです!)

 

個人的な感想として、値オブジェクトパターン自体はとてもシンプルなデザインパターンだと感じていたのですが、Goで実践するとどうなるのかというのは試してみて初めて実感を得られましたので大きな学びになりました。

Go × DDD、奥が深い…!

 

今後も事業開発の中での学びなどを記事にしていけたらと思うのでお楽しみいただけましたら幸いです。

それではご覧いただきましてありがとうございました!

 


 

AmebaLIFE事業本部(4/1からは株式会社AmebaLIFE !!)では、オウンドメディア「Ameヨコ」も展開しております。

AmebaLIFEでの働き方や開発組織についてのコンテンツが充実しておりますので、AmebaLIFEにご興味をお持ちいただけた方はぜひそちらもご覧いただけますと幸いです。

アバター画像
2023年新卒入社のバックエンドエンジニアです。AmebaLIFE事業本部の開発局/新規事業準備室兼務。 好きなものはゲーム, MtG, ジビエなどなど。 Developers Blogの運営など、広報的な業務も担当しています!