Yappli Tech Blog

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

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

概要

こんにちは。サーバーサイドエンジニアの窪田です。 最近入社して、初めてGoを業務で使うことになりました。

ヤプリではDDDでサーバーサイドの設計を行なっています。 これまで、Nest.jsというTypeScript製のフレームワークでDDD設計をしてきた経験がありましたが、 初めて触れるGoではどのようにかけるのかということを整理していこうと思います。

実戦ドメイン駆動設計(V. Vernon)には戦略的DDD、戦術的DDDが紹介されています。 今回は戦術的DDDにフォーカスし(なぜなら言語仕様に左右される要素があるから)、 その中でもドメインモデルの設計に限定して考えて行きます。

特に、TypeScriptではこう書いていたが、Goではどのように書くのが良いかということを中心に考えます。

要件・目指す状態

  • ドメインモデルが定義されている
  • ドメインモデルの振る舞いが定義されている
  • 生成されたモデルのインスタンスはドメインルールを守ったインスタンスになる(逆にいうと、ドメインルールを破ったインスタンスは存在できないようになっている)
  • モデルの振る舞いのテストが簡単に書ける状態にある

が満たされていればドメインモデルの設計としては十分だと思います。 それぞれについてみて行きます。

ドメインモデルの定義

例えば、ECサービスにおけるユーザーというentityがあるとします。 各ユーザーはuserNameを属性として持つとします。

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

// ユーザーモデルの定義
class User {
  id: number;
  userName string;

  constructor({ id, userName }: { id: number, userName: string }) {
    this.id = id;
    this.userName = userName;
  }
}

// ユーザーモデルのインスタンスを生成
const user = new User({ id: 1, userName: 'ワンパンマン' });

idは識別子です。 ドメインルールに直接表現がなくてもentityとして扱うならば戦術的DDDの設計上必要になります。(値オブジェクトでは不要です)。 現実世界で言えばDNA配列みたいなものです。 これに対応するGoの定義は以下です。

// ユーザーモデルの定義
type User struct {
  Id int
  UserName string
}

// constructorにあたる、モデルのインスタンスを作る関数を用意する
func NewUser(id int, userName string) (*User, error) {
  u := &User{
    Id: id,
    UserName: userName,
  }
  return u, nil
}

// ユーザーモデルのインスタンスを生成
user := NewUser(1, "ワンパンマン")

Goではclassという概念はなく、構造体(Struct)でモデルを定義するのが一般的です。 以上でドメインモデルをシステム上に簡潔に書くことができました。

ドメインモデルの振る舞いを定義する

ドメインモデルには振る舞いがあります。 例えば定義したユーザーモデルはECサイトの上でuserNameを自由に変更できるとします。 これをドメインモデル上の振る舞いとして反映させます。

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

class User {
  id: number;
  userName string;

  constructor({ id, userName }: { id: number, userName: string }) {
    this.id = id;
    this.userName = userName;
  }

  changeUserName(newName: string) {
    this.userName = newName
  }
}

const user = new User({ id: 1, userName: 'ワンパンマン' });
console.log(user.userName); // -> ワンパンマン

user.changeUserName('さいたま');
console.log(user.userName); // -> さいたま

changeUserNameというメソッドを追加してインスタンスのuserNameを変更できるようになりました。

これに対応するGoのメソッド定義は以下です。

type User struct {
  Id int
  UserName string
}

func (u *User) changeUserName(newName string) {
  u.UserName = newName
}

func NewUser(id int, userName string) (*User, error) {
  u := &User{
    Id: id,
    UserName: userName,
  }
  return u, nil
}

user := NewUser(1, "ワンパンマン")

fmt.Println(user.UserName) // -> ワンパンマン
user.changeUserName("さいたま")
fmt.Println(user.UserName) // -> さいたま

できました。 続いてドメインルールを追加していきます。

生成されたモデルのインスタンスはドメインルールを守ったインスタンスになる設計を目指す

ドメインルールをモデルに表現する

ドメインモデルはそれを見るだけで業務上のドメインルールがわかるように設計されていることが理想です。 例えば、

  • idが1001~2000のユーザーはVIPユーザーで10文字までユーザー名を入力できる
  • それ以外のユーザーは5文字までしかユーザー名を入力できない

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

これはTypeScriptでは以下のように表現できます。

class User {
  id: number;
  userName string;

  constructor({ id, userName }: { id: number, userName: string }) {
    const isVip = id > 1000 && id <= 2000;
    if (isVip && userName.length > 10) {
      throw new Error('ユーザー名は10文字以内に設定してください');
    }

    if (!isVip && userName.length > 5) {
      throw new Error('ユーザー名は5文字以内に設定してください');
    } 

    this.id = id;
    this.userName = userName;
  }

  changeUserName(newName: string) {
    this.userName = newName
  }
}

const userNg = new User({ id: 1, userName: 'ワンパンマン' }); // -> throw Error
const userOk = new User({ id: 1500, userName: 'ワンパンマン' }); // -> OK

もしこのシステムのドメインルールを守らないid, userNameの組み合わせが与えられた場合、constructorでerrorを発生させ、インスタンス化が失敗します。 これにより、User classのインスタンスは生成された時点でドメインルールが守られていることを担保できます。

同じことをGoで書くと以下のようになります。

type User struct {
  Id int
  UserName string
}

