ryota21silvaの技術ブログ

Funna(ふんな)の技術ブログ

これまで学んだ技術の備忘録。未来の自分が救われることを信じて

【Go】Goでドメインオブジェクトをどのようにして生成すべきか

ゴール

  • ドメインルールを守ったオブジェクトであることを保証したい。
  • Goの言語使用上外部からの変更(同一パッケージ内からの変更)を完全に防ぐことは難しいが、それでもなるべくドメインオブジェクトの不変性を保ちたい。

方針その1. 構造体のフィールドをパッケージ内でのみアクセス可能にする

以下の手順で実装する。

  • 構造体のフィールドの先頭を小文字にする
  • コンストラクタを用意する
  • パッケージ内でのみアクセス可能なフィールドを変更するためのメソッドを用意する
  • Getterを用意する
type User struct {
    // フィールドを小文字にすることで、パッケージ内でしか取得・変更できないようにする
    id int
    name string
    email string
    password string
    createdAt time.Time
    updatedAt time.Time
}

// コンストラクタを用意する
func NewUser(id int, name string, email string, password string, createdAt time.Time) *User {
    return &User{
        id:        id,
        name:      name,
        email:     email,
        password:  password,
        createdAt: createdAt,
        updatedAt: createdAt,
    }
}

// 「パッケージ内でのみアクセス可能なフィールド」を変更するためのメソッド
func (u *User) ChangeName(name string) {
    u.name = name
}

// Getterを用意する
func (u *User) CreatedAt() time.Time {
    return u.createdAt 
}
// 外部packageのコード
user, err := NewUser(1, "John")
user.ChangeName("Bob") // 変更可能
user.userName = "Mike" // コンパイルエラー

デメリット

Package内では破壊的変更が可能

  • いわゆるOOPにおけるProtectedでの制限はできるが、Privateでの制限はできない。
  • 以下のようなパッケージ構成をすれば破壊的変更を防げるが、管理が大変かつDDDのパッケージ構成にそぐわないと思う。

      ├── domain
      │   ├── user
      │   │   └── user.go
      │   ├── userid
      │   │   └── user_id.go
      │   └── username
      │       └── user_name.go
    

Getterを用意するのが面倒

フィールドごとにGetterを用意する必要がある。

type User struct {
  id int
  userName string
}

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

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

方針その2. インターフェイスを用いることでコンストラクタの使用を強制する

インターフェイスを用いてエンティティや値オブジェクトを生成すればコンストラクタの使用を強制することができるため、ドメインルールを守ったオブジェクトであることを保証できそう。

// インターフェイス
type UserNameInterface interface {
    Get() string
}

// 実装側の構造体を先頭小文字にする
type userNameImpl struct {
    UserNameInterface  // 省略可能だがどのインターフェイスを実装しているかあえて明示している
    username string
}

// インターフェイスを満たすためのメソッド
// Goでは「インターフェースの中にある同じ名前のメソッドを全て定義すれば、自動的にインターフェイスを実装したことになる
func (i *userNameImpl) Get() string {
    return i.username
}

// コンストラクタ
func NewUserName(s string) (UserNameInterface, error) {
    if err := validateUsername(s); err != nil {
          return nil, err
    }

    return &userNameImpl{username: s}, nil
}

// バリデーション
func validateUsername(s string) error {
    s = strings.Trim(s, " ")
    c := utf8.RuneCountInString(s) // 日本語(マルチバイト)の文字列を数える
    switch {
    case c == 0:
        return fmt.Errorf("名前を入力してください")
    case c > 10:
        return fmt.Errorf("名前は10文字以内で入力してください")
    }
    return nil
}
u := UserNameInterface{} // エラー
u := userName{username: "hoge"} // エラー
u, err := NewUserName("hoge") // OK
if err != nil {
  // エラーハンドリング
}

結論

方針その2「インターフェイスを用いることでコンストラクタの使用を強制する」が良さげ。

実装例

コードの例があまり良くないかもだが、備忘録として実装したコードを書き記しておく。

  • UserBookエンティティ
package userbook

import (
    "github.com/ryota1116/stacked_books/domain/model/book"
    "time"
)

type UserBookInterface interface {
    UserId() UserIdInterface
    BookId() BookIdInterface
    Status() StatusInterface
    Memo() MemoInterface

    ChangeMemo(value *string) error
}

type userBook struct {
    userId    UserIdInterface
    bookId    BookIdInterface
    status    StatusInterface
    memo      MemoInterface
}

func NewUserBook(
    userId int,
    bookId int,
    status int,
    memo *string,
) (UserBookInterface, error) {
    s, err := NewStatus(status)
    if err != nil {
        return &userBook{}, err
    }

    m, err := NewMemo(memo)
    if err != nil {
        return &userBook{}, err
    }

    return &userBook{
        userId: NewUserId(userId),
        bookId: NewBookId(bookId),
        status: s,
        memo:   m,
    }, nil
}

func (ub *userBook) UserId() UserIdInterface {
    return ub.userId
}

func (ub *userBook) BookId() BookIdInterface {
    return ub.bookId
}

func (ub *userBook) Status() StatusInterface {
    return ub.status
}

func (ub *userBook) Memo() MemoInterface {
    return ub.memo
}

func (ub *userBook) ChangeMemo(value *string) error {
    return ub.memo.changeMemo(value)
}
  • 値オブジェクトであるMemo
package userbook

import (
    "fmt"
    "unicode/utf8"
)

const maxCount = 255 // メモの最大文字数

type MemoInterface interface {
    Value() *string
    changeMemo(value *string) error
}

type memo struct {
    value *string // NOTE: ポインタ型にすることでnilを許容している
}

func NewMemo(value *string) (MemoInterface, error) {
    if err := validate(value); err != nil {
        return nil, err
    }

    // &でポインタ型を生成
    return &memo{value}, nil
}

func (m *memo) Value() *string {
    return m.value
}

func (m *memo) changeMemo(value *string) error {
    if err := validate(value); err != nil {
        return err
    }

    m.value = value
    return nil
}

func validate(value *string) error {
    // nilではない場合、値をチェックする
    if value != nil {
        memoCount := utf8.RuneCountInString(*value)

        if memoCount > maxCount {
            return fmt.Errorf("メモは255文字以下で入力ください。")
        }
    }

    return nil
}
  • 呼び出し元はこんな感じにする。
// エンティティの取得
ub, err := userBookRepository.FindOne(id)
if err != nil {
    // エラーハンドリング
}

// エンティティの更新
value := "更新後のメモの内容"
if err := ub.ChangeMemo(&value); err != nil {
    // エラーハンドリング
}


u, err := userBookRepository.Save(ub)
if err != nil {
    // エラーハンドリング
}

2023/03/26 追記 : ChatGPTさんに考えてもらう

ChatGPTさんに何回質問しても堂々巡りになったので(私のプロンプトが悪いやんなごめんな😇)端的に回答してくれたモノだけ載せておく。 主にChatGPTさんが考えてくれたことは

  1. 構造体のフィールドを小文字にして、同一パッケージ内からのアクセスしかできないように制限する。
  2. コンストラクタを用意して、ドメインルールを保証したオブジェクトを生成する。
  3. Getter、Setterを用意する
  4. Go言語にはアクセス修飾子が無く外部からのアクセスを完全に制限することはできないので、、コーディングルールを設けてチーム内に浸透させる。

ということで「方針その1. 構造体のフィールドをパッケージ内でのみアクセス可能にする」と同じ方法でした。

参考