Golang 和 DDD 领域建模
Golang and DDD domain modeling
我最近一直在研究领域驱动设计,必须说这种类型的架构设计触发了我的一些东西。当我尝试将它的概念应用到我的 Go 项目时,我遇到了一些障碍。以下是一些示例方法,但我不确定要使用哪种方法。
项目结构摘录:
├── api/
├── cmd/
├── internal/
| ├── base/
| | ├── eid.go
| | ├── entity.go
| | └── value_object.go
| ├── modules/
| | ├── realm/
| | | ├── api/
| | | ├── domain/
| | | | ├── realm/
| | | | | ├── service/
| | | | | ├── friendly_name.go
| | | | | ├── realm.go
| | | | | └── realm_test.go
| | | | └── other_subdomain/
| | | └── repository/
| | | ├── inmem/
| | | └── postgres/
所有方法通用:
package realm // import "git.int.xxxx.no/go/xxxx/internal/modules/realm/domain/realm"
// base contains common elements used by all modules
import "git.int.xxxx.no/go/xxxx/internal/base"
方法一:
type Realm struct {
base.Entity
FriendlyName FriendlyName
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
var r = new(Realm)
r.Entity = base.NewEntity(id)
r.FriendlyName, err = NewFriendlyName(params.FriendlyName)
return r, err
}
type FriendlyName struct {
value string
}
var ErrInvalidFriendlyName = errors.New("invalid friendly name")
func (n FriendlyName) String() string { return n.value }
func NewFriendlyName(input string) (FriendlyName, error) {
if input == "" {
return ErrInvalidFriendlyName
}
// perhaps some regexp rule here...
return FriendlyName{value: input}, nil
}
使用这种方法,我认为长 运行 中会有很多重复代码,但至少 FriendlyName 值对象根据 DDD 要求是不可变的,并且可以附加更多方法。
方法二:
type Realm struct {
base.Entity
FriendlyName string
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
if err = validateFriendlyName(params.FriendlyName); err != nil {
return nil, err
}
entity := base.NewEntity(id)
return &Realm{
Entity: entity,
FriendlyName: params.FriendlyName,
}, nil
}
这一定是我遇到过的最常见的例子,除了很多例子都缺乏验证。
方法三:
type Realm struct {
base.Entity
friendlyName string
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
if err = validateFriendlyName(friendlyName); err != nil {
return nil, err
}
entity := base.NewEntity(id)
return &Realm{
Entity: entity,
friendlyName: friendlyName,
}, nil
}
func (r *Realm) FriendlyName() string { return r.friendlyName }
func (r *Realm) SetFriendlyName(input string) error {
if err := validateFriendlyName(input); err != nil {
return err
}
r.friendlyName = input
return nil
}
这里的友好名称类型只是一个字符串,但不可变。这个结构让我想起了 Java 代码...
在查找领域时,存储库层是否应该使用域模型中的 setter 方法来构建领域聚合?
我尝试将 DTO 实现放置在与 encoded/decoded to/from 领域聚合相同的包 (dto_sql.go) 中,但是将这个问题放在域包中感觉有点不对劲。
如果您遇到与我相同的问题,知道任何其他方法或有任何需要指出的地方,我将非常有兴趣收到您的来信!
首先,正如其他评论者所说的那样,您必须查看 DDD 的 目标 并确定该方法是否有价值。 DDD 增加了架构的一些复杂性(大部分是在构建项目和基本类型的初始阶段)以及之后您必须处理的样板文件和仪式的数量。
在许多情况下,设计更简单,例如一种 CRUD 方法,效果最好。 DDD 的亮点在于其本身在功能方面更为复杂 and/or 的应用程序,其中功能的数量预计会随着时间的推移而显着增长。技术优势可以体现在模块化、可扩展性和可测试性方面,但是 - 最重要的是恕我直言 - 提供一个过程,在这个过程中,您可以让 non-technical 利益相关者一起并将他们的愿望转化为代码,而不会在此过程中丢失他们。
有一系列很棒的博文,Wild Workouts Go DDD Example,带您了解从传统 Go CRUD-based REST API 设计到 full-blown 的重构过程DDD 架构,分几个步骤。
Robert Laszczak,系列作者对DDD的定义如下:
Ensure that you solve valid problems in the optimal way. After that implement the solution in a way that your business will understand without any extra translation from technical language needed.
他认为 Golang + DDD 是编写业务应用程序的绝佳方式。
这里要理解的关键是决定你的设计要走多远(没有双关语意)。重构逐渐引入新的架构概念,在每个步骤中,您都应该决定它是否足以满足您的用例,权衡利弊以进一步发展。他们从 DDD Lite 版本开始非常 KISS,然后在 CQRS、Clean Architecture、微服务甚至事件源方面走得更远。
我在许多项目中看到的是,它们会立即进入 Full Monty,造成矫枉过正。尤其是微服务和事件溯源增加了很多(意外的)复杂性。
我还没有 well-versed 使用 Go(实际上对这门语言还很陌生),但我会尝试一下您的选择并提供一些注意事项。也许更有经验的 Go 开发人员可以纠正我,我去哪里 off-the-mark :)
对于我自己的项目,我正在研究一个干净的架构(Ports & Adapters, Inversion of Control) + CQRS + DDD 组合。
Wild Workouts 示例提供了充足的灵感,但仍需要进行一些调整和补充。
我的目标是,在代码库的文件夹结构中,开发人员应该立即识别功能/用例(epics、用户故事、场景)所在的位置,并拥有 self-contained 完全一致的领域,直接反映客户容易理解的 Ubiquitous Language and are separately testable. Part of testing will be text-only BDD 脚本和 end-users.
将涉及一些样板文件,但是 - 鉴于上述情况 - 我认为利大于弊(如果您的应用程序保证 DDD)。
你的选项 #1 在我看来是最好的,但有一些额外的观察(注意:我会坚持你的命名,这会让其中的一些看起来有点矫枉过正..再次是想法那个算)。
- 而不是
Entity
我会说 Realm
代表一个 AggregateRoot
.
- 这可以是隐式的,也可以内联一个
base.AggregateRoot
。
- 聚合根是域的访问点,并确保其状态始终一致。
- 因此
Realm
的内部状态应该是不可变的。状态更改通过函数发生。
- 除非真的微不足道并且不太可能改变,否则我会在单独的文件中实现
FriendlyName
值 object。
- 域的一部分也是
RealmRepository
,但这仅提供一个界面。
现在我正在使用 CQRS,它是对您的代码片段中显示的内容的扩展。在这:
- 应用层中可能有一个
ChangeFriendlyName
命令处理程序。
- 处理程序可以访问存储库实现,例如
InMemRealmRepository
.
- 可能会将
CreateRealmParams
传递给命令,然后命令进行验证。
- 处理程序逻辑可能首先从数据库中获取
Realm
聚合。
- 然后构造一个新的
FriendlyName
(也可以封装在一个Realm
函数调用中)
- 对
Realm
的函数调用更新状态并排队 FriendlyNameChanged
事件。
- 命令处理程序将更改保存到 InMemory 数据库。
- 只有在没有错误的情况下,命令处理程序才会对聚合调用
Commit()
。
- 现在可以发布一个或多个排队的事件,例如通过
EventBus
,在需要时进行处理。
关于选项 #1 的代码有一些变化(希望我做对了)..
realm.go - 聚合根
type Realm struct {
base.AggregateRoot
friendlyName FriendlyName
}
// Change state via function calls. Not shown: event impl, error handling.
// Even with CQRS having Events is entirely optional. You might implement
// it solely to e.g. maintain an audit log.
func (r *Realm) ChangeFriendlyName(name FriendlyName) {
r.friendlyName = name
var ev = NewFriendlyNameChanged(r.id, name)
// Queue the event.
r.Apply(ev)
}
// You might use Params types and encapsulate value object creation,
// but I'll pass value objects directly created in a command handler.
func CreateRealm(id base.AID, name FriendlyName) (*Realm, error) {
ar := base.NewAggregateRoot(id)
// Might do some param validation here.
return &Realm{
AggregateRoot: ar,
friendlyName: name,
}, nil
}
friendlyname.go - 值 object
type FriendlyName struct {
value string
}
// Domain error. Part of ubiquitous language.
var FriendlyNameInvalid = errors.New("invalid friendly name")
func (n FriendlyName) String() string { return n.value }
func NewFriendlyName(input string) (FriendlyName, error) {
if input == "" {
return FriendlyNameInvalid
}
// perhaps some regexp rule here...
return FriendlyName{value: input}, nil
}
我最近一直在研究领域驱动设计,必须说这种类型的架构设计触发了我的一些东西。当我尝试将它的概念应用到我的 Go 项目时,我遇到了一些障碍。以下是一些示例方法,但我不确定要使用哪种方法。
项目结构摘录:
├── api/
├── cmd/
├── internal/
| ├── base/
| | ├── eid.go
| | ├── entity.go
| | └── value_object.go
| ├── modules/
| | ├── realm/
| | | ├── api/
| | | ├── domain/
| | | | ├── realm/
| | | | | ├── service/
| | | | | ├── friendly_name.go
| | | | | ├── realm.go
| | | | | └── realm_test.go
| | | | └── other_subdomain/
| | | └── repository/
| | | ├── inmem/
| | | └── postgres/
所有方法通用:
package realm // import "git.int.xxxx.no/go/xxxx/internal/modules/realm/domain/realm"
// base contains common elements used by all modules
import "git.int.xxxx.no/go/xxxx/internal/base"
方法一:
type Realm struct {
base.Entity
FriendlyName FriendlyName
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
var r = new(Realm)
r.Entity = base.NewEntity(id)
r.FriendlyName, err = NewFriendlyName(params.FriendlyName)
return r, err
}
type FriendlyName struct {
value string
}
var ErrInvalidFriendlyName = errors.New("invalid friendly name")
func (n FriendlyName) String() string { return n.value }
func NewFriendlyName(input string) (FriendlyName, error) {
if input == "" {
return ErrInvalidFriendlyName
}
// perhaps some regexp rule here...
return FriendlyName{value: input}, nil
}
使用这种方法,我认为长 运行 中会有很多重复代码,但至少 FriendlyName 值对象根据 DDD 要求是不可变的,并且可以附加更多方法。
方法二:
type Realm struct {
base.Entity
FriendlyName string
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
if err = validateFriendlyName(params.FriendlyName); err != nil {
return nil, err
}
entity := base.NewEntity(id)
return &Realm{
Entity: entity,
FriendlyName: params.FriendlyName,
}, nil
}
这一定是我遇到过的最常见的例子,除了很多例子都缺乏验证。
方法三:
type Realm struct {
base.Entity
friendlyName string
}
type CreateRealmParams struct {
FriendlyName string
}
func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
var err error
if err = validateFriendlyName(friendlyName); err != nil {
return nil, err
}
entity := base.NewEntity(id)
return &Realm{
Entity: entity,
friendlyName: friendlyName,
}, nil
}
func (r *Realm) FriendlyName() string { return r.friendlyName }
func (r *Realm) SetFriendlyName(input string) error {
if err := validateFriendlyName(input); err != nil {
return err
}
r.friendlyName = input
return nil
}
这里的友好名称类型只是一个字符串,但不可变。这个结构让我想起了 Java 代码... 在查找领域时,存储库层是否应该使用域模型中的 setter 方法来构建领域聚合? 我尝试将 DTO 实现放置在与 encoded/decoded to/from 领域聚合相同的包 (dto_sql.go) 中,但是将这个问题放在域包中感觉有点不对劲。
如果您遇到与我相同的问题,知道任何其他方法或有任何需要指出的地方,我将非常有兴趣收到您的来信!
首先,正如其他评论者所说的那样,您必须查看 DDD 的 目标 并确定该方法是否有价值。 DDD 增加了架构的一些复杂性(大部分是在构建项目和基本类型的初始阶段)以及之后您必须处理的样板文件和仪式的数量。
在许多情况下,设计更简单,例如一种 CRUD 方法,效果最好。 DDD 的亮点在于其本身在功能方面更为复杂 and/or 的应用程序,其中功能的数量预计会随着时间的推移而显着增长。技术优势可以体现在模块化、可扩展性和可测试性方面,但是 - 最重要的是恕我直言 - 提供一个过程,在这个过程中,您可以让 non-technical 利益相关者一起并将他们的愿望转化为代码,而不会在此过程中丢失他们。
有一系列很棒的博文,Wild Workouts Go DDD Example,带您了解从传统 Go CRUD-based REST API 设计到 full-blown 的重构过程DDD 架构,分几个步骤。
Robert Laszczak,系列作者对DDD的定义如下:
Ensure that you solve valid problems in the optimal way. After that implement the solution in a way that your business will understand without any extra translation from technical language needed.
他认为 Golang + DDD 是编写业务应用程序的绝佳方式。
这里要理解的关键是决定你的设计要走多远(没有双关语意)。重构逐渐引入新的架构概念,在每个步骤中,您都应该决定它是否足以满足您的用例,权衡利弊以进一步发展。他们从 DDD Lite 版本开始非常 KISS,然后在 CQRS、Clean Architecture、微服务甚至事件源方面走得更远。
我在许多项目中看到的是,它们会立即进入 Full Monty,造成矫枉过正。尤其是微服务和事件溯源增加了很多(意外的)复杂性。
我还没有 well-versed 使用 Go(实际上对这门语言还很陌生),但我会尝试一下您的选择并提供一些注意事项。也许更有经验的 Go 开发人员可以纠正我,我去哪里 off-the-mark :)
对于我自己的项目,我正在研究一个干净的架构(Ports & Adapters, Inversion of Control) + CQRS + DDD 组合。
Wild Workouts 示例提供了充足的灵感,但仍需要进行一些调整和补充。
我的目标是,在代码库的文件夹结构中,开发人员应该立即识别功能/用例(epics、用户故事、场景)所在的位置,并拥有 self-contained 完全一致的领域,直接反映客户容易理解的 Ubiquitous Language and are separately testable. Part of testing will be text-only BDD 脚本和 end-users.
将涉及一些样板文件,但是 - 鉴于上述情况 - 我认为利大于弊(如果您的应用程序保证 DDD)。
你的选项 #1 在我看来是最好的,但有一些额外的观察(注意:我会坚持你的命名,这会让其中的一些看起来有点矫枉过正..再次是想法那个算)。
- 而不是
Entity
我会说Realm
代表一个AggregateRoot
. - 这可以是隐式的,也可以内联一个
base.AggregateRoot
。 - 聚合根是域的访问点,并确保其状态始终一致。
- 因此
Realm
的内部状态应该是不可变的。状态更改通过函数发生。 - 除非真的微不足道并且不太可能改变,否则我会在单独的文件中实现
FriendlyName
值 object。 - 域的一部分也是
RealmRepository
,但这仅提供一个界面。
现在我正在使用 CQRS,它是对您的代码片段中显示的内容的扩展。在这:
- 应用层中可能有一个
ChangeFriendlyName
命令处理程序。 - 处理程序可以访问存储库实现,例如
InMemRealmRepository
. - 可能会将
CreateRealmParams
传递给命令,然后命令进行验证。 - 处理程序逻辑可能首先从数据库中获取
Realm
聚合。 - 然后构造一个新的
FriendlyName
(也可以封装在一个Realm
函数调用中) - 对
Realm
的函数调用更新状态并排队FriendlyNameChanged
事件。 - 命令处理程序将更改保存到 InMemory 数据库。
- 只有在没有错误的情况下,命令处理程序才会对聚合调用
Commit()
。 - 现在可以发布一个或多个排队的事件,例如通过
EventBus
,在需要时进行处理。
关于选项 #1 的代码有一些变化(希望我做对了)..
realm.go - 聚合根
type Realm struct {
base.AggregateRoot
friendlyName FriendlyName
}
// Change state via function calls. Not shown: event impl, error handling.
// Even with CQRS having Events is entirely optional. You might implement
// it solely to e.g. maintain an audit log.
func (r *Realm) ChangeFriendlyName(name FriendlyName) {
r.friendlyName = name
var ev = NewFriendlyNameChanged(r.id, name)
// Queue the event.
r.Apply(ev)
}
// You might use Params types and encapsulate value object creation,
// but I'll pass value objects directly created in a command handler.
func CreateRealm(id base.AID, name FriendlyName) (*Realm, error) {
ar := base.NewAggregateRoot(id)
// Might do some param validation here.
return &Realm{
AggregateRoot: ar,
friendlyName: name,
}, nil
}
friendlyname.go - 值 object
type FriendlyName struct {
value string
}
// Domain error. Part of ubiquitous language.
var FriendlyNameInvalid = errors.New("invalid friendly name")
func (n FriendlyName) String() string { return n.value }
func NewFriendlyName(input string) (FriendlyName, error) {
if input == "" {
return FriendlyNameInvalid
}
// perhaps some regexp rule here...
return FriendlyName{value: input}, nil
}