My Profile & Blog GitHub X Zenn Qiita Wantedly note しずかな
2023-11-28

Goで汎用的な値オブジェクト(value object)の仕組みを作成した話

Go
Tech

この記事ではドメイン駆動開発(DDD 以下DDDとします)に登場してくる値(value)オブジェクトをGoで実装する方法を紹介します。クラスがないGoで完全な値(value)オブジェクトを実装するには工夫が必要です。今回はGoのジェネリクスを使用してなるべく汎用的な作りにしてみました。

値(value)オブジェクトについて

DDDの文脈で登場するドメインモデルの一種です。ドメインを表現するのにエンティティという概念があり、エンティティは一意性を持ちます。エンティティ以外のドメインモデルは値(value)オブジェクトであり、以下のような特徴を持ちます。

これ以外にも値(value)オブジェクトの特性や定義などはいろんなところで語られているかと思いますが、intやstringのようなプリミティブな値をクラスなどで表現したものと考えて問題はないかと思います。

値(value)オブジェクトを採用する理由やメリットについてはDDDについての理解や説明となってしまうので詳細には本記事では語りませんが、ドメインの概念をより表現できるようになったり、値の不変性を保てることが採用されるメリットになるでしょう。

値(value)オブジェクトを実装してみる

以下のようなUserというドメインモデルが持つIDを値(value)オブジェクトとして表現することを考えてみます。

domain/model.go
type User struct {
	Id     UserId `json:"user_id"`
	Name   string `json:"name"`
}

型エイリアスの使用を考えてみる

以下のような型エイリアスでUserIdという値(value)オブジェクトを表現してみます。

domain/model.go
type UserId int64

この値(value)オブジェクトを作成してみると以下のようになります。

main.go
func main() {
  ui := domain.UserId(1)
  fmt.Println(ui) // 1
}

では、オブジェクトの値を変更してみましょう。

main.go
func main() {
  ui := domain.UserId(1)
  ui = 2 // <- これを追加
  fmt.Println(ui) // 2
}

一度作成した値が途中で変わってしまいました。これは値(value)オブジェクトの重要な特徴である不変性が備わっていません。

構造体の使用を考えてみる

値(value)オブジェクトはクラスが存在する言語ではクラスで表現されます。Goではクラスはありませんが構造体が用意されているので構造体を使用することを考えてみます。

domain/model.go
type UserId struct {
	Value int64
}

func NewUserId(value int64) UserId {
	return UserId{value}
}
main.go
func main() {
	ui := domain.NewUserId(1)
	fmt.Println(ui) // {1}
}

ではこの値の不変性は保たれているでしょうか?

main.go
func main() {
  ui := domain.NewUserId(1)
  ui.Value = 2
  fmt.Println(ui) // {2}
}

UserIdValueの値が公開されてしまっているので値が書き換えられてしまいました。値が書き換えられないように非公開にしてみましょう。

domain/model.go
type UserId struct {
	value int64 // フィールドを非公開に変更
}
main.go
func main() {
	ui := domain.NewUserId(1)
	ui.value = 2 // フィールドが非公開のためコンパイルエラー
	fmt.Println(ui)
}

上記のようにフィールドを非公開にしたことでこの値(value)オブジェクトは不変性を持っていそうです。加えて、値の比較もできるため値(value)オブジェクトとしての性質を持っていそうです。

main.go
func main() {
	ui := domain.NewUserId(1)
	ui2 := domain.NewUserId(1)
	fmt.Println(ui == ui2) // true
}

ジェネリクスを使って汎用的にする

値(value)オブジェクトは一つではなくドメインモデルを表現するために非常に多く作成されることになります。試しに、ユーザー名を表すUserNameという値(value)オブジェクトを追加してみます。

domain/model.go
package domain

type UserId struct {
	value int64
}

func NewUserId(value int64) UserId {
	return UserId{value}
}

// ---- これを追加 ----
type UserName struct {
	value string
}

func NewUserName(value string) UserName {
	return UserName{value}
}
// -------------------

type User struct {
	Id     UserId   `json:"user_id"`
	Name   UserName `json:"name"` // ここも変更
}

表現する値がstringなのかintなのか型が違うだけの実装が増えそうなためジェネリクスを使用して汎用的な実装にしてみたいと思います。

domain/model.go
package domain

// これを追加
type ValueObject[T any] struct {
	value T
}

type UserId struct {
	ValueObject[int64] // ここも修正
}

func NewUserId(value int64) UserId {
	return UserId{ValueObject[int64]{value}} // ここも修正
}

type UserName struct {
	ValueObject[string] // ここも修正
}

func NewUserName(value string) UserName {
	return UserName{ValueObject[string]{value}} // ここも修正
}

type User struct {
	Id     UserId   `json:"user_id"`
	Name   UserName `json:"name"`
}

修正した値(value)オブジェクトを作成して出力してみます。

main.go
func main() {
	ui := domain.NewUserId(1)
	ui2 := domain.NewUserId(1)
	fmt.Println(ui, ui2, ui == ui2) // {{1}} {{1}} true
}