func (u *User) changeUserName(newName string) {
  u.UserName = newName
}

func NewUser(id int, userName string) (*User, error) {
  isVip := id > 1000 && id <= 2000
  if isVip && utf8.RuneCountInString(userName) > 10 {
    return nil, errors.New("ユーザー名を10文字以内に設定してください")
  }
  if !isVip && utf8.RuneCountInString(userName) > 5 {
    return nil, errors.New("ユーザー名を5文字以内に設定してください")
  }
  u := &User{
    Id: id,
    UserName: userName,
  }
  return u, nil
}

user, err := NewUser(1, "ワンパンマン")

fmt.Println(err.Error()) // -> ユーザー名を5文字以内に設定してください

このように書くことで、ドメインルールが守られていないuserインスタンスが生成されることはなくなります。 しかし、これでドメインルールが完璧に守られる仕組みができたのかというと実はそうではありません。

生成されたインスタンスを不用意に変更できないようにする

せっかくドメインモデル上にルールを表現し、ドメインルールに整合したインスタンスだけが生成される仕組みを作りました。 しかし、実は上記のコードで生成したインスタンスは簡単に変更できてしまいます。

const userOk = new User({ id: 10, userName: 'さいたま' }); 
userOk.userName = 'ワンパンマン';

console.log({ userOk }); // -> { id: 10, userName: 'ワンパンマン' }

せっかくドメインルールを反映させたドメインモデルを綺麗に書いたのに、この1行によって生成されたインスタンスはドメインルールを守れていない(VIPじゃないのに5文字を超えるユーザー名を設定できてしまっている)ことになってしまいます。

そこで、Userモデルのインスタンスのプロパティはreadonlyにして直接書き換えることができないようにします。 以下のようにかけます。

type Mutable<T> = { -readonly [P in keyof T]: T[P] };
class User {
    readonly id: number;
    readonly userName string;

    changeName(name: string) {
        (this as Mutable<User>).userName = name;
    }
}

const user = new User({ id: 1500, userName: 'ワンパンマン' });
user.userName = 'invalid name'; // -> コンパイルエラー

Userモデルの各propertyにはreadonlyをつけることによって、 例えばドメインモデルのメソッド以外で破壊的変更を加えるような処理は コンパイルエラーになり弾けます。 changeNameメソッドの中だけreadonlyを外した型にアサーションすることによりクラスインスタンスの破壊的変更を許可できます。 他にもObject.assignを用いる方法等もあります。

同じようにGoでオブジェクトをimmutableにする方法は以下です。

type User struct {
  id int
  userName string
}

func (u *User) ID() int {
  return u.id
}

func (u *User) UserName() string {
  return u.userName
}

func (u *User) changeUserName(newName string) {
  u.userName = newName
}

user, err := NewUser(1, "ワンパンマン")

// 外部packageで
user.userName = "InvalidName" // -> コンパイルエラー

先ほどの例から変わったところは

  • User structのid, userNameが非公開になった(Goはproperty名を小文字で書くとpackage内でprivateになる)
  • Getterをかましてid, userNameを公開する

という点です。 これによって、package外部からは直接の構造体の破壊的変更はできなくなります。 domainモデルをimmutableにするということが実現できました。

残された課題

上でGoでdomain entityを実装するときimmutableにする方法を紹介しました。 しかし、実はこれには大きく2個の課題があります。

  • package内では破壊的変更ができてしまう
  • 全てのpropertyに対応したgetterを実装するのが大変

という課題です。

package内では破壊的変更ができてしまうという課題

Goでは構造体の破壊的変更は同じpackage内では可能です。 例えばentityというpackage内で全てのentityを定義しているとすると上の例は

// user_entity.go
package entity
type User struct {
  id int
  userName string
}


// other_entity.go
package entity
user.userName = "InvalidName"

となり、User構造体が定義されたpackage内ではコンパイルエラーにはなりません。 これを防ぐにはentityごとにpackageを切る(例えばpackage user_entity)という方法がありますが、DDDの設計がpackage設計に影響してしまうことになり必ず成立させられるとは限りません。

全てのpropertyに対応したgetterを実装するのが大変という課題

実際に業務で扱うモデルは種類も多く、一つ一つのモデルに多くのpropertyが定義されることが想定されます。 今回のようにIDとUserNameで事足りるモデルはほとんどないはずです。

それらの多くの種類のモデルに対して全てのpropertyに対してgetterを生やしていくのは大変であると同時にファイルの長さも長くし可読性を落とす可能性もあります。 そこで、ある程度immutable性を諦めてpropertyを公開する実装にするのも一つの選択肢になります。

以上の課題にyappliはどう向き合っているか

上で挙げた課題は実際にyappliのサーバーチームで出てきた課題です。

それぞれの課題についてメンバーで熱く議論して全員の認識をそろえていく活動も業務の中でしていきます。

例えば、immutable性のことについては業務効率を考えpropertyを公開する実装を選択しました。

おそらく、他の企業やプロダクトでは違う結論にもなり得たと思います。 この辺りの選択肢がいくつもある中での議論ができるのもyappliのサーバーチームで働く面白さの一つだと思います。

Goに興味がある方、TypeScriptに興味がある方、DDDに興味がある方、チームビルディングに興味がある方、ぜひ一度カジュアルにお話ししてみませんか!

open.talentio.com