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
}
Другие вопросы по тегам