問題なさそうですが構造体がネストする作りのため、文字列として出力したときが見にくいためString()を追加で実装してみます。ついでに、値を取得できるようにValue()も実装してみます。

domain/model.go
package domain

import "fmt"

type ValueObject[T any] struct {
	value T
}

// ---- ここを追加 ----
func (v ValueObject[T]) Value() T {
	return v.value
}

func (v ValueObject[T]) String() string {
	return fmt.Sprintf("%v", v.value)
}
// -------------------

type UserId struct {
	ValueObject[int64]
}

func NewUserId(value int64) UserId {
	return UserId{ValueObject[int64]{value}}
}

type UserName struct {
	ValueObject[string]
}

func NewUserName(value string) UserName {
	return UserName{ValueObject[string]{value}}
}

type User struct {
	Id     UserId   `json:"user_id"`
	Name   UserName `json:"name"`
}

main.go
func main() {
  ui := domain.NewUserId(1)
  ui2 := domain.NewUserId(1)
  fmt.Println(ui, ui2, ui == ui2) // 1 1 true
  fmt.Println(ui.Value(), ui2.Value()) // 1 1
}

これで値(value)オブジェクトをそのままfmt.Println()などに渡しても値のみを出力しますし、Value()を使用して値を取り出すことができるようになったため扱いやすくなりました。もちろん、まだ値(value)オブジェクトの不変性は保たれています。

HTTPサーバーからJSONレスポンスとして返す

以下のようにHTTPサーバーを立てて値(value)オブジェクトを含む構造体をレスポンスとして返すことを考えてみます。

main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"test-value-object/domain"
)

func main() {
	ui := domain.NewUserId(1)
	name := domain.NewUserName("user")
	user := domain.User{Id: ui, Name: name}

	http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(user)
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}
% go run main.go
% curl localhost:8080/user

> {"user_id":{},"name":{}}

レスポンスに肝心の値が含まれていません。これは値(value)オブジェクトとして作成した構造体のフィールドが非公開になっているからです。これはMarshalJSON()を実装することで解決できます。

domain/model.go
package domain

import (
	"encoding/json"
	"fmt"
)

type ValueObject[T any] struct {
	value T
}

func (v ValueObject[T]) Value() T {
	return v.value
}

func (v ValueObject[T]) String() string {
	return fmt.Sprintf("%v", v.value)
}

// これを追加
func (v ValueObject[T]) MarshalJSON() ([]byte, error) {
	return json.Marshal(v.value)
}

type UserId struct {
	ValueObject[int64]
}

func NewUserId(value int64) UserId {
	return UserId{ValueObject[int64]{value}}
}

type UserName struct {
	ValueObject[string]
}

func NewUserName(value string) UserName {
	return UserName{ValueObject[string]{value}}
}

type User struct {
	Id   UserId   `json:"user_id"`
	Name UserName `json:"name"`
}

% go run main.go
% curl localhost:8080/user

> {"user_id":1,"name":"user"}

型制約を実装する

値(value)オブジェクトはその性質上プリミティブな値を表現することが多いでしょう。構造体やスライスのような複雑な型を値(value)オブジェクトとして扱うことも可能かもしれませんがその不変性を確保したりするには実装がかなり複雑になってしまいます。そこで、値(value)オブジェクトとして作成できる値の型を制限することを考えてみます。これは以下のような制約になるようなインターフェースを実装することで実現することができます。

domain/model.go
package domain

import (
	"encoding/json"
	"fmt"
)

// これを追加
type primitive interface {
  ~int | ~int8 | ~int16 | ~int32 | ~int64 |
  ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
  ~float32 | ~float64 |
  ~bool |
  ~string
}

// anyをprimitiveに変更
type ValueObject[T primitive] struct {
	value T
}

func (v ValueObject[T]) Value() T {
	return v.value
}

func (v ValueObject[T]) String() string {
	return fmt.Sprintf("%v", v.value)
}

func (v ValueObject[T]) MarshalJSON() ([]byte, error) {
	return json.Marshal(v.value)
}

type UserId struct {
	ValueObject[int64]
}

func NewUserId(value int64) UserId {
	return UserId{ValueObject[int64]{value}}
}

type UserName struct {
	ValueObject[string]
}

func NewUserName(value string) UserName {
	return UserName{ValueObject[string]{value}}
}

type User struct {
	Id   UserId   `json:"user_id"`
	Name UserName `json:"name"`
}

このように実装することで以下のような構造体などの値を値(value)オブジェクトとして表現できなくなります。

type TestStruct struct {
	Field1 string
	Field2 string
}

type TestValue struct {
	ValueObject[TestStruct] // これはコンパイルエラー
}

まとめ

今回はGoで値(value)オブジェクトを実装する方法について紹介しました。

DDDを本格的に実践せずに値(value)オブジェクトのような概念のみを扱うのは軽量DDDというアンチパターンとされることが多いようですが、ドメイン層のロジックを組みやすくなるならば値(value)オブジェクトのみ採用するのもありなんじゃないかなと筆者個人としては思います。

この記事がGoでDDDを実践しようとしている方や値(value)オブジェクトを実装しようとしている方の参考になれば嬉しいです。

今回は以上です🐼