学习地址:levelup.gitconnected.com/practical-d…
Value Object
简单并且漂亮
ValueObject咋一看是一项简单的模式,它将几个属性组合到独立结构体中,提供特定的行为;独立结构体中包含了一些特定的质量或数量,可以在真实世界中找到,并且可以绑定到一些复杂对象上。提供一些特定的值或者特征。它可以是颜色或者money,手机号或者一些提供某些值的对象。
type Money struct {
Value float64
Currency Currency
}
func (m Money) ToHTML() string {
returs fmt.Sprintf(`%.2f%s`, m.Value, m.Currency.HTML)
}
type Salutation string
func (s Salutation) IsPerson() bool {
returs s != "company"
}
type Color struct {
Red byte
Green byte
Blue byte
}
func (c Color) ToCSS() string {
return fmt.Sprintf(`rgb(%d, %d, %d)`, c.Red, c.Green, c.Blue)
}
type Address struct {
Street string
Number int
Suffix string
Postcode int
}
type Phone struct {
CountryPrefix string
AreaCode string
Number string
}
在go中,ValueObject可以代表新的结构体或者扩展某些基本类型。这种做法能够给单个值或者一组值提供唯一的附加行为。在许多例子中,ValueObject能够提供特定的方法,比如用于字符串格式化或定义值在JSON编码或解码中的行为方式;这些方法的主要目的是支持与现实生活中的特征或质量绑定的业务不变量;(ValueObject不允许改变)
唯一性以及相等性
ValueObject没有身份标识(唯一ID),这是它与Entity的关键区别。Entity有一个身份作为其唯一性的描述。如果两个Entity有同样的身份标识(ID),那么这意味着我们在谈论相同的对象。ValueObject没有那个标识。ValueObject只有一些字段可以更好地描述它的值。要测试两个ValueObject之间的相等性,我们需要检查所有字段是否相等,如下面的代码块所示。
// 检查ValueObject是否相等
func (c Color) EqualTo(other Color) bool {
return c.Red == other.Red && c.Green == other.Green && c.Blue == other.Blue
}
// 检查ValueObject是否相等
func (m Money) EqualTo(other Money) bool {
return m.Value == other.Value && m.Currency.EqualTo(other.Currency)
}
// 检查Entity是否相等
func (c Currency) EqualTo(other Currency) bool {
return c.ID.String() == other.ID.String()
}
在上面的示例中,Money 和 Color 结构都定义了 EqualTo 方法来检查它们的所有字段。另一方面,Currency 检查身份是否相等,在此示例中为 UUID。
您可能会注意到,Value Object 还可以引用某些 Entity 对象,例如本例中的 Money 和 Currency。它还可以包含一些其他较小的ValueObject,例如包含 Color 和 Money 的 struct Coin。或者将切片定义为颜色的集合。
type Coin struct {
Value Money
Color Color
}
type Colors []Color
在一个限界上下文中,我们可以有几十个ValueObejct。不过,其中一些实际上可以是其他限界上下文中的Entity。货币就是这种情况。在一个简单的 Web 服务上,我们想要提供一些钱,我们可以将 Currency 视为一个ValueObject,绑定到我们不打算更改的 Money。另一方面,在支付服务上,我们希望通过一些 Exchange 服务 API 进行实时更新,我们需要在领域模型中使用身份。在那种情况下,我们将在不同的服务上有不同的 Currency 实现。
// value object on web service
type Currency struct {
Code string
HTML int
}
// entity on payment service
type Currency struct {
ID uuid.UUID
Code string
HTML int
}
我们要使用的模式,无论是ValueObject还是Entiry,仅取决于该对象在限界上下文中表示的内容。如果它是一个可重用的对象,独立存储在数据库中,可以更改并应用于许多其他对象,或者耦合到某个外部Entity,每当外部Entity发生变化时都需要更改,我们说的是Entity。但是,如果一个对象描述了一些值,属于一个特定的Entity,它是来自外部服务的简单副本,或者它不应该独立存在于数据库中,那么它就是一个ValueObject。
明确性
ValueObject最有用的特征是它的明确性。在 Golang(或任何其他编程语言)的原始类型不支持特定行为或支持的行为不直观的情况下,它为外界提供了清晰的思路。我们可以在许多项目中与客户打交道,这些项目必须满足一些业务不变量,比如是成年人或代表某个法律Entity。在这些情况下,提供更明确的类型(如 Birthday 和 LegalForm)是一种合法的方法。
type Birthday time.Time
func (b Birthday) IsYoungerThen(other time.Time) bool {
return time.Time(b).After(other)
}
func (b Birthday) IsAdult() bool {
return time.Time(b).AddDate(18, 0, 0).Before(time.Now())
}
const (
Freelancer = iota
Partnership
LLC
Corporation
)
type LegalForm int
func (s LegalForm) IsIndividual() bool {
return s == Freelancer
}
func (s LegalForm) HasLimitedResponsability() bool {
return s == LLC || s == Corporation
}
有时ValueObject不需要明确定义为任何其他Entity或ValueObject的一部分。尽管如此,我们仍可以将ValueObject定义为辅助对象,为以后在代码中的使用提供清晰度。与可以是个人或公司的客户打交道就是这种情况。根据客户的类型,我们在应用程序中有不同的流程。更好的方法之一可能是转变客户以更轻松地应对它。
type Customer struct {
ID uuid.UUID
Name string
LegalForm LegalForm
Date time.Time
}
func (c Customer) ToPerson() Person {
return Person{
FullName: c.Name,
Birthday: c.Date,
}
}
func (c Customer) ToCompany() Company {
return Company{
Name: c.Name,
CreationDate: c.Date,
}
}
type Person struct {
FullName string
Birthday Birthday
}
type Company struct {
Name string
CreationDate time.Time
}
尽管在某些项目中可能会发生转换的情况,但在大多数情况下,它们告诉我们应该将这些ValueObject添加为领域模型的真实部分。事实上,每当我们注意到一些特定的较小的字段组不断地相互交互,但它们在某个较大的组中时,这已经是一个迹象,表明我们应该将它们分组到ValueObject中,并像在我们较大的组中那样使用它(现在变小了)。
不变性
ValueObject是不可变的。没有单一的原因、原因或其他论据可以在其生命周期内更改ValueObject的状态。有时多个对象可以包含一个相同的ValueObject(尽管这不是一个完美的解决方案)。在这些情况下,我们绝对不想在一些意想不到的地方更改ValueObject。因此,每当我们想要更改ValueObject的内部状态或组合多个ValueObject时,我们总是需要返回一个具有新状态的新实例,如下面的代码块所示。
// 错误的方式
func (m *Money) AddAmount(amount float64) {
m.Amount += amount
}
// 好的方式
func (m Money) WithAmount(amount float64) Money {
return Money {
Amount: m.Amount + amount,
Currency: m.Currency,
}
}
// 错误的方式
func (m *Money) Deduct(other Money) {
m.Amount -= other.Amount
}
// 好的方式
func (m Money) DeductedWith(other Money) Money {
return Money {
Amount: m.Amount - other.Amount,
Currency: m.Currency,
}
}
// 错误的方式
func (c *Color) KeppOnlyGreen() {
c.Red = 0
c.Bed = 0
}
// 好的方式
func (c Color) WithOnlyGreen() Color {
return Color {
Red: 0,
Green: c.Green,
Blue: 0,
}
}
在所有示例中,唯一正确的方法始终是返回新实例并保持旧实例不变。Golang 中的良好做法始终是将函数绑定到值而不是ValueObject的引用,以确保我们永远不会更改内部状态。
func (m Money) Deduct(other Money) (Money, error) {
if !m.Currency.EqualTo(other.Currency) {
return Money{}, errors.New("currencies must be identical")
}
if other.Amount > m.Amount {
return Money{}, errors.New("there is not enough amount to deduct")
}
return Money {
Amount: m.Amount - other.Amount,
Currency: m.Currency,
}
}
这种不变性意味着我们不应该在它的整个生命周期内验证ValueObject,而应该像上面的例子那样只在创建时验证。当我们想要创建一个新的ValueObject时,我们必须始终执行验证并在不满足业务不变量时返回错误,并且只有在它有效时才创建ValueObject。从那时起,不再需要验证ValueObject。
丰富的行为
ValueObject提供许多不同的行为。它的主要目的是提供一个可实现目的的接口。如果它是贫血模型,我们可能没有任何办法去想它存在的原因。如果ValueObject在代码的某个特定位置确实有意义,那么它提供了大量额外的业务不变量,可以更好地描述我们想要解决的问题。
func (c Color) ToBrighter() Color {
return Color {
Red: math.Min(255, c.Red + 10),
Green: math.Min(255, c.Green + 10),
Blue: math.Min(255, c.Blue + 10),
}
}
func (c Color) ToDarker() Color {
return Color {
Red: math.Max(0, c.Red - 10),
Green: math.Max(0, c.Green - 10),
Blue: math.Max(0, c.Blue - 10),
}
}
func (c Color) Combine(other Color) Color {
return Color {
Red: math.Min(255, c.Red + other.Red),
Green: math.Min(255, c.Green + other.Green),
Blue: math.Min(255, c.Blue + other.Blue),
}
}
func (c Color) IsRed() bool {
return c.Red == 255 && c.Green == 0 && c.Blue == 0
}
func (c Color) IsYellow() bool {
return c.Red == 255 && c.Green == 255 && c.Blue == 0
}
func (c Color) IsMagenta() bool {
return c.Red == 255 && c.Green == 0 && c.Blue == 255
}
func (c Color) ToCSS() string {
return fmt.Sprintf(`rgb(%d, %d, %d)`, c.Red, c.Green, c.Blue)
}
将整个领域模型分解成像ValueObject(和Entity)这样的小块,使代码清晰并接近现实世界中的业务逻辑。每个ValueObject都可以描述一些小组件并支持许多类似于常规业务流程的行为。最后,这使得单元测试的整个过程变得更加容易,并有助于涵盖所有边缘情况。
结论
现实世界充满了不同的特征、质量和数量。当软件应用程序试图解决现实世界的问题时,使用此类量词是不可避免的。Value Object 是作为解决我们业务逻辑中这种明确性的解决方案呈现给我们的。
Entity
许多开发人员听说过 Entity ,即使他们从未使用过 DDD 方法。一些示例在 PHP 框架中,一些在 Java 中。尽管它看起来可能相同,但它在 DDD 中的用途是不同的。了解它在 DDD 中的用途对我来说是一种突破。它看起来有点奇怪,尤其是对于具有 PHP MVC 框架背景的人来说,但今天 DDD 方法似乎更合乎逻辑。
它不是ORM的一部分!
正如我们在 PHP 和 Java 框架的示例中看到的那样,Entity扮演着许多构建块的角色,从行数据网关到活动记录。由于这个事实,它涉及到Entity模式的误用。
Entity的目的不是反映数据库表结构,而是保留基本的业务逻辑。每当我处理某些应用程序时,我的Entity都不仅仅是数据库的镜像。
在实现方面,首先,我总是提供领域层。在那里,我想将完整的业务逻辑耦合在一起,以Entity、ValueObject和Service的形式构建。 一旦我完成了业务逻辑并覆盖了单元测试,我就开始提供一个基础层,其中包含技术细节,例如与数据库的连接。
就像您在下面的示例中看到的那样,我们将Entity从它在数据库中的表示中分离出来。镜像数据库结构的对象是独立的,它们更尊重数据传输对象。
// Entity在领域层内部
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
Person Person
}
// Repository interface inside domain layer
type BankAccountRepository interface {
Get(ctx context.Context, ID uint) (*BankAccount, error)
}
// DTO 基础设施层
type BankAccountGorm struct {
ID uint `gorm:"primaryKey;column:id"`
IsLocked bool `gorm:"column:is_locked"`
Amount int `gorm:"column:amount"`
CurrencyID uint `gorm:"column:currency_id"`
Currency CurrencyGorm `gorm:"foreignKey:CurrencyID"`
PersonID uint `gorm:"column:person_id"`
Person PersonGorm `gorm:"foreignKey:PersonID"`
}
// actual implementation of Repository inside infrastructure layer
type BankAccountRepository struct {
//
// some fields
//
}
func (r *BankAccountRepository) Get(ctx context.Context, ID uint) (*domain.BankAccount, error) {
var dto BankAccountGorm
//
// some code
//
return &BankAccount{
ID: dto.ID,
IsLocked: dto.IsLocked,
Wallet: domain.Wallet{
Amount: dto.Amount,
Currency: dto.Currency.ToEntity(),
},
Person: dto.Person.ToEntity(),
}, nil
}
上面的示例是我们可以提供的众多变体之一。尽管Entity和 DTO 的结构可能会有所不同,但取决于我们想要拥有的业务案例(比如每个 BankAccount 有更多钱包),想法始终是相同的。
我们始终将 Repository 接口保留在领域层。在这一层内(我使用的分层架构中的最底层),一些领域服务可能依赖于它们。所以,他们至少应该知道他们的存在。
存储库提供了一个约束,保证我们将处理来自领域层的Entity对象,至少是在外部。在 Repository 内部,我们可以随心所欲地处理任何事情,只要我们提供准确的结果即可。
通过这种结构,我总是设法将我的业务逻辑与下面的存储分离。一旦我需要对数据库进行一些更改,只需要更改将 DTO 转换为实体的映射方法,反之亦然。
type Currency struct {
ID uint
Code string
Name string
HtmlCode string
}
type Person struct {
ID uint
FirstName string
LastName string
DateOfBirth time.Time
}
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
Person Person
}
有时,Entity会反映复杂的业务逻辑,数据来自多个地方,如数据库、NoSQL 和一些外部 API。尤其是在那些情况下,将业务层与技术细节分离的想法非常值得。
唯一标识
与ValueObject的主要区别在于唯一标识。实体有唯一标识。唯一标识是实体的唯一属性,可以定义每个实体的唯一性。
两个Entity在它们的一个或多个领域中可能只有一点点差异。如果他们有相同的唯一标识,那么我们就是在谈论同一个Entity。为此,当我们检查它们是否相等时,我们只检查它们的唯一标识。
type Currency struct {
ID uint
Code string
Name string
HtmlCode string
}
func (c Currency) IsEqual(other Currency) bool {
return other.ID == c.ID
}
身份分为三种类型。
- 它们可以是应用程序生成的。这意味着,在某些时候,在将它们发送到要创建的存储之前,我们会为它们创建一个新的身份。在这种情况下,我使用 UUID。
- 使用自然身份。每当我们想与现实世界中具有某些独特属性的人或物体一起工作时,我们就可以操纵他们的生物标识符。例如,可以是社会保险号。
- 最常见的方式是数据库生成唯一ID。即使我能够实施前两种解决方案中的任何一种,我也会采用这种方法。
// application generated
type Currency struct {
ID uuid.UUID
Code string
Name string
HtmlCode string
}
func NewCurrency() Currency {
return Currency{
ID: uuid.New(), // generate new UUID
}
}
// natural
type Person struct {
SSN string // social security number
FirstName string
LastName string
DateOfBirth time.Time
}
// database generated
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
Person Person
}
type BankAccountGorm struct {
ID uint `gorm:"primaryKey;autoIncrement:true"`
IsLocked bool
Amount int
CurrencyID uint
PersonID uint
}
对于索引和查询,我喜欢只使用数字。在许多情况下,在处理应用程序生成的键或自然键时,我们应该处理文本或找到某种方法将这些文本正确映射到数据库中的数值。
由于 Identity 是 Entity 和 Value Object 之间的主要区别,您可能会猜想我们可以轻松抹去这条分隔线。事实上,根据限界上下文,一个对象可以轻松地从实体切换到值对象。
// transaction service
type Currency struct {
ID uint
Code string
Name string
HtmlCode string
}
// web service
type Currency struct {
Name string
HtmlCode string
}
就像上面的例子一样,货币可以在一个限界上下文中扮演中心实体的角色。这可以是交易服务或交换服务。但是,在我们需要它在 UI 中进行格式化的地方,货币可以用作简单的值对象。
有效性
与ValueObject相反,实体可以在其生命周期内更改其状态。这意味着每当我们想要更改实体时,它都需要不断的验证检查。
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
//
// some fields
//
}
func (ba *BankAccount) Add(other Wallet) error {
if ba.IsLocked {
return errors.New("account is locked")
}
//
// do something
//
}
是的,我知道。在上面的示例中,我们可以直接访问 Wallet 并通过避免 Add 方法来更改它。我不太喜欢 Go 中的 Getters 和 Setters。我发现拥有许多返回或设置值的函数是难以维护的。
在那些情况下,我更多地依靠所有工程师的理解来认识到如果方法已经存在,他们应该如何改变实体的状态。不过,每个开发人员自己决定的。使用具有私有字段的 getter 和 setter 也是成功的解决方案。
推动行为?
DDD 的全部目的是尽可能地反映业务流程。正因为如此,当我们看到许多方法作为我们领域层的一部分时,我们不会感到惊讶。
这些方法可以属于不同的对象。由于实体将最复杂的状态保留在所有其他代码块之外(高内聚),因此它们也可能拥有代表其丰富行为的最多功能(低耦合)。在某些情况下,我们可能会注意到实体中的几个字段不断地相互作用。每当我们在一个业务不变量中使用其中一个时,我们可能还需要另一个。
在那些情况下,我们总是可以将这些字段组合成一个单独的值对象,并将其交给实体来处理。我们需要谨慎地执行此操作,以避免实体和值对象之间的关注点分离不明确的情况。
type Wallet struct {
Amount int
Currency Currency
}
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
//
// some fields
//
}
// wrong way -> BankAccount takes responsibility from Wallet value object
// 这个方法应该是Wallet对象的,而不是BankAccount的
func (ba *BankAccount) Deduct(other Wallet) error {
if ba.IsLocked {
return errors.New("account is locked")
}
===================以下部分存在问题======================
if !other.Currency.IsEqual(ba.Wallet.Currency) {
return errors.New("currencies must be the same")
}
if other.Amount > ba.Wallet.Amount {
return errors.New("insufficient funds")
}
ba.Wallet = Wallet{
Amount: ba.Wallet.Amount - other.Amount,
Currency: ba.Wallet.Currency,
}
========================================================
return nil
}
在上面的示例中,我们能看到BankAccount实体承担了来自Wallet值对象的职责。我们询问 BankAccount 是否被锁定时的部分很清楚。但是,检查Currency是否相等以及Wallet的数量是否足够是一种Bad code smell。
在这些情况下,我将完整的扣除逻辑移至ValueObject,当然,检查 BankAccount 是否被锁定除外。像这样,Wallet 获取它的代码部分来验证和扣除金额。
type Wallet struct {
Amount int
Currency Currency
}
// right way -> Wallet value object checks its own invariants
func (w Wallet) Deduct(other Wallet) (Wallet, error) {
if !other.Currency.IsEqual(w.Currency) {
return Wallet{}, errors.New("currencies must be the same")
}
if other.Amount > w.Amount {
return Wallet{}, errors.New("insufficient funds")
}
return Wallet{
Amount: w.Amount - other.Amount,
Currency: w.Currency,
}, nil
}
type BankAccount struct {
ID uint
IsLocked bool
Wallet Wallet
//
// some fields
//
}
// right way -> BankAccount entity checks its own invariants
func (ba *BankAccount) Deduct(other Wallet) error {
if ba.IsLocked {
return errors.New("account is locked")
}
result, err := ba.Wallet.Deduct(other)
if err != nil {
return err
}
ba.Wallet = result
return nil
}
像这样,Wallet Value Object 可以属于任何其他 Entity 或 Value Object,并且它仍然可以支持扣除,这取决于它的内部状态。另一方面,BankAccount 可以为锁定的帐户提供额外的金额扣除方法,而无需复制相同的逻辑。
Entity可以将其行为推送到其他构建块,例如Domain Service(在下一篇文章中介绍)。在两种情况下,我将这些方法移至服务。
- 行为过于复杂。它可能需要使用规范、策略或其他实体和值对象。它可能取决于Repositories 或其他Services的结果。
- 第二种情况不是那么复杂的行为,但仍然不清楚逻辑属于哪里。它可能属于一个实体,也可能属于另一个实体,或者某个值对象。
type ExchangeRates []ExchangeRate
type Currency struct {
ID uint
//
// some fields
//
}
func (c *Currency) Exchange(to Currency, other Wallet, rates ExchangeRates) (Wallet, error) {
//
// do something
//
}
在上面的示例中,货币实体具有 Exchange 方法。此方法已经采用了太多参数。而且,问题是此方法是否属于这里而不是Wallet值对象或 ExchangeRate 实体。
更不用说由于政治纠纷或经济原因可能会暂时禁止兑换特定货币。这样,我们将为货币实体带来更多业务不变量。
type Currency struct {
ID uint
//
// some fields
//
}
type ExchangeRatesService struct {
repository ExchangeRatesRepository
}
func (s *ExchangeRatesService) Exchange(to Currency, other Wallet) (Wallet, error) {
//
// do something
//
}
当业务逻辑太大时,我总是将其移至单独的域服务中,如上例中的 ExchangeRatesService。通过这种方法,我总是能够通过提供新的域策略来调整我的域层。
有时将行为推给其他构建块看起来是一件自然而然的事情。但是,我们应该非常小心地处理它。将太多行为从实体转移到领域服务会导致另一种代码味道,即贫血领域模型。
type TransactionService struct {
//
// some fields
//
}
func (s *TransactionService) Add(account *BankAccount, second Wallet) error {
//
// do something
//
}
上面的示例显示了 TransactionService 域服务。该服务由 BankAccount 实体负责。如果我们不需要检查复杂的业务不变量,这种行为不属于领域服务。
为特定行为找到合适的位置更像是一种练习,但随着时间的推移会变得更加直观。即使在今天,有时我仍然很难找到合适的地方。但是,更多时候,我可以按应有的方式构建代码。
结论
尽管我们在许多框架中使用它们,但它们的用法并不总是正确的。它们应该在那里代表我们的状态和行为,而不是反映数据库的结构体。
实体为我们提供了更多机会来描述现实世界中的有状态对象。在许多情况下,它们是我们应用程序的主要目的,或者至少我们的业务逻辑没有它们就无法工作。
Domain Service
服务可能是最常被误用的 DDD 模式。对领域服务用途的误解来自许多不同的 Web 框架。在大多数框架中,服务就是一切。
它用于保持业务逻辑。它创建 UI 组件,如表单字段。它处理会话和 HTTP 请求。有时它只是扮演一个庞大的“utils”类的角色。有时保留最简单的值对象可以有的代码。定期执行数据库迁移。
上面的例子几乎都不应该是领域服务的一部分。直到本文结束,我会尽量更好地解释它的用途和用法。
Domian Service代表行为
领域服务代表来自问题领域的行为。它为过于复杂而无法存储在单个实体或值对象中的业务不变量提供解决方案。
有时,一个特定的行为可能会与多个实体或值对象交互。在那些情况下,很难找到该行为属于哪个实体。遇到这样的场景,领域服务就是解决方案。
域服务不处理会话或请求。它对 UI 组件一无所知。它不执行数据库迁移。它不验证用户输入。 领域服务只管理业务逻辑。
type ExchangeRateService interface {
IsConversionPossible(from Currency, to Currency) bool
Convert(to Currency, from Money) (Money, error)
}
type DefaultExchangeRateService struct {
repository *ExchangeRateRepository
}
func NewExchangeRateService(repository *ExchangeRateRepository) ExchangeRateService {
return &DefaultExchangeRateService{
repository: repository,
}
}
func (s *DefaultExchangeRateService) IsConversionPossible(from Currency, to Currency) bool {
var result bool
//
//
// some code
return result
}
func (s *DefaultExchangeRateService) Convert(to Currency, from Money) (Money, error) {
var result Money
//
//
// some code
return result, nil
}
在上面的例子中,有一个 ExchangeRateService 的案例。每当我提供一些我应该注入到另一个对象中的无状态结构时,我都会定义一个接口。它有助于以后进行单元测试。
该服务处理货币兑换的完整业务逻辑。它包含 ExchangeRateRepository 以获取所有汇率,因此它可以转换任何金额。
type CasinoService struct {
bonusRepository BonusRepository
accountService AccountService
//
// some other fields
//
}
func (s *CasinoService) Bet(account Account, money Money) error {
bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
if err != nil {
return err
}
//
// some code
//
for _, bonus := range bonuses {
err = bonus.Apply(&account)
if err != nil {
return err
}
}
//
// some code
//
err = s.accountService.Update(account)
if err != nil {
return err
}
return nil
}
如前所述,领域服务包含过于复杂而无法存储在单个实体或值对象中的业务不变量。在上面的示例中,CasinoService 拥有复杂的逻辑,只要某个帐户有新的赌注就可以应用奖金。
我们不应该强迫帐户实体或奖励实体相互依赖,或者更糟的是,为实体的方法提供预期的存储库或服务,我们应该创建一个域服务。该服务可以封装完整的业务逻辑,用于将奖金应用到任何必要的帐户。
代表约束
有时,我们的限界上下文依赖于其他限界上下文。一个典型的例子是一组微服务,其中一个通过 REST API 访问第二个。
在大多数情况下,从外部 API 接收的数据对于初始限界上下文的工作至关重要。因此,在我们的领域层中,我们应该能够访问该数据。
我们必须始终将领域层与技术细节分离。这意味着如果我们在我们的业务逻辑中放置一些与外部 API 或数据库的集成——这是一种代码味道。
在这里,领域服务就位了。在领域层,我总是提供一个服务接口作为外部集成的契约。然后我们可以在整个业务逻辑中注入该接口,但实现是在基础架构层上。
// domain layer
type AccountService interface {
Update(account Account) error
}
// infrastructure layer
type AccountAPIService struct {
client *http.Client
}
func NewAccountService(client *http.Client) domain.AccountService {
return &AccountAPIService{
client: client,
}
}
func (s AccountAPIService) Update(account domain.Account) error {
var request *http.Request
//
// some code
//
response, err := s.client.Do(request)
if err != nil {
return err
}
//
// some code
//
return nil
}
在上面的示例中,我在域层上定义了 AccountService 接口。它代表其他领域服务可以调用的合约。但是,我们以 AccountAPIService 的形式提供实现。
AccountAPIService 将 HTTP 请求发送到外部 CRM 系统或我们的内部微服务,仅专用于帐户。我们可以为 AccountService 提供一个额外的实现,它可以使用这种方法在隔离测试环境中的文件中使用测试帐户。
无状态
域服务不能保持状态。域服务也不得包含任何具有状态的字段。
这条规则可能很明显,但不幸的是,事实并非如此。根据每个开发人员的背景,他们中的一些人具有使用针对每个请求运行隔离进程的语言进行 Web 开发的经验。
在这种情况下,服务是否包含状态从来都不重要。但是,当你使用 Go 时,你可能会为整个应用程序使用一个域服务实例。因此,您可以想象如果许多不同的客户端访问内存中的相同值会发生什么(数据竞争data race) 。
// entity keeps state
type Account struct {
ID uint
Person Person
Wallets []Wallet
}
// value object keeps state
type Money struct {
Amount int
Currency Currency
}
// domain service depends only on other stateless constructs like:
// services, repositories, factories, objects that represent app configuration
type DefaultExchangeRateService struct {
repository *ExchangeRateRepository
useForceRefresh bool
}
type CasinoService struct {
bonusRepository BonusRepository
bonusFactory BonusFactory
accountService AccountService
}
正如我们在上面的示例中看到的,实体和值对象保持状态。实体可以在运行时更改状态,而值对象始终保持不变。当我们需要一个新的值对象时,我们创建一个新的值对象。
域服务不包含任何有状态对象。它仅包含其他无状态结构,如存储库、其他服务、工厂、配置值。它可以启动状态的创建或它的持久性,但它不持有它。
// wrong keeping of state inside service
type TransactionService struct {
bonusRepository BonusRepository
result Money // field that contains state
}
func (s *TransactionService) Deposit(account Account, money Money) error {
bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
if err != nil {
return err
}
//
// some code
//
s.result = s.result.Add(money) // changing state of service
return nil
}
在上面的示例中,TransactionService 以货币值对象的形式持有一个有状态的字段。每当我们想要进行新的存款时,我们都会执行应用奖金的逻辑,然后将其添加到最终结果中,这是服务中的一个字段。
这种做法是错误的。每当有人,任何人存款时,结果都会改变。我们不想这样做,而是要对每个帐户进行汇总。相反,我们应该将计算作为方法的结果返回,如下例所示。
// right dealing with state provided as argument
type TransactionService struct {
bonusRepository BonusRepository
}
func (s *TransactionService) Deposit(current Money, account Account, money Money) (Money, error) {
bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
if err != nil {
return Money{}, err
}
//
// some code
//
return current.Add(money), nil // returning new value that represents new state
}
func main() {
//
// some code
//
LOOP:
for true {
select {
case deposit := <-moneyChan:
current, err := service.Deposit(current, account, deposit)
if err != nil {
log.Fatal(err)
}
case <-quitChan:
break LOOP
}
}
//
// some code
//
}
New TransactionService 总是产生最新的计算而不是将它们存储在里面。不同的用户不能在内存中共享同一个对象,领域服务可以再次像一个单一的实例一样。
该服务的客户现在负责保存新结果并在存款发生时刷新它。
域服务与其他类型的服务
到此为止,提供领域服务的原因应该是什么就很清楚了。但是,在某些情况下,不清楚某些服务是否也是领域服务。或者,更好地说,Service 属于哪一层?
基础设施服务是最容易识别的。它们总是包含技术细节、与数据库的集成或外部 API。在大多数情况下,它们是来自其他层的接口的实际实现。展示服务也很容易识别。它们总是提供一些与 UI 组件或验证用户输入相关的逻辑。表单服务就是一个典型的例子。
当涉及到区分应用程序和领域服务时,问题就出现了。我发现最难区分这两种类型。根据我的经验,我使用应用程序服务只是为了提供处理会话或请求的一般逻辑。处理授权和访问权限也很适合放在应用程序层中。
type AccountSessionService struct {
accountService AccountService
}
func (s *AccountSessionService) GetAccount(session *sessions.Session) (*Account, error) {
value, ok := session.Values["accountID"]
if !ok {
return nil, errors.New("there is no account in session")
}
id, ok := value.(string)
if !ok {
return nil, errors.New("invalid value for account ID in session")
}
account, err := s.accountService.ByID(id)
if err != nil {
return nil, err
}
return account, nil
}
在许多情况下,我将应用程序服务作为领域服务的包装结构。每当我想在会话中缓存一些东西并使用域服务作为数据的后备时,我都会使用这种方法。您可以在上面的示例中找到这种方法。
AccountSessionService 是一个应用服务,它从域层包装了 AccountService。它负责从会话存储中提取一个值,然后使用它在下面的服务中查找帐户详细信息。
结论
在领域服务代表一种无状态结构,提供来自真实商业世界的行为。它与许多不同的对象交互,例如实体和值对象。它需要他们的复杂行为,或者至少是我们不知道它应该属于哪里。
领域服务与其他层的服务没有任何相似之处,除了名称。它只与业务逻辑相关,不应与技术细节、会话、请求或任何其他特定于应用程序的细节相关。
Domain Event
有时,呈现问题域的最佳方式是使用其中发生的事件。实际上,就我而言,我越来越多地尝试识别事件,然后识别与它们相关的实体。
尽管Eric Evans在他的第一版书中没有涉及领域事件模式,但今天,不使用事件来完成领域层是一项挑战。
领域事件模式代表我们代码中发生的此类事件。我们用它来描述现实世界中与我们的业务逻辑相关的任何事件。今天,商业世界中的一切都与某些事件有关。
它可以是任何事
领域事件可以是任何事,但是,它们需要满足一些规则。第一个是——它们是不可变的。为了支持该功能,我总是在 Event 结构中使用私有字段,即使我不是 Go 中的私有字段和 getters 的忠实粉丝。至少,Events 没有太多的 getter。
一个特定事件只能发生一次。 这意味着我们只能创建一个具有某个身份的Order实体一次,因此我们的代码只能触发一次描述该Order创建的事件。
该订单的任何其他事件都是不同类型的事件。任何其他创建事件都是另一个Order的事件。
每个事件实际上都描述了已经发生的事情。它代表过去。这意味着我们在创建订单时触发 OrderCreated 事件,而不是在此之前。
// Event interface for describing Domain Event
type Event interface {
Name() string
}
// GeneralError actual event
type GeneralError string
func NewGeneralError(err error) Event {
return GeneralError(err.Error())
}
func (e GeneralError) Name() string {
return "event.general.error"
}
// OrderEvent interface for describing Order relevant Domain Event
type OrderEvent interface {
Event
OrderID() uuid.UUID
}
// OrderDispatched actual event
type OrderDispatched struct {
orderID uuid.UUID
}
func (e OrderDispatched) Name() string {
return "event.order.dispatched"
}
func (e OrderDispatched) OrderID() uuid.UUID {
return e.orderID
}
// OrderDelivered actual event
type OrderDelivered struct {
orderID uuid.UUID
}
func (e OrderDelivered) Name() string {
return "event.order.delivery.success"
}
func (e OrderDelivered) OrderID() uuid.UUID {
return e.orderID
}
// OrderDeliveryFailed actual event
type OrderDeliveryFailed struct {
orderID uuid.UUID
}
func (e OrderDeliveryFailed) Name() string {
return "event.order.delivery.failed"
}
func (e OrderDeliveryFailed) OrderID() uuid.UUID {
return e.orderID
}
上面的代码示例显示了简单的领域事件。这段代码是在 Go 中实现它们的数十亿种解决方案之一。在某些情况下,比如这里的 GeneralError,我只使用了简单的字符串。但是,有时候,我有复杂的对象。或者,我必须使用一些更具体的接口来扩展主要的 Event 接口,以添加其他方法,例如 OrderEvent。
领域事件作为接口不需要实现任何方法。它可以是任何你想要的。如前所述,有时我使用字符串,但任何东西都足够了。为了通用化,有时,我仍然声明 Event 接口。
领域事件作为一种模式,并没有提供一些新的结构,但它是观察者模式的另一种表现形式。观察者模式将发布者、订阅者视为主角,当然还有事件。
领域事件遵循相同的逻辑。订阅者或事件处理程序是一种结构,应该响应它订阅的特定领域事件。
发布者是一种结构,一旦某个事件发生,它就会通知所有事件处理程序。发布者是触发任何事件的入口点。它包含所有事件处理程序,并为任何域服务、工厂或其他想要发布某些事件的对象提供一个简单的接口。
// EventHandler interface for describing any object that should be notified upon some Event has happened
type EventHandler interface {
Notify(event Event)
}
// EventPublisher central structure for notifying all EventHandler
type EventPublisher struct {
handlers map[string][]EventHandler
}
// Subscribe subscribes EventHandler to particular Event
func (e *EventPublisher) Subscribe(handler EventHandler, events ...Event) {
for _, event := range events {
handlers := e.handlers[event.Name()]
handlers = append(handlers, handler)
e.handlers[event.Name()] = handlers
}
}
// Notify notifies subscribed EventHandler for particular Event
func (e *EventPublisher) Notify(event Event) {
for _, handler := range e.handlers[event.Name()] {
handler.Notify(event)
}
}
上面的代码片段显示了域事件模式的其余部分。EventHandler接口表示应对某些事件作出反应的任何结构。它只有一个 Notify 方法需要 Event 作为参数。
EventPublisher结构更复杂。它提供了负责通知所有订阅该事件的事件处理程序的通用 Notify 方法。另一个函数 Subscribe 增加了任何 EventHandler 订阅任何 Event 的可能性。
EventPublisher 结构可以不那么复杂。相反,为了让 EventHandler 有机会通过使用映射来订阅特定事件,它只能处理一个简单的 EventHandler 数组,并为任何事件通知所有这些事件处理程序。一般来说,我们应该在领域层同步发布领域事件。但有时,出于任何原因,我想异步触发它们,为此,我使用了 Goroutine。
type Event interface {
Name() string
IsAsynchronous() bool
}
type EventPublisher struct {
handlers map[string][]EventHandler
}
func (e *EventPublisher) Notify(event Event) {
if event.IsAsynchronous() {
go e.notify(event) // runs code in separate Go routine
}
e.notify(event) // synchronous call
}
func (e *EventPublisher) notify(event Event) {
for _, handler := range e.handlers[event.Name()] {
handler.Notify(event)
}
}
上面的示例显示了异步发布事件的一种变体。为了提供这两种方法,我经常在 Event 接口中定义一个方法,稍后应该为我提供是否应该同步触发 Event 的信息。
创建
我最大的难题是创建事件的正确位置。老实说,我每个地方都有创建过它们。我的唯一规则是有状态对象不能通知 EventPublisher。
实体、值对象等聚合(我们将在下一篇文章中介绍)是有状态对象。从这个角度来看,它们不应该在它们内部包含 EventPublisher,并且将它作为参数提供给它们的方法对我来说总是一个丑陋的代码。
此外,我不使用有状态对象作为事件处理程序。如果我需要在特定事件发生时对某些实体执行某些操作,我会创建一个包含存储库的 EvenHandler。然后,存储库可以提供一个应该适配的实体。尽管如此,在 Aggregate 的某些方法中创建 Event 对象还是可以的。有时,我在 Entity 的方法中创建它们并将它们作为结果返回。然后,我使用域服务或工厂等无状态结构来通知 EventPublisher。
type Order struct {
id uuid.UUID
//
// some fields
//
isDispatched bool
deliveryAddress Address
}
func (o Order) ID() uuid.UUID {
return o.id
}
func (o *Order) ChangeAddress(address Address) Event {
if o.isDispatched {
return DeliveryAddressChangeFailed{
orderID: o.ID(),
}
}
//
// some code
//
return DeliveryAddressChanged{
orderID: o.ID(),
}
}
type OrderService struct {
repository OrderRepository
publisher EventPublisher
}
func (s *OrderService) Create(order Order) (*Order, error) {
result, err := s.repository.Create(order)
if err != nil {
return nil, err
}
//
// update Adrress in DB
//
s.publisher.Notify(OrderCreated{
orderID: result.ID(),
})
return result, err
}
func (s *OrderService) ChangeAddress(order Order, address Address) {
evt := order.ChangeAddress(address)
s.publisher.Notify(evt) // publishing of events only inside stateless objects
}
在上面的示例中,Order Aggregate 提供了一种更新送货地址的方法。该方法的结果可能是一个事件。这意味着 Order 可以创建一些 Events,但仅此而已。
另一方面,OrderService 既可以创建事件又可以发布事件。它还可以在更新送货地址时触发从订单接收到的事件。这是可能的,因为它包含 EventPublisher。
其它层事件
我们可以在其他层上监听领域事件,比如应用程序、表示或基础设施。我们还可以定义仅专用于这些层的单独事件。在那些情况下,我们不谈论领域事件。
一个简单的例子就是应用层的事件。在我们创建订单后,在大多数情况下,我们应该向客户发送一封电子邮件。尽管它看起来像是一条业务规则,但发送电子邮件始终是特定于应用程序的。
在下面的示例中,有一个带有 EmailEvent 的简单代码。正如您可能猜到的那样,Email 可以处于许多不同的状态,并且在某些 Events 期间总是执行从一种状态到另一种状态的切换。
type Email struct {
id uuid.UUID
//
// some fields
//
}
type EmailEvent interface {
Event
EmailID() uuid.UUID
}
type EmailSent struct {
emailID uuid.UUID
}
func (e EmailSent) Name() string {
return "event.email.sent"
}
func (e EmailSent) EmailID() uuid.UUID {
return e.emailID
}
type EmailHandler struct{
//
// some fields
//
}
func (e *EmailHandler) Notify(event Event) {
switch actualEvent := event.(type) {
case EmailSent:
//
// do something
//
default:
return
}
}
有时我们想在限界上下文之外触发领域事件。这些领域事件是我们限界上下文的内部事件,但它们是其他事件的外部事件。
虽然这更像是战略领域驱动设计的主题,但我将在这里涉及这一部分。要在我们的微服务之外发布事件,我们可以使用一些消息服务,例如 SQS。
// infrastructure layer
import (
//
// some imports
//
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sqs"
)
// EventSQSHandler publishes internal events to external world
type EventSQSHandler struct {
svc *sqs.SQS
}
// Notify publishes Event over SQS
func (e *EventSQSHandler) Notify(event Event) {
data := map[string]string{
"event": event.Name(),
}
body, err := json.Marshal(data)
if err != nil {
log.Fatal(err)
}
_, err = e.svc.SendMessage(&sqs.SendMessageInput{
MessageBody: aws.String(string(body)),
QueueUrl: &e.svc.Endpoint,
})
if err != nil {
log.Fatal(err)
}
}
上面的代码片段中有一个基础结构层上的简单结构 EventSQSHandler,它会在某些事件发生时向 SQS 队列发送消息。它只发布事件名称,没有任何具体细节。
在向外界发布内部事件的同时,我们也可能会监听外部事件并将其映射到内部事件。为此,我总是在基础架构层上提供一些服务来侦听来自外部的消息。
// infrastructure layer
type SQSService struct {
svc *sqs.SQS
publisher *EventPublisher
stopChannel chan bool
}
// Run starts SQS message listening
func (s *SQSService) Run(event Event) {
eventChan := make(chan Event)
MessageLoop:
for {
s.listen(eventChan)
select {
case event := <-eventChan:
s.publisher.Notify(event)
case <-s.stopChannel:
break MessageLoop
}
}
close(eventChan)
close(s.stopChannel)
}
// Stop stops SQS message listening
func (s *SQSService) Stop() {
s.stopChannel <- true
}
func (s *SQSService) listen(eventChan chan Event) {
go func() {
message, err := s.svc.ReceiveMessage(&sqs.ReceiveMessageInput{
//
// some code
//
})
var event Event
if err != nil {
log.Print(err)
event = NewGeneralError(err)
return
} else {
//
// extract message
//
}
eventChan <- event
}()
}
上面的示例显示了基础设施层内的 SQSService。此服务侦听 SQS 消息并将它们映射到内部事件(如果它可以映射到某些事件)。
我没有过多地使用这种方法,但在某些情况下,这是值得的。例如,如果许多微服务应该对订单的创建做出反应。或者当客户注册时。
结论
领域事件是我们领域逻辑中不可避免的结构。今天,商业世界中的一切都与特定事件相关联,因此用事件描述我们的领域模型是一个很好的做法。
领域事件模式只是观察者模式的实现。它可以在许多对象中创建,但应该从无状态对象中触发。其他层也可以使用领域事件或它们自己的。
Module
乍一看,Module 不像是一种模式,至少,不像我们认为的软件开发中的模式。这是可以理解的,因为有人可能更多地将 Module 视为项目结构而不是模式。
当我们考虑 Go Modules 时,额外的麻烦来了。他们将 Go 包的集合耦合在一起,一起进行版本控制和发布。我们使用这些模块作为 Go 中的依赖管理。
因此,如果 Go Modules 和 Packages 都影响项目结构, 那么它们似乎应该与 DDD 模式 Module 有某种联系。确实如此。
代码结构
在 Go 中,我们使用 Packages 来对我们的代码进行分组。包遵循我们项目中的文件夹结构,尽管它们的命名可能有所不同。这些变化的出现是因为我们可以用不同于实际文件夹的方式来调用我们的包。
// folder pkg/access/domain/model
package access_model
import (
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID
//
// some fields
//
}
// folder pkg/access/domain/service
package access_service
import (
"project/pkg/access/domain/model"
)
type UserService interface {
Create(user access_model.User) error
//
// some methods
//
}
在上面的示例中,我们可以看到文件夹和包命名之间的细微差别。有时,如果我有很多模型包,我会为它们添加我的 DDD 模块的前缀,以便轻松地在同一个文件中引用多个模型包。
现在我们可能已经对前面示例中的 DDD 模块有了一些了解。在这里,模块是访问包,连同它的所有子包。
project
|--cmd
|--main.go
|--internal
|--module1
|--infrastructure
|--presentation
|--application
|--domain
|--service
|--factory
|--repository
|--model
|--module1.go
|--module2
|--...
|--...
|--pkg
|--module3
|--...
|--module4
|--...
|--...
|--go.mod
|--...
上面方案中的文件夹结构代表了我最喜欢的在 Go 中实现领域驱动设计的项目结构。有时我会对一些文件夹进行不同的修改,但我总是尽量让 DDD 模块保持相同的形式。
在我的项目中,每个 Module 最多有四个基础包:infrastructure、presentation、application 和 domain。如您所见,我喜欢遵循分层架构的原则。
这里我把基础设施包放在最上面。这是因为遵循 Bob 大叔的依赖倒置原则,我来自基础设施层的低级服务实现了来自其他层的高级接口。
通过这种方法,我确保将端口定义为域层上的 UserRepository 接口。实际的实现是在基础设施层,可以是多个Adapter,比如UserDBRepository,或者UserFakeRepository。
// folder pkg/access/domain/repository
package acces_repository
import (
"project/pkg/access/domain/model"
)
type UserRepository interface {
Create(user access_model.User) error
}
// folder pkg/access/infrastructure/database
package database
type UserDBRepository struct {
//
// some fields
//
}
func (r *UserDBRepository) Create(user access_model.User) error {
//
// some code
//
return nil
}
// folder pkg/access/infrastructure/fake
type UserFakeRepository struct {
//
// some fields
//
}
func (r *UserFakeRepository) Create(user access_model.User) error {
//
// some code
//
return nil
}
关于端口和适配器的故事并不新鲜,属于六边形架构的原则。这是我在设计 DDD 模块时使用的第二个原则,对我来说,这是至关重要的一个。
回到Module内部的包结构,每一层都知道下面所有层的一切,而没有人知道他们的一切。因此,基础设施层可以依赖所有层,而领域层不依赖任何层。
基础设施层的正下方是表示层。我们也可以称它为接口层,但它是 Go 中的保留字,所以 presentation 似乎很好。最后,在表现层和域之间,还有应用层。
Go 中这种分层的好处在于它可以帮助我们避免循环依赖,这会在编译时破坏我们的代码。通过遵循这些分层规则和依赖方向,我们可以避免痛苦的代码重构。
最后,您注意到域层中的一些文件夹(或包):模型、服务等。我偶尔会放置它们以使我的包尽可能简单。有时,我在域内部使用该子结构来避免 Go 中的循环依赖。我选择底部模型和顶部服务,但这取决于个人选择他们喜欢的方式。
逻辑集群
DDD 模块不仅仅是一组文件和文件夹。这些文件和文件夹中的代码必须代表某种内聚结构。不仅如此,两个不同的模块应该松散耦合,它们之间的依赖性最小。
project
|--...
|--pkg
|--access
|--infrastructure
|--...
|--presentation
|--...
|--application
|--service
|--authorization.go
|--registration.go
|--domain
|--repository
|--user.go
|--group.go
|--role.go
|--model
|--user.go
|--group.go
|--role.go
|--access.go
|--shopping
|--infrastructure
|--...
|--presentation
|--...
|--application
|--service
|--session_basket.go
|--domain
|--service
|--shopping.go
|--factory
|--basket.go
|--repository
|--order.go
|--model
|--order.go
|--basket.go
|--shopping.go
|--customer
|--infrastructure
|--...
|--presentation
|--...
|--application
|--...
|--domain
|--repository
|--customer.go
|--address.go
|--model
|--customer.go
|--address.go
|--customer.go
|--...
|--...
上面的文件夹结构是 DDD 模块的一个简单示例。在那里,我们有三个模块(可能更多),称为访问、购物和客户。它们都有自己的层和子层。
访问模块与授权和注册过程有关。它包含在会话中处理一个用户的整个逻辑。此外,它还拥有每个人的访问权限,并决定他们是否可以访问特定对象。
客户模块包含有关客户及其地址的信息。虽然看起来和User一样,但它代表的是下订单的业务Entity,其中User是会话中的一个Entity。另外,正如我们已经在许多平台上所做的那样,一个用户可以有多个客户进行交付。
最后,购物模块是一个完整逻辑的集群,包括创建购物篮并将其保持在会话中以及进一步创建订单。这个购物模块看起来比其他两个更复杂,实际上,它依赖于它们两个。而且,就像层一样,我们还应该跟踪模块之间的依赖关系并确保它们是单向的。否则,编译器可能会哭。
正如您在上图中看到的,购物模块使用客户模块来找出订单所有者是谁。从那里,它可以使用 Address 来定义交付。它还依赖于检查特定篮子和项目的访问权限的访问模块。
客户模块只依赖于访问模块。它提供与会话中的用户的连接和已分配客户的列表,以决定向谁发送订单。
客户和购物可以一起定义一个限界上下文。单个模块不需要代表一个限界上下文。我喜欢将一个限界上下文拆分成多个模块。
access Module 可能看起来像是一个不同的 Bounded Context 的候选者,为了将来,我们可以考虑将它放在其他地方。稍后,其他限界上下文可能会依赖于访问限界上下文。
尽管购物和客户看起来耦合在一起,但在我们的应用程序中,我们决定将它们分开。原因是作为客户,我们可以独立于订单做很多不同的事情。我们可能会更改我们的地址、查看我们的历史记录、跟踪我们的交付、联系客户支持。客户详细信息的更改不应影响一个订单。此外,一个订单的更改不会影响客户。我们可以独立地与他们合作。
命名
谈论命名可能看起来令人惊讶,但不幸的是,事实并非如此。根据我的经验,我见过 DDD 模块的糟糕名称,而且我创建的更糟糕。
project
|--...
|--pkg
|--shoppingAndCustomer
|--...
|--utils
|--...
|--events
|--...
|--strategy
|--...
|--...
|--...
上面的示例包含许多不同的错误名称。我总是避免在模块名称中使用“and”这个词,比如这里的 shoppingAndCustomer。如果我不能避免使用“和”这个词,那么我可能正在处理两个独立的模块。
“utils”这个词是软件开发中最糟糕的名字。我无法忍受它作为结构名、文件名、函数名、包或模块名。“垃圾收集器”这个名字可能更合适,因为它最好地描述了存储在 utils 模块中的代码。
拥有一个包含来自各个地方的小部件的模块也是无用的。事件模块就是这样一个例子——它包含来自整个应用程序的领域事件。在一些设计模式之后命名模块也不是一个很好的做法,比如策略模块。可能我们应该在我们的应用程序的很多地方使用策略模式,所以制作多个策略模块没有意义。
我们的模块应该有来自真实商业世界的名字。它应该是 Ubiquitous Language 的一部分,属于商业和软件开发领域的一些术语,它描述的是同一件事。它应该是该业务逻辑集群的唯一名称。
依赖注入
您可能会注意到第一个项目结构在每个 DDD 模块的根目录中引入了单独的 Go 文件。我将它们的名称始终设置为 module.go 或与 Module 相同。
这些文件是我在我的模块中定义依赖项和我的端口的不同适配器的地方。在许多情况下,我编写简单的 Go 容器,存储我在应用程序中使用的对象。
type AccessModule struct {
repository acces_repository.UserRepository
service access_service.UserService
}
func NewAccessModule(useDatabase bool) *AccessModule{
var repository acces_repository.UserRepository
if useDatabase {
repository = &database.UserDBRepository{}
} else {
repository = &fake.UserFakeRepository{}
}
var service access_service.UserService
//
// some code
//
return &AccessModule{
repository: repository,
service: service,
}
}
func (m *AccessModule) GetRepository() acces_repository.UserRepository {
return m.repository
}
func (m *AccessModule) GetService() access_service.UserService {
return m.service
}
在上面的示例中,我创建了 AccessModule 结构。在初始化期间,它接受定义是否应该依赖数据库或 UserRepository 的一些伪实现的配置。之后,所有其他模块都可以使用这个容器来获取它们的依赖项。
我们还可以使用我们拥有的众多框架之一来解决 Go 中的依赖注入。最常用的库之一是 Wire,但我个人最好的库是 GitHub - i-love-flamingo/dingo: Go Dependency Injection Framework。Dingo 库依赖于反射,这对于许多 Go 开发人员来说可能是一个痛苦的话题。虽然我不喜欢 Go 中的反射,但根据我的经验,Dingo 被证明是一个简单而稳定的解决方案,提供了许多不同的功能。
package example
type BillingModule struct {}
func (module *BillingModule) Configure(injector *dingo.Injector) {
// This tells Dingo that whenever it sees a dependency on a TransactionLog,
// it should satisfy the dependency using a DatabaseTransactionLog.
injector.Bind(new(TransactionLog)).To(DatabaseTransactionLog{})
// Similarly, this binding tells Dingo that when CreditCardProcessor is used in
// a dependency, that should be satisfied with a PaypalCreditCardProcessor.
injector.Bind(new(CreditCardProcessor)).To(PaypalCreditCardProcessor{})
}
结论
DDD 模块是我们代码的逻辑集群。它将许多结构耦合在一个共享某些业务规则的有凝聚力的组中。在模块内部,我们可能会引入不同的层。
层和模块都应该遵循单向通信,以避免循环依赖。 模块的名称应该代表商业领域的术语。
欢迎关注:小唐云原生