Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

戦術的DDDをGoで実現する【Value Object編】

はじめに

こんにちは。サーバサイドエンジニアの窪田です。

前回の 戦術的DDDをGoで実現する【entity編】 - Yappli Tech Blog に続き、 今回は戦術的DDDにおける、Value ObjectがGoでどのように書けるのかを考えていきます。 例によってTypeScriptとの書き方の違いも一緒に考えていきます。

要件・目指す状態

  • ドメインルールを表したドメインモデルが定義されている
  • 値の性質を満たした実装がされている

という状態を目指します。

書いてみる

ドメインモデルを定義する

例えば、ある登録制のサービスでのユーザーネームについて考えます。 そのユーザーネームが

  • 5文字以内でなくてはいけない
  • 半角英数字のみでなくてはいけない

というドメインルールがあるとします。

TypeScriptでは以下のように書けます。

class UserName extends String {
  constructor(params: string) {
    if (params.length > 5) {
      throw new Error('ユーザーネームは5文字以内にしてください。');
    }
    if (!params.match(/^[A-Za-z0-9]*$/)) {
      throw new Error('ユーザーネームは半角英数字にしてください。');
    }
    super(params);
  }
}

Goでは以下のように書けます。

type UserName string

func NewUserName(n string) (UserName, error){
    if utf8.RuneCountInString(params) > 5 {
        return UserName(""), errors.New("ユーザーネームは5文字以内にしてください。")
    }
    if m, _ := regexp.MatchString("^[0-9a-zA-Z]+$", params); !m {
        return UserName(""), errors.New("ユーザーネームは半角英数字にしてください。")
    }

    return UserName(params), nil
}

上の例は扱う値がプリミティブな値の場合です。 プリミティブな値の場合、Defined Typeを使うことで単純なstringを新たに定義したUserNameというtypeで扱うことができるようになります。

次に、緯度、経度を属性にもつ座標を表すValue Objectについて考えます。 これはentityの記事でも示した実装がそのまま使えます。

type Coordinate struct {
    Latitude int
    Longitude int
}

func NewCoordinate(lat int, lon int) (*Coordinate, error){
    if lat < -90 || lat > 90 {
        return nil, errors.New("緯度の範囲が不正です。")
    }
    if lon < -180 || lon > 180 {
        return nil, errors.New("経度の範囲が不正です。")
    }
    return &Coordinate{
        Latitude:  lat,
        Longitude: lon,
    }, nil
}

できました! DDDにおいては、ユーザーネームも座標も一つのValue Objectですが、実装上はDefined Typeを使うかstructを使うかの違いが出てきます。

値の性質を満たした実装をする

ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 電子書籍|翔泳社の本 で述べられているValue Objectの特徴は以下です。

  • 不変である
  • 再代入可能である
  • 等価比較できる

不変である

明らかにおかしい例を示します。

a := 1
fmt.Println(a) -> 1
a.changeToTwo() // 仮に変更できるメソッドがあるとして
fmt.Println(a) -> 2

このように1という値を2に変更することはできません。 これは1という値自体が不変であるということです。 1自体の値が変更されることはなく、この例がおかしいということは感覚的にも受け入れられます。

値と同様にValue Objectはこの性質を持ちます。 緯度経度を示す座標 Value Objectについても

c := NewCoordinate(35, 135)
fmt.Println(c.Latitude) // -> 35
c.changeLat(40)
fmt.Println(c.Latitude) // -> 40

ということができないように実装する必要があります。 これを満たすのは簡単で、単純にCoordinate structに内部プロパティを変更するメソッドを実装しないことで実現できます。 entity, Value Objectの実装上の大きな違いの一つがこの内部変更のメソッドの有無です。

再代入可能である

以上の例から、値は不変であることを確認しました。 値は不変である変わりに再代入することで変数が示す値を変更することができます。

a := 1
a.changeToTwo() // これはできない
a = 2 // これはできる

以上のように1という値自体を2に変更することはできませんが、 aという変数が示す値を1から2に変更することは再代入によりできます。 これが値の性質の一つです。

この再代入可能であるという性質もValue Objectは満たす必要があります。

c, _ := NewCoordinate(35, 135)
fmt.Println(c.Latitude) // -> 35
c, _ = NewCoordinate(40, 135)
fmt.Println(c.Latitude) // -> 40

今までの実装でValue Objectを再代入することで変数cの示す値が変更できることが確認できます。

等価比較できる

等価比較できる、というのも値の性質です。 すなわち、Value Objectが持つべき性質です。

まず、単純な例を示します。

fmt.Println(1 == 1) // -> true
fmt.Println(1 == 2) // -> false

1は1と等価で、1は2と等価でないということを示しています。

同じように、Value Objectについても等価比較できるようにEqualsメソッドを用意します。

import (
    "fmt"
    "reflect"
)
type Coordinate struct {
    Latitude int
    Longitude int
}

func NewCoordinate(lat int, lon int) (*Coordinate, error){
    if lat < -90 || lat > 90 {
        return nil, errors.New("緯度の範囲が不正です。")
    }
    if lon < -180 || lon > 180 {
        return nil, errors.New("経度の範囲が不正です。")
    }
    return &Coordinate{
        Latitude:  lat,
        Longitude: lon,
    }, nil
}

func (c *Coordinate) Equals(v *Coordinate) bool {
    return reflect.DeepEqual(c, v) 
}

c1, _ := NewCoordinate(35, 135)
c2, _ := NewCoordinate(35, 135)
c3, _ := NewCoordinate(40, 135)

fmt.Println(c1.Equals(c2)) // -> true
fmt.Println(c1.Equals(c3)) // -> false

Equalsメソッドによって (北緯35度, 東経135度)の座標c1とc2は等価であることを表現でき、 c1と(北緯40度, 東経135度)の座標c3は等価ではないことを表現できました。

ちなみに、struct同士は比較演算子「==」を使うことで等価比較できますが、 Value Objectがポインタで表現されている場合や、 プロパティにポインタがある場合は==演算子では等価比較できません。 Value Objectは値の性質をもつ概念ですが、言語の実装上はポインタとして扱うことはよくあります。 reflect.DeepEqualによる比較、または自前実装や他ライブラリを使うことで正しく比較できるように注意する必要があります。

この等価比較できる性質は値のもので、entityとValue Objectで実装上大きな違いが出るポイントになります。 現実世界では、同じユーザーネームのUserを同一とみなさないとき、 戦術的DDDにおいてはこれはentityとして定義でき

type User struct {
    ID string
    UserName string
}

のように必ず識別子を実装することになります。

実装上はentityのインスタンスが同一かを判断するにはID(識別子)のみの比較でよく、 各プロパティを比較する必要がありません。

// あるuseCase内で

ua := &User{ ID: "a", UserName: "同じ名前" }
ub := &User{ ID: "b", UserName: "同じ名前" }

isEqual := ua.ID == ub.ID

このように特にUserモデルにmethodとして持たせるのではなく、ドメインモデルを操作するUseCase等の場所で インスタンスのID同士を比べることで同じドメインモデルのインスタンスなのかを判定するべきです(同じインスタンスかどうか比べることはそうそうなさそうですが)。 この時、UserName(つまりID以外の全ての属性)が同じでもIDが違えば当然別のインスタンスとして扱いますし、IDが同じでもUserNameが変化することはあります。

yappliでのValue Objectを使用した事例

yappliのシステムでもValue Objectがいくつも登場します。 一例を示します。

yappliはノーコードでアプリが作れるプラットフォームで、クライアントごとにアプリを管理しています。 それぞれのアプリをアプリ識別子で管理しており*1、このアプリ識別子は設計上Value Objectとして扱っています。

以前はこのアプリ識別子はシステム上ただの文字列として扱っていました。 しかし、実際の業務上ではアプリひとつひとつを管理する識別子として重要な役割を果たすドメインモデルであり、 アプリ識別子としての文字列のドメイン上の制約(文字数の制約や文字の種類の制約など)が存在していました。

このような事情があり、システム上でもアプリ識別子をただの文字列ではなくValue Objectとして定義し直した経緯がありました。 実際のコードを直接お見せできませんが、前章までで紹介したような書き方で定義しています。

おわりに

今回はGoで戦術的DDDにおけるValue Objectを実装することについて考えてみました。 ドメインオブジェクトの定義はstructを定義することで実現できました。 プリミティブな値についてもDefined Typeを用いることで定義できました。

また、Goで値の3個の性質である

  • 不変である
  • 再代入可能である
  • 等価比較できる

について満たしたValue Objectの実装を確認することができました。

yappliで開発しているプロダクトの設計はDDDを採用しており、 紹介したアプリ識別子以外にもValue Objectがいくつも登場します。

この記事ではValue Objectとentityの比較も少し紹介していますが、 それは実装上の話で業務上定義されているドメインモデルをentityで扱うかValue Objectで扱うかは設計次第です。 ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 電子書籍|翔泳社の本ではトラックのタイヤを扱う時、 entityとして扱うシステムもあればValue Objectとして扱うというシステムもあるという例が出ていましたね。

この、「entityとして定義すべきか、Value Objectとして定義すべきか」ということも一つの設計上の課題になります。 前回、entityの実装を紹介した記事でDDDで設計していく上での課題や それについての議論をサーバーサイドエンジニアのチームでしていることを紹介しました。 大体、週1回の定例でこの「entityとして定義すべきか、Value Objectとして定義すべきか」なども議論しています。 色々な人の色々な意見を聞けてとても楽しい議論の場になっています。

興味を持たれた方はぜひカジュアル面談にお越しください! open.talentio.com