iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 19

[Day 19] Domain Model 設計:定義專案最核心的商業規則

  • 分享至 

  • xImage
  •  

在我們的六角形架構中,Domain 層處於同心圓的最中心。它代表了我們業務的本質,是整個系統中最穩定、最不應輕易變動的部分。但是,一個好的 Domain Model 應該是什麼樣子?僅僅是一個包含幾個欄位的 struct 嗎?

貧血模型 vs. 充血模型

在軟體設計中,對於領域模型有兩種常見的風格:

  1. 貧血領域模型 (Anemic Domain Model):這是一種反模式。在這種模型中,領域物件(如 User struct)只包含一堆公開的欄位或 Get/Set 方法,沒有任何業務邏輯。所有的業務邏輯都散落在外部的 ServiceUsecase 層中。這會導致業務規則分散,難以維護,且領域物件自身無法保證其資料的有效性。

  2. 充血領域模型 (Rich Domain Model):在這種模型中,領域物件不僅包含資料,更封裝了與該資料相關的業務邏輯和驗證規則。物件自身負責維護其一致性(不變性),不允許自己處於一個無效的狀態。

實作:設計我們的 DBUserModel 領域模型

讓我們以 DBUserModel 為例,來實作一個「充血」模型。我們將在 internal/domain/ 目錄下進行設計(對應到專案中的 internal/database/mysql/entity/user.go 等檔案)。

1. 封裝與建構函式

為了保護物件的內部狀態,我們應該將欄位設為私有(小寫開頭),並透過建構函式來控制物件的建立過程,確保任何被建立出來的 DBUserModel 物件都是有效的。

// internal/domain/db_user.go

// DBUserModel 是我們的 Domain Model,代表資料庫中的使用者
type DBUserModel struct {
	id           int
	email        string
	passwordHash string
  createdAt    time.Time
	updatedAt    time.Time
}

// NewDBUserModel 是一個建構函式,用於建立一個新的、有效的 DBUserModel 物件
func NewDBUserModel(id int, email, passwordHash string, createdAt, updatedAt time.Time) (*DBUserModel, error) {
	if id < 0 {
		return nil, errors.New("id must be non-negative")
	}

	if email == "" {
		return nil, errors.New("email is required")
	}

	if passwordHash == "" {
		return nil, errors.New("passwordHash is required")
	}

	return &DBUserModel{
		id:           id,
		email:        email,
		passwordHash: passwordHash,
    createdAt:    createdAt,
		updatedAt:    updatedAt,
	}, nil
}

// Getter 方法,提供對私有欄位的唯讀存取
func (u *DBUserModel) ID() int {
	return u.id
}

func (u *DBUserModel) Email() string {
	return u.email
}

func (u *DBUserModel) PasswordHash() string {
	return u.passwordHash
}

func (u *DBUserModel) CreatedAt() time.Time {
	return u.createdAt
}

func (u *DBUserModel) UpdatedAt() time.Time {
	return u.updatedAt
}

透過這種方式,我們杜絕了在系統中出現一個沒有 idemailDBUserModel 物件的可能性。

2. 封裝業務規則

我們可以將屬於 DBUserModel 自身的業務規則,作為方法封裝在 DBUserModel struct 上。

// ChangePassword 封裝了變更密碼的業務規則
func (u *DBUserModel) ChangePassword(newPasswordHash string) error {
  if newPasswordHash == "" {
    return errors.New("new password hash is required")
  }
  u.passwordHash = newPasswordHash
  return nil
}

現在,變更密碼的邏輯被內聚到了 User 物件自身,而不是散落在某個 Usecase 中。

Domain Model vs. Database Model

再次強調,我們的 domain.DBUserModel 和 GORM 使用的 database/mysql/entity/user.go 是兩個不同的東西。
後者是為了資料庫持久化而存在的,它可以包含 gorm 標籤、資料庫特定的欄位類型等。而 domain.DBUserModel 是純粹的,代表了業務。

這種分離讓我們能夠更靈活地應對業務需求的變化,而不會被資料庫結構所束縛。

所以,當我們在 Usecase 層中處理業務邏輯時,我們應該操作的是 domain.DBUserModel,而不是直接操作資料庫模型。

因此,我們需要負責這兩者之間的轉換。

// internal/database/mysql/entity/user.go

func (u *User) ToDomain() (*domain.DBUserModel, error) {
	return domain.NewDBUserModel(u.ID, u.Email, u.Password, u.CreatedAt, u.UpdatedAt)
}

func NewUserFromDomain(m *domain.DBUserModel) *User {
	return &User{
		ID:        m.ID(),
		Email:     m.Email(),
		Password:  m.PasswordHash(),
		CreatedAt: m.CreatedAt(),
		UpdatedAt: m.UpdatedAt(),
	}
}

使用範例

我們以 change password 為例,展示如何在 Usecase 層中使用我們的 Domain Model

// internal/usecase/api/user/change_password/change_password.go

type repository interface {
	FindUserByID(ctx context.Context, userID int) (*domain.DBUserModel, error)
	UpdateUserPassword(ctx context.Context, user *domain.DBUserModel) error
}

這裡可以看到,我們的 repository 介面方法都使用 domain.DBUserModel,而不是資料庫模型。

本文添加調整的完整內容可以到 Github 觀看

總結

透過實作「充血」領域模型,我們將業務規則和資料驗證的職責下沉到了最核心的 Domain 層。
這讓我們的領域物件變得「更聰明」,從而讓上層的 UsecaseController 變得「更簡單」。

這種設計風格,使得我們的系統核心更加穩固、內聚,並且易於理解和維護。


上一篇
API 文件:使用程式產生 API 文件,讓前後端協作更順暢
系列文
Go Clean Architecture API 開發全攻略19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言