Golang и DDD доменное моделирование
В последнее время я изучал дизайн, управляемый предметной областью, и должен сказать, что этот тип архитектурного дизайна что-то вызывает во мне. Когда я пытаюсь применить его концепции к своему проекту 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"
Способ № 1:
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 и открывает для присоединения дополнительных методов.
Способ № 2:
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
}
Это, наверное, самый распространенный пример, который я встречал там, за исключением проверки, которой не хватает во многих примерах.
Способ № 3:
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... При поиске области должен ли уровень хранилища использовать методы установки из модели предметной области для построения агрегата области? Я попытался с реализацией DTO, помещенной в тот же пакет (dto_sql.go), который кодировал / декодировал в / из агрегата области, но это было неправильно, если поместить эту проблему в пакет домена.
Если вы сталкиваетесь с теми же проблемами, что и я, знаете о любом другом методе или хотите что-то указать, мне будет очень интересно услышать от вас!
1 ответ
Прежде всего, как справедливо говорят другие комментаторы, вы должны посмотреть на цели DDD и решить, заслуживает ли подход. DDD усложняет архитектуру (в основном на начальном этапе при структурировании вашего проекта и базовых типов) и количество шаблонов и церемоний, с которыми вам придется иметь дело после этого.
Во многих случаях более простые конструкции, например подход CRUD, работают лучше всего. Где DDD блистает, так это в приложениях, которые сами по себе более сложны с точки зрения функциональности и/или где количество функций, как ожидается, со временем значительно возрастет. Технические преимущества могут заключаться в модульности, расширяемости и тестируемости, но — самое главное, имхо — в обеспечении процесса, в котором вы можете взять с собой нетехнических заинтересованных лиц и воплотить их пожелания в код, не теряя их по пути.
Есть отличная серия сообщений в блоге Wild Workouts Go DDD Example, в которых вы пройдете процесс рефакторинга традиционного дизайна REST API на основе Go CRUD до полноценной архитектуры DDD в несколько этапов.
Роберт Лащак , автор серии, определяет DDD следующим образом:
Убедитесь, что вы решаете действительные проблемы оптимальным способом . После этого внедрите решение таким образом, чтобы ваш бизнес был понятен без дополнительного перевода с технического языка.
И он считает Golang + DDD отличным способом написания бизнес-приложений.
Ключ к пониманию здесь заключается в том, чтобы решить, как далеко вы хотите зайти (без каламбура) в своем дизайне. Рефакторинг постепенно вводит новые концепции архитектуры, и на каждом из этих шагов вы должны решить, достаточно ли этого для вашего варианта использования, взвесить все за и против, чтобы идти дальше. Они начинают с KISS с версии DDD Lite , а затем идут дальше с CQRS, чистой архитектурой, микросервисами и даже Event Sourcing.
Что я вижу во многих проектах, так это то, что они сразу же превращаются в Full Monty, создавая перебор. Особенно микросервисы и источники событий добавляют много (случайно) сложности.
Я еще не очень хорошо разбираюсь в Go (на самом деле совсем новичок в этом языке), но попробую ваши варианты и предложу некоторые соображения. Может быть, более опытные разработчики Go поправят меня, где я иду не по адресу :)
Для моего собственного проекта я изучаю чистую архитектуру (порты и адаптеры , инверсия управления) + комбинацию CQRS + DDD .
Пример Wild Workouts дает достаточно вдохновения, но здесь и там потребуются некоторые изменения и дополнения.
Моя цель состоит в том, чтобы в структуре папок кодовой базы разработчики сразу же узнавали, где находятся функции/прецеденты (эпики, пользовательские истории, сценарии), и имели автономные, полностью согласованные домены, которые напрямую отражают Ubiquitous Language и могут тестироваться отдельно. Частью тестирования будут текстовые сценарии BDD , которые легко понять заказчику и конечным пользователям.
Будет задействован некоторый шаблон, но, учитывая вышесказанное, я думаю, что плюсы перевешивают минусы (если ваше приложение требует DDD).
Ваш вариант № 1 кажется мне лучшим, но с некоторыми дополнительными замечаниями (примечание: я буду придерживаться вашего именования, из-за чего некоторые из них покажутся излишними... опять же, важны идеи ).
- Вместо
Entity
я бы сказал, что представляет собойAggregateRoot
. - Это может быть так неявно или может быть встроено
base.AggregateRoot
. - Совокупный корень — это точка доступа к домену, обеспечивающая постоянную согласованность его состояния.
- Следовательно, внутреннее состояние должно быть неизменным. Изменения состояния происходят через функции.
- Если это действительно тривиально и вряд ли изменится, я бы реализовал объект значения в отдельном файле.
- Также частью домена является
RealmRepository
но это обеспечивает не более чем интерфейс.
Теперь я использую CQRS, который является расширением того, что показано в ваших фрагментах кода. В этом:
- Может быть
ChangeFriendlyName
Обработчик команд на уровне приложения. - Обработчик имеет доступ к реализации репозитория, например
InMemRealmRepository
. - Может пройти
CreateRealmParams
в команду, которая затем выполняет проверку. - Логика обработчика может начинаться с получения агрегата из базы данных.
- Затем строит новый
FriendlyName
(также может инкапсулироваться в вызове функции). - Вызов функции для
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 — объект-значение
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
}