Skip to content

Latest commit

 

History

History
521 lines (431 loc) · 26.2 KB

entity.md

File metadata and controls

521 lines (431 loc) · 26.2 KB

DDD на практике в Golang: Сущности

intro Фото George Prentzas из Unsplash

В предыдущей статье я попытался дать некоторое представление о шаблоне проектирования Объект-значение (Value Object) и о том как мы должны использовать его в Go. Сегодня продолжим наш обзор, рассмотрев шаблон проектирования, называющийся Сущность (Entity).

Многие разработчики слышали о Сущностях тысячи раз, даже если они никогда не использовали DDD. Некоторые благодаря PHP фреймфоркам, некоторые - в Java. Хотя на вид они могут быть похожи, его назначение в DDD совершенно другое.

Его назначение в DDD стало для меня своего рода откровением. Сначала оно казалось странным, особенно для человека, имеющего опыт работы с PHP MVC фреймворками, но сейчас DDD подход кажется более логичным.

Это не часть ORM

Выше по ссылкам для PHP и Java фреймворков, Сущность играет роль различных строительных блоков от Row Data Gateway (Шлюз к данным записи) до Active Record (Активная запись). Из-за этого начинают неправильно использовать шаблон Сущность.

Цель Сущности (Entity) не отразить схему таблицы, а сохранить важную бизнес-логику. Когда я работаю над каким-либо приложением, мои Сущности не совпадают с таблицами в базе данных.

Когда дело доходит до реализации, в первую очередь я всегда создаю уровень предметной области (Domain Layer). Затем я связываю бизнес-логику, структурированную в Сущностях, Объектах-значениях и Сервисах (службах).

Как только бизнес-логика завершена и покрыта unit тестами, я реализую инфраструктурный уровень, где решаются такие технические детали как подключение к базе данных.

Как видно в примере ниже, мы разделили Сущность от его представления в базе данных. Объекты, которые отражают схему базы данных, заданы отдельно и относятся больше к объектам передачи данных (Data Transfer Objects).

// Сущность внутри уровня предметной области
type BankAccount struct {
    ID       int
    IsLocked bool
    Wallet   Wallet
    Person   Person
}

// Интерфейс репозитория внутри уровня предметной области
type BankAccountRepository interface {
    Get(ctx context.Context, ID int) (*domain.BankAccount, error)
}

// DTO внутри инфраструктурного уровня
type BankAccountGorm struct {
    ID         int          `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"`
}

// фактическая реализация репозитория внутри инфраструктурного уровня
type BankAccountRepository struct {
    //
    // какие-то поля
    //
}

func (r *BankAccountRepository) Get(ctx context.Context, ID uint) (*domain.BankAccount, error) {
    var dto dtopackage.BankAccountGorm
    //
    // какой-то код
    //
    return &domain.BankAccount{
        ID:       dto.ID,
        IsLocked: dto.IsLocked,
        Wallet: domain.Wallet{
            Amount:   dto.Amount,
            Currency: dto.Currency.ToEntity(),
        },
        Person: dto.Person.ToEntity(),
    }, nil
}
*Пример, когда Сущность отделена от базы данных*

Вышеприведенный пример — один из многих вариантов, который мы можем реализовать. Хотя структура как Entity, так и DTO может различаться в зависимости от задач бизнеса, которые мы решаем (например, несколько кошельков (Wallet) на одном банковском счёте (BankAccount)), идея всегда одна и та же.

Мы всегда храним интерфейсы репозиториев на уровне предметной области. Внутри этого уровня (нижнего в многоуровневой архитектуре, которую я использую), некоторые сервисы предметной области могут зависеть от них. Поэтому они хотя бы должны знать об их существовании.

Репозитории предоставляют контракт, который гарантирует, что мы будем работать с объектами Entity из нашего уровня предметной области по крайней мере за их пределами. Внутри репозитория мы можем делать всё что захотим, пока возвращаем правильные результаты.

С помощью такой структуры мне всегда удавалось отделить мою бизнес-логику от того места, где они будут храниться. Как только мне нужно внести некоторые изменения в базу данных, необходимо изменить только методы отображения, которые преобразуют DTO в Entity и наоборот.

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       int
    IsLocked bool
    Wallet   Wallet
    Person   Person
}

Некоторые примеры Entity

Иногда Entity могут содержать сложную бизнес-логику и данные, которые поступают из различных источников, например, базы данных, NoSQL, и некоторых внешних API. В этих случаях идея разделения бизнес-уровня от технических деталей помогает как никогда.

Идентификатор

Основное отличие Сущности от Объекта-значения — это идентификатор. У Сущностей есть идентификаторы. Идентификатор — это единственное свойство Сущностей, которое определяет уникальность каждой из них.

У двух Сущностей могут отличаться значения одного или нескольких полей, но если они имеют один и тот же идентификатор, то мы говорим об одной и той же сущности. Из-за этого при проверке на равенство, мы проверяем только их идентификаторы.

type Currency struct {
    ID       uint
    Code     string
    Name     string
    HtmlCode string
}

func (c Currency) IsEqual(other Currency) bool {
    return other.ID == c.ID
}

Существует три типа идентификаторов. Они могут генерироваться приложением, то есть в какой-то момент, перед отправкой в хранилище, мы создаём новый идентификатор для Сущности. В таких случаях я использую UUID.

Во втором случае можно использовать естественные идентификаторы. Мы можем работать с биологическими идентификаторами, когда Сущностью является человек или объекты из реального мира с некоторым уникальным свойством. Примером может быть номер социального страхования (Social Security Number), ИИН и т. д.

Наконец, наиболее распространенный способ - это идентификатор, генерируемый базой данных. Я придерживаюсь этого подхода, даже когда могу реализовать любое из двух предыдущих решений.

// идентификатор генерирует приложение
type Currency struct {
    ID       uuid.UUID
    Code     string
    Name     string
    HtmlCode string
}

func NewCurrency() Currency {
	return Currency{
        ID: uuid.New(), // генерируем новый UUID 		
    }
}

// естественный идентификатор
type Person struct {
    SSN         string // номер социального страхования
    FirstName   string
    LastName    string
    DateOfBirth time.Time
}

// идентификатор генерируется базой данных
type BankAccount struct {
    ID       int
    IsLocked bool
    Wallet   Wallet
    Person   Person
}

type BankAccountGorm struct {
    ID         int          `gorm:"primaryKey;autoIncrement:true"`
    IsLocked   bool
    Amount     int
    CurrencyID uint
    PersonID   uint
}

Различные типы идентификаторов

Для индексации и запросов я предпочитаю использовать только числа. Во многих случаях, когда необходимо работать со сгенерированными приложением ключами или естественными ключами, мы должны иметь дело со строками или искать способ правильно сопоставить их с числовыми значениями в базе данных.

Поскольку идентификатор является основным отличием между Сущностью (Entity) и Объектом-значением (Value Object), не трудно догадаться, что от него можно легко избавиться. На самом деле, в зависимости от Ограниченного Контекста, объект можно использовать как Entity или Value Object.

// сервис Transaction
type Currency struct {
	ID       uint
	Code     string
	Name     string
	HtmlCode string
}

// web сервис
type Currency struct {
	Name     string
	HtmlCode string
}

Структура с одинаковым названием, но используемая для разных целей

В вышеприведенном примере, Currency может играть роль основной Сущности внутри одного ограниченного контекста. Им может быть сервис проведения транзакций (Transaction Service) или сервис обмена валют (Exchange Service). Но там, где нам нужен этот объект для форматирования в пользовательском интерфейсе, Currency можно использовать как простой объект-значение.

Валидация

В отличие от объекта-значения, сущность может изменять свое состояние пока существует. Это означает, что необходима постоянная валидация всякий раз, когда мы хотим изменить Entity.

type BankAccount struct {
    ID       int
    IsLocked bool
    Wallet   Wallet
    // 
    // другие поля
    //
}

func (ba *BankAccount) Add(other Wallet) error {
    if ba.IsLocked {
        return errors.New("account is locked")
    }
    //
    // что-то делаем
    //
    return nil
}

Пример простой валидации

Да, в вышеприведенном примере мы можем напрямую получить доступ к Wallet и изменить его, не используя метод Add. Я не очень люблю геттеры и сеттеры в Go. На мой взгляд код становится неподдерживаемым, когда в нём много функций, возвращающих или устанавливающих значения.

В таких случаях я больше полагаюсь на здравомыслие всех разработчиков, которые должны понимать, что изменять состояние Сущности (Entity) нужно, используя существующие методы.

Тем не менее, этот вопрос каждый разработчик должен решить для себя сам. Использовать геттеры и сеттеры вместе с приватными полями также является вполне работоспособным решением.

Добавляем логику работы

Основная цель DDD - максимально точно отразить бизнес-процесс. Поэтому ничего удивительного нет в том, что мы создали так много методов как часть нашего уровня предметной области.

Эти методы могут принадлежать разным объектам. Поскольку Сущности хранят самое сложное состояние по сравнению с другими частями кода, они также могут иметь наибольшее число функций, описывающих их логику работы.

Иногда несколько полей внутри Сущности постоянно взаимодействуют друг с другом. Когда мы используем одно из них в каком-то бизнес-инварианте, то, вероятно, нам понадобится и другой.

В таких случаях мы всегда можем сгруппировать такие поля в один объект-значение Value Object и передать его в Сущность Entity для работы с ним. Но нужно делать это аккуратно, чтобы избежать случаев, когда разделить логику между Entity и Value Objects тяжело.

type Wallet struct {
    Amount   int
    Currency Currency
}

type BankAccount struct {
    ID       int
    IsLocked bool
    Wallet   Wallet
    // 
    // другие поля
    //
}

// неправильно - в BankAccount находится часть логики объекта-значения Wallet
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 и наличия достаточной суммы в кошельке — это код с запашком.

В таком случае я перемещаю всю логику работы в объект-значение, за исключением, конечно, проверки заблокирован ли BankAccount. Таким образом, в кошельке находится часть кода, ответственная за проверку и списание необходимой суммы.

type Wallet struct {
    Amount   int
    Currency Currency
}
// правильно - объект-значение Wallet проверяет свои собственные инварианты
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       int
    IsLocked bool
    Wallet   Wallet
    // 
    // другие поля
    //
}

// правильно - сущность BankAccount проверяет свои собственные инварианты
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
}

Правильное разделение логики работы между сущностью Entity и объектом-значением Value Object

Таким образом, объект-значение Wallet может входить в любую другую сущность или объект-значение, и он по-прежнему сможет поддерживать операцию списания, в зависимости от его внутреннего состояния. С другой стороны, в BankAccount можно создать дополнительный метод списания суммы для не заблокированных аккаунтов, не копируя ту же логику.

Сущность может передавать своё поведение другим строительным блокам, например, сервисам предметной области (описаны в следующей статье). Я перемещаю эти методы в Сервисы в двух случаях.

Во-первых, когда логика работы слишком сложная. Таким образом, может возникнуть необходимость использования шаблонов проектирования Specification, Policy или других Entities и Value Objects. Она может зависеть от результатов работы Репозиториев или других Сервисов.

Во-вторых, когда логика не очень сложная, но непонятно к чему она относится. Она может относится к одной Сущности, но касаться другой или какого-либо объекта-значения.

type ExchangeRates []ExchangeRate

type Currency struct {
    ID uint
    // 
    // другие поля
    //
}

func (c *Currency) Exchange(to Currency, other Wallet, rates ExchangeRates) (Wallet, error) {
    //
    // что-то делаем
    //
}

Слишком большая ответственность для одной Сущности

В приведенном выше примере сущность Currency имеет метод Exchange. Этот метод уже принимает слишком много аргументов. И вопрос в том принадлежит ли он больше этой сущности, чем объекту-значению Wallet или сущности ExchangeRate.

Не говоря уже о том, что обмен определенной валюты может быть временно запрещен по политическим или экономическим причинам. Таким образом, мы добавим еще больше бизнес-инвариантов в Сущность Currency.

type Currency struct {
	ID uint
	//
	// другие поля
	//
}

type ExchangeRatesService struct {
	repository ExchangeRatesRepository
}

func (s *ExchangeRatesService) Exchange(to Currency, other Wallet) (Wallet, error) {
	//
	// что-то делаем
	//
}

Вводим Сервис предметной области, которому поручаем работу со сложной логикой

Когда бизнес-логика слишком большая, я всегда перемещаю её в отдельный сервис предметной области, например, ExchangeRatesService в вышеприведенном примере. При таком подходе я всегда могу адаптировать уровень предметной области, добавляя новые Стратегии (Policy) предметной области.

Иногда перенос логики работы в другие строительные блоки кажется естественным процессом. Однако к этому следует относиться очень осторожно. Перенос слишком большого количества логики из Сущностей в Сервисы предметной области может привести к другому коду с запашком, Anemic Domain Model.

type TransactionService struct {
    //
    // какие-то поля
    //
}

func (s *TransactionService) Add(account *BankAccount, second Wallet) error {
    //
    // что-то делаем
    //
}

Anemic Domain Model

В вышеприведенном примере показан Сервис предметной области TransactionService. Он берёт на себя логику работы Сущности BankAccount. Если нам не нужно проверять сложные бизнес-инварианты, то логику не следует переносить в Сервис предметной области.

Поиск подходящего места для определенной логики похож на выполнение физических упражнений, сначала может показаться сложным, но со временем становится более интуитивным. Даже сейчас мне иногда трудна найти подходящее место. Но чаще всего у меня получается правильно структурировать код.

Заключение

Хотя мы используем Сущности во многих фреймворках, это не означает, что там они применяются по назначению. Они должны представлять наши состояния и логику работы, а не отражать схему базы данных.

Сущности предоставляют нам дополнительные возможности для описания объектов, имеющих состояние, из реального мира. Во многих случаях они являются основой нашего приложения или по крайней мере, наша бизнес-логика не может работать без них.

Полезные ссылки на источники: