前書き:GolangでValue Objectを作りづらい

2025年の抱負で「ブログのアウトプットを増やす(リンク先の末尾を参照)」と宣言したので、早速アウトプットします。

今回取り扱うValue Objectとは、主に以下のような特徴を持ちます(本記事の本題と関係ない要素は意図的に省略しています)

  • 不変(Immutable)
  • オブジェクトは、値が同値の時に等しい

Golangで不変の仕様を満たすには、構造体の中にプライベートフィールドを用意し、プライベートフィールドにアクセスするGetterメソッドを準備する必要があります。以下に、サンプルコードを示します。

type Person struct {
	name string
}

func NewPerson(name string) Person {
	return Person{name: name}
}

func (o Person) Name() string {
	return o.name
}

func (o Person) Equal(other Person) bool {
	return o.Name() == other.Name() 
}

Person構造体が持つnameフィールドは可視性がプライベートなため、NewPerson()した後は不変です。Getterメソッドで経由で値にアクセスします。値が同値かどうかは、Equal()でチェックします。

このような実装は、ハッキリ言って面倒くさいです。また、Getter()Equal()にユニットテストを書くのも馬鹿らしいです。私は「Kotlinのvalue classdata classがGolangにもあればな〜」と無い物ねだりをしていました。

仕組みがなければ作ればいいじゃない。

ということで、nao1215/vogenパッケージを2時間ぐらいで作りました。

vogenは、メタデータからValue Objectを生成

nao1215/vogenパッケージは、New()GetterEqual()を持つValue Objectコードを自動生成するライブラリです。Value Object Generatorの略です。

GolangでValue Objectのメタデータを書き、そのメタデータを元にコードを自動生成します。この仕様の元ネタは、shogo82148/myddlmaker(メタデータからDB DDLを生成するライブラリ)です。

想定の利用方法としては、value_object/gen/main.goにメタデータを定義し、go generate ./...value_object/value_object.goファイルを生成します。出力先を複数のファイルに分散することもできます。

以下、value_object/gen/main.goに実装例です。

package main

import (
	"fmt"
	"path/filepath"

	"github.com/nao1215/vogen"
)

//go:generate go run main.go

func main() {
	// Step 1: Create a Vogen instance with custom file path and package name.
	// By default, the file path is "value_objects.go" and the package name is "vo".
	gen, err := vogen.New(
		vogen.WithFilePath(filepath.Join("testdata", "example_output.go")),
		vogen.WithPackageName("vo_example"),
	)
	if err != nil {
		fmt.Printf("Failed to create Vogen instance: %v\n", err)
		return
	}

	// Step 2: Append the ValueObject definition
	if err := gen.AppendValueObjects(
		vogen.ValueObject{
			StructName: "Person",
			Fields: []vogen.Field{
				{Name: "Name", Type: "string", Comments: []string{"Name is the name of the person."}},
				{Name: "Age", Type: "int", Comments: []string{"Age is the age of the person."}},
			},
			Comments: []string{
				"Person is a Value Object to describe the feature of vogen.",
				"This is sample comment.",
			},
		},
		// Use auto generated comments.
		vogen.ValueObject{
			StructName: "Address",
			Fields: []vogen.Field{
				{Name: "City", Type: "string"},
			},
		},
	); err != nil {
		fmt.Printf("Failed to append ValueObject: %v\n", err)
		return
	}

	// Step 3: Generate the code
	if err := gen.Generate(); err != nil {
		fmt.Printf("Failed to generate code: %v\n", err)
		return
	}
}

vogen.New() では、生成先のファイルパスやパッケージ名を指定していますが、省略できます。省略した場合は、value_objects.go ファイルが vo パッケージとして生成されます。

vogen.ValueObject()がメタデータに相当します。構造体コメントや構造体フィールドコメントは省略可能です。省略した場合は、魂のこもっていない英文コメントが出力されます。型にはDefined Type(ユーザーが定義した型)を指定できますが、その場合はモジュールパスを指定する必要があります。Defined Type絡みはテストしていないので、意図的にExampleコードからその存在を隠しています(後日テスト予定)

上記のサンプルコードを利用して自動生成されたコードを以下に示します。

// Code generated by vogen. DO NOT EDIT.
package vo_example

import (
	"fmt"
)

// Person is a Value Object to describe the feature of vogen.
// This is sample comment.
type Person struct {
	// Name is the name of the person.
	name string
	// Age is the age of the person.
	age int
}

// NewPerson creates a new instance of Person.
func NewPerson(name string, age int) Person {
	return Person{name: name, age: age}
}

// Name returns the name field.
func (o Person) Name() string {
	return o.name
}

// Age returns the age field.
func (o Person) Age() int {
	return o.age
}

// Equal checks if two Person objects are equal.
func (o Person) Equal(other Person) bool {
	return o.Name() == other.Name() && o.Age() == other.Age()
}

// Address represents a value object.
type Address struct {
	city string
}

// NewAddress creates a new instance of Address.
func NewAddress(city string) Address {
	return Address{city: city}
}

// City returns the city field.
func (o Address) City() string {
	return o.city
}

// Equal checks if two Address objects are equal.
func (o Address) Equal(other Address) bool {
	return o.City() == other.City()
}

微妙な点1:大して楽ではない

勢いで作ったので、利便性の低さが気になっています。

「自動生成コードは、テストカバレッジの計測対象外にしよう」というルールがあったとしても、vogenパッケージに便利さを感じていません。「メタデータを書く量」と「自動生成されるコード量」が見合ってません。“自分で書いた方が早インパラ(元ネタ)“という気持ちになる可能性があります。

まず、厳密なValue Objectをプロダクションコードに導入したことがないので、「今までValue Objectがなくても上手く動いていたのに、今更こんな厳密な仕組みを導入するんですか?」という気持ちが前にでてきてしまいます。

微妙な点2:New()時にバリデーションできない

「バリデーションされた値を保持している」という状態は、安心感があります。しかし、vogenパッケージはそのような機能を導入できていません。構造体生成時に受け取る値を無条件で信用します。ピュアな奴め。

メタデータにバリデーション用関数を追加する案が思い浮かびましたが、その場合は実装が素直になりません。また、メタデータの記述量が増えるので、「vogenパッケージを使わず、素直に自分でValue Objectを実装したほうが楽」と考えてしまいます(少なくとも私はそのように考えます)

2025年1月1日追記

バリデーション機能を追加してみました。以下のように、vogen.ValueObject.Validatorsに任意のバリデーションを追加します。

	if err := gen.AppendValueObjects(
		vogen.ValueObject{
			StructName: "Person",
			Fields: []vogen.Field{
				{
					Name: "Name", Type: "string",
					Comments: []string{"Name is the name of the person."},
					Validators: []vogen.Validator{
						vogen.NewStringLengthValidator(0, 120), // バリデーション1
					},
				},
				{
					Name: "Age", Type: "int",
					Comments: []string{"Age is the age of the person."},
					Validators: []vogen.Validator{
						vogen.NewPositiveValueValidator(), // バリデーション2
						vogen.NewMaxValueValidator(120), // バリデーション3
					}},
			},
			Comments: []string{
				"Person is a Value Object to describe the feature of vogen.",
				"This is sample comment.",
			},
		},
	); err != nil {
		fmt.Printf("Failed to append ValueObject: %v\n", err)
		return
	}

以下のようなNewPersonStrictly()が自動生成されます。バリデーションのないNewPerson()も自動生成されます。

// NewPerson creates a new instance of Person.
func NewPerson(name string, age int) Person {
	return Person{name: name, age: age}
}

// NewPersonStrictly creates a new instance of Person with validation.
func NewPersonStrictly(name string, age int) (Person, error) {
	o := Person{name: name, age: age}
	if len(o.name) < 0 || len(o.name) > 120 {
		return fmt.Errorf("struct 'Person' field 'Name' length is out of range: %d", len(o.name))
	}
	if o.age < 0 {
		return fmt.Errorf("struct 'Person' field 'Age' value is negative: %d", age)
	}
	if o.age > 120 {
		return fmt.Errorf("struct 'Person' field 'Age' value exceeds the maximum value: %d", age)
	}
	return o, nil
}

最後に

最近はDDD(Domain-Driven Design)やアーキテクチャを勉強していることもあり、「理論に合わせてなるべく厳密に実装してみよう」とトライしている最中です。その一環で、vogenパッケージを施策してみました。

「Golangを使わない」、「Golangで厳密なValue Objectを採用しない」、「GolangでもキッチリValue Objectを適用する」など、色々な判断ができます。手探りで色々試していきたい所存です。