在我們的六角形架構中,Domain
層處於同心圓的最中心。它代表了我們業務的本質,是整個系統中最穩定、最不應輕易變動的部分。但是,一個好的 Domain Model
應該是什麼樣子?僅僅是一個包含幾個欄位的 struct
嗎?
在軟體設計中,對於領域模型有兩種常見的風格:
貧血領域模型 (Anemic Domain Model):這是一種反模式。在這種模型中,領域物件(如 User
struct)只包含一堆公開的欄位或 Get/Set 方法,沒有任何業務邏輯。所有的業務邏輯都散落在外部的 Service
或 Usecase
層中。這會導致業務規則分散,難以維護,且領域物件自身無法保證其資料的有效性。
充血領域模型 (Rich Domain Model):在這種模型中,領域物件不僅包含資料,更封裝了與該資料相關的業務邏輯和驗證規則。物件自身負責維護其一致性(不變性),不允許自己處於一個無效的狀態。
DBUserModel
領域模型讓我們以 DBUserModel
為例,來實作一個「充血」模型。我們將在 internal/domain/
目錄下進行設計(對應到專案中的 internal/database/mysql/entity/user.go
等檔案)。
為了保護物件的內部狀態,我們應該將欄位設為私有(小寫開頭),並透過建構函式來控制物件的建立過程,確保任何被建立出來的 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
}
透過這種方式,我們杜絕了在系統中出現一個沒有 id
或 email
的 DBUserModel
物件的可能性。
我們可以將屬於 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.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
層。
這讓我們的領域物件變得「更聰明」,從而讓上層的 Usecase
和 Controller
變得「更簡單」。
這種設計風格,使得我們的系統核心更加穩固、內聚,並且易於理解和維護。