Введение


Итак, мы уже определились с областью применения, методологией и архитектурой. Перейдем от теории к практике, к написанию кода. Хотелось бы начать с шаблонов проектирования, которые описывают бизнес логику — Service и Interactor. Но прежде чем приступить к ним, изучим структурные паттерны — ValueObject и Entity. Разрабатывать мы будем на языке ruby. В дальнейших статьях разберем все паттерны, необходимые для разработки с использованием Вариативной архитектуры. Все наработки, являющиеся приложениями к данному циклу статей, соберем в отдельный фреймворк.


Blacjack & hockers


И мы уже подобрали подходящее название — LunaPark.
Текущие наработки выложенны на Github.
Разобрав все шаблоны, соберем один полноценный микросервис.


Так исторически сложилось


Была необходимость в рефакторинге сложного корпоративного приложения, написанного на Ruby on Rails. Была готовая команда ruby-разработчиков. Методология Domain Driven Development прекрасно подходила для этих задач, но готового решения на используемом языке не было. Не смотря на то, что выбор языка, в основном, был обусловлен нашей специализацией, он оказался достаточно удачным. Среди всех языков, что принято использовать для web-приложений, ruby, на мой взгляд, является самым выразительным. И поэтому больше других подходит для моделирования реальных объектов. Это не только мое мнение.


That is the Java world. Then you have the new-comers like Ruby. Ruby has a very expressive syntax, and at this basic level it should be a very good language for DDD (although I haven't heard of much actual use of it in those sorts of applications yet). Rails has generated a lot of excitement because it finally seems to make creation of Web UIs as easy as UIs were back in the early 1990s, before the Web. Right now, this capability has mostly been applied to building some of the vast number of Web applications which don't have much domain richness behind them, since even these have been painfully difficult in the past. But my hope is that, as the UI implementation part of the problem is reduced, that people will see this as an opportunity to focus more of their attention on the domain. If Ruby usage ever starts going in that direction, I think it could provide an excellent platform for DDD. (A few infrastructure pieces would probably have to be filled in.)

Eric Evans 2006

К сожалению, за прошедшие 13 лет ничего особо не изменилось. В интернете можно найти попытки приспособить для этого Rails, но все они выглядят ужасно. Фреймворк Rails тяжелый, медленный и не соответствует принципам SOLID. Смотреть без слез, как кто-то пытается изобразить на основе AсtiveRecord реализацию паттерна Репозиторий, очень тяжело. Мы решили взять на вооружение какой-нибудь микрофреймворк и доработать его до наших потребностей. Попробовали Grape, идея с авто-документированием показалась удачной, но в остальном он был заброшенным и мы быстро отказались от идеи его использования. И почти сразу стали использовать другое решение — Sinatra. Мы до сих пор продолжаем его использовать для REST Контроллеров и Эндпоинтов.


REST ?

Если вы разрабатывали web-приложения, то уже имеете представление о технологии. У нее есть свои плюсы и минусы, полное перечисление которых выходит за рамки данной статьи. Но для нас, как разработчиков корпоративных приложений, самым главным недостатком будет то, что REST (это понятно даже из названия) отражает не процесс, а его состояние. А преимуществом будет его понятность — технология ясна как back-end разработчикам, так и разработчикам front-end'a.
Но может тогда не ориентироваться на REST, а реализовать свое решение http + json? Если даже вам удасться разработать свой сервисный API, то предоставляя его описание третьим лицам вы получите много вопросов. Гораздо больше, чем если вы предоставите привычный REST.
Будем считать использование REST компромиссным решением. Мы используем JSON для лаконичности и jsonapi стандарт, чтобы не тратить время разработчиков на священные войны по поводу формата запросов.
В дальнейшем, когда мы будем разбирать Endpoint, мы увидим, что для того, чтобы избавится от rest, достаточно переписать всего один класс. Так что REST не должен вообще беспокоить, если остались сомнения на его счет.


В ходе написания нескольких микросервисов у нас появились наработки — набор абстрактных классов. Каждый такой класс можно написать за пол часа, его код легко понять, если знать для чего этот код предназначен.


Тут и возникли основные трудности. Новые сотрудники, не имевшие дело с практиками DDD и чистой архитектурой, не могли понять код и его предназначение. Если бы я сам увидел этот код впервые до того как прочитал Эванса, я бы воспринял его как legacy, over-engineering.


Чтобы побороть это препятствие было принято решение написать документацию (guideline), описывающую философию используемых подходов. Наброски этой документации показались удачными и было решено выложить их на Хабре. Абстрактные классы, которые повторялись из проекта в проект, было решено вынести в отдельный gem.


Философия


legacy-way
Если вспомнить какой-нибудь классический фильм про боевые искусства, то там будет крутой парень, который очень ловко обращается с шестом. Шест — это по сути палка, очень примитивный инструмент, один из первых, который попал человеку в руки. Но в руках мастера он становится грозным оружием.
Можно потратить время на создание пистолета, который не стреляет тебе в ногу, а можно потратить время на обучение технике стрельбы. Мы выделили 4 основных принципа:


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

Схожую философию можно проследить например у ОС ArchLinux — The Arch Way. На моем ноутбуке Linux долго не приживался, рано или поздно он ломался и мне постоянно приходилось его переустанавливать. Это вызывало ряд проблем, иногда серьезных вроде срыва deadline по работе. Но потратив один раз 2-3 дня на установку Arch я разобрался с тем как моя ОС работает. После этого она стала работать стабильнее, без сбоев. Мои заметки помогли мне устанавливать ее на новые ПК за пару часов. А обильная документация помогала мне решать новые задачи.


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


Фреймворк


Сложно назвать LunaPark фреймворком в привычном смысле. Frame — рамка, Work — работа. Мы же призываем не ограничивать себя рамками. Единственная рамка, которую мы декларируем, это та, которая подсказывает класс, в котором должна быть описана та или иная логика. Это скорее набор инструментов с объемной инструкцией к ним.
Каждый класс — абстрактный и имеет три уровня:


module LunaPark  # Фреймворк
  module Forms   # Паттерн
    class Single # Реализация/вариант
    end
  end
end

Если вы хотите реализовать форму, которая создает один элемент, вы наследуетесь от данного класса:


module Forms
  class Create < LunaPark::Forms::Single

Если несколько элементов, воспользуемся другой Реализацией.


module Forms
  class Create < LunaPark::Forms::Multiple

На данный момент не все наработки приведены в идеальный порядок и gem находится в состоянии альфа-версии. Мы будем приводить его поэтапно, согласованно с выходом статей. Т.е. если вы видите статью про ValueObject и Entity, то эти два шаблона уже реализованы. К окончанию цикла все они будут пригодны к использованию на проекте. Поскольку сам по себе фреймворк малополезен без связки с sinatra \ roda, будет сделан отдельный репозиторий, который покажет как все "прикрутить" для быстрого старта вашего проекта.


Фреймворк является прежде всего приложением к документации. Не стоит воспринимать данные статьи как документацию к фреймворку.


Итак, перейдем к делу.


Объект-Значение (Value)


— Какого роста твоя подруга?
— 151
— Ты стал встречаться со статуей свободы?

Примерно такой разговор мог бы произойти в штате Индиана. Рост человека это не просто число, но еще и единица измерения. Не всегда атрибуты объекта можно описать только примитивами (Integer, String, Boolean и т.п.), иногда требуются их комбинации:


  • Деньги это не просто число, это число (сумма) + валюта.
  • Дата состоит из числа, месяца и года.
  • Чтобы измерить вес нам недостаточно одного числа, требуется еще и единица измерения.
  • Номер паспорта состоит из серии и, собственно, из номера.

С другой стороны это не всегда комбинация, возможно это некое расширение примитива.
Телефонный номер зачастую воспринимается как число. С другой стороны, вряд ли у него должен быть метод сложения или деления. Возможно, есть метод, который будет выдавать код страны и метод, определяющий код города. Возможно, будет некий декоративный метод, который представит его не просто строкой чисел 79001231212, а читаемой строкой: 7-900-123-12-12.


а может в декоратор?

Если исходить из догм, то бесспорно — да. Если подходить к этой дилемме со стороны здравого смысла, то когда мы решим позвонить по этому номеру, то передадим телефону сам объект:


phone.call Values::PhoneNumber.new(79001231212)

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


Values::PhoneNumber.new(79001231212).to_s

Представим, что мы создаем сайт онлайн-казино "Три топора" и реализуем карточные игры. Нам понадобится класс 'игральная карта'.


module Values
  class PlayingCard < Lunapark::Values::Compound
    attr_reader :suit, :rank
  end
end

Итак, у нашего класса есть два атрибута только для чтения:


  • suit — масть карты
  • rank — достоинство карты

Эти атрибуты задаются только при создании карты и не могут изменятся при ее использовании. Вы конечно можете взять игральную карту и перечеркнуть 8, написать Q, но это недопустимо. В приличном обществе вас, скорее всего, пристрелят. Невозможность менять атрибуты после создания объекта определяет первое свойство Объекта-значения — иммутабельность.
Вторым важным свойством Объекта-Значения будет то, как мы их сравниваем.


module Values
  RSpec.describe PlayingCard  do
    let(:card)  { described_class.new suit: :clubs, rank: 10 }
    let(:other) { described_class.new suit: :clubs, rank: 10 }
    it 'should be eql' do
      expect(card).to eq other
    end
  end
end

Такой тест не пройдет, так как они будут сравниваться по адресу. Чтобы тест прошел, мы должны сравнивать Value-Obects по значению, для этого допишем метод сравнения:


def ==(other)
  suit == other.suit &&
  rank == other.rank
end

Теперь наш тест пройдет. Мы также можем дописать методы, которые отвечают за сравнение, но как нам сравнить 10 и K? Как вы уже, наверное, догадались, мы тоже их представим в виде Объектов-Значений. Ок, значит теперь мы должны будем инициировать десятку трефа так:


ten       = Values::Rank.new('10')
clubs     = Values::Suits.new(:clubs)
ten_clubs = Values::PlayingCards.new(rank: ten, clubs: clubs)

Три строчки это достаточно много для ruby. Для того, чтобы обойти это ограничение, мы введем третье свойство Объекта-Значения — оборачиваемость. Пусть у нас появится специальный метод класса .wrap, который может принимать значения различного типа и преобразовывать их в нужный.


class PlayingCard < Lunapark::Values::Compound
  def self.wrap(obj)
    case obj.is_a? self.class # Если мы получили объект класса PlayingCard
      obj                     # то мы его и вернем
    case obj.is_a? Hash       # Если мы получили хэш, то создадим на его основе
      new(obj)                # Новую игральную карту
    case obj.is_a String      # Если мы получили строку, то последний символ будет
      new rank: obj[0..-2], suit:[-1]  # мастью, остальные - достоинством карты.
    else                      # если тип не совпадает с ожидаемым
      raise ArgumentError     # выдаем ошибку.
    end
  end
  def initialize(suit:, rank:) # Еще модифицируем инициализатор класса
     @suit = Suit.wrap(suit)   # Это позволит нам оборачивать значения
     @rank = Rank.wrap(rank)
  end
 end

Такой подход дает большое преимущество:


ten         = Values::Rank.new('10')
clubs       = Values::Suits.new(:clubs)
from_values = Values::PlayingCard.wrap rank: ten,  suit: clubs
from_hash   = Values::PlayingCard.wrap rank: '10', suit: :clubs
from_obj    = Values::PlayingCard.wrap from_values
from_str    = Values::PlayingCard.wrap '10C' # тут хотелось бы использовать симол треф из utf кодировки, но хабр, их обрезает.

Все эти карты будут равны между собой. Если метод wrap разрастается хорошей практикой, будет вынесение его в отдельный класс. С точки зрения догматического подхода отдельный класс так же будет обязательным.
Хм, а как насчет места в колоде? Как узнать, является ли данная карта козырем? Это не игральная карта. Это Значение игральной карты. Это именно та надпись 10, которую вы ведите на углу картона.
К Объекту-Значению нужно относится также, как и к примитиву, который почему-то не реализовали в ruby. Отсюда возникает последнее свойство — Объект-Значение не привязан ни к какому домену.


Рекомендации


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

Фредерик Тейлор 1914

Арифметические операции должны возвращать новый объект

# GOOD
class Money < LunaPark::Values::Compound
  def +(other)
    other = self.class.wrap(other)
    raise ArgumentError unless same_currency? other
    self.class.new(
      amount: amount + other.amount,
      currency: currency
    )
  end
end

Атрибуты Объекта-Значения могу быть только примитивами или другими Объектами-значения

# GOOD
class Weight < LunaPark::Values::Compound
  def intialize(value:, unit:)
    @value = value
    @unit  = Unit.wrap(unit)
  end
end

# BAD
class PlaingCard < LunaPark::Value
  def initialize(rank:, suit:, deck:)
    ...
    @deck = Entity::Deck.wrap(deck) # зависимость от сущности
  end
end

Простые операции держите внутри методов класса

# GOOD
class Weight < LunaPark::Values::Compound
  def >(other)
    value > other.convert_to(unit).value
  end
end

Если операция "конвертация" большая, то возможно есть смысл вынести ее в отдельный класс

# UGLY
class Weight < LunaPark::Values::Compound
  def convert_to(unit)
    unit = Unit.wrap(unit)
    case { self.unit.to_sym => unit.to_sym }
    when { :kg => :ft }
      Weight.new(value: 2.2046 * value, unit.to_sym)
    when 
      # ...
    end
  end
end

# GOOD
#./lib/values/weight/converter.rb
class Weight
  class Converter < LunaPark::Services::Simple
    def initialize(weight, to:)
      ...
    end
  end
end
#./lib/values/weight.rb
class Weight < LunaPark::Values::Compound
  def convert_to(unit)
    Converter.call! self, to: unit
  end
end

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


Объект значение не может ничего знать о доменной логике

Предположим, что мы пишем интернет магазин, и у нас есть рейтинг товаров. Чтобы его получить, необходимо сделать запрос в БД через Репозиторий.


# DEADLY BAD
class Rate < LunaPark::Values::Single
  def top?(10)
    Repository::Rates.top(first: 10).include? self
  end
end

Сущность (Entity)


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


Сущность по Звансу
Если объект определяется уникальным индивидуальным существованием, а не набором атрибутов, это свойство следует с читать главным при определении объекта в модели. Определение класса должно быть простым и строиться вокруг непрерывности и уникальности цикла существования объекта. Найдите способ различать каждый объект независимо от его формы или истории существования. С особым вниманием отнеситесь к техническим требованиям, связанным с сопоставлением объектов по их атрибутам. Задайте операцию, которая бы обязательно давала неповторимый результат для каждого такого объекта, — возможно, для этого с объектом придется ассоциировать некий символ с гарантированной уникальностью. Такое средство идентификации может иметь внешнее происхождение, но это может быть и произвольный идентификатор, сгенерированный системой для ее собственного удобства. Однако такое средство должно соответствовать правилам различения объектов в модели. В модели должно даваться точное определение, что такое одинаковые объекты.

С точки зрения Мартина, Entity — это не объект, а слой. Этот слой объединят как объект, так и бизнес-логику по его изменению.


Разъеснение от Мартина
My view of Entities is that they contain Application Independent Business rules. They are not simply data objects. They may hold references to data objects; but their purpose is to implement business rule methods that can be used by many different applications.

Gateways return Entities. The implementation (below the line) fetches the data from the database, and uses it to construct data structures which are then passed to the Entities. This can be done either with containment or inheritance.

For example:

public class MyEntity { private MyDataStructure data;}

or

public class MyEntity extends MyDataStructure {...}

And remember, we are all pirates by nature; and the rules I'm talking about here are really more like guidelines...

Мы под Сущностью будем иметь в виду только структуру. В простейшем варианте класс Entity будет выглядеть так:


module Entities
  class MeatBag < LunaPark::Entities::Simple
    attr_accessor :id, :name, :hegiht, :weight, :birthday
  end
end

Мутабельный объект, описывающий структуры бизнес модели, может содержать примитивные типы и Значения.
Класс LunaPark::Entites::Simple невероятно прост, вы можете посмотреть его код, он дает нам только одну вещь — легкую инициализацию.


LunaPark::Entites::Simple
module LunaPark
  module Entities
    class Simple
      def initialize(params)
        set_attributes params
      end

      private

      def set_attributes(hash)
        hash.each { |k, v| send(:"#{k}=", v) }
      end
    end
  end
end

Вы можете написать:


john_doe = Entity::MeatBag.new(
  id:        42,
  name:     'John Doe',
  height:   '180cm',
  weight:   '80kg',
  birthday: '01-01-1970'
)

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


module Entities
  class MeatBag < LunaPark::Entites::Simple    
    attr_accessor :id, :name
    attr_reader   :heiht, :wight, :birthday

    def height=(height)
        @height = Values::Height.wrap(height)
    end
    def weight=(height)
        @height = Values::Weight.wrap(weight)
    end
    def birthday=(day)
      @birthday = Date.parse(day)
    end   
  end
end

Чтобы не тратить время на подобные конструкторы, у нас подготовлена более сложная Реализация LunaPark::Entites::Nested:


module Entities
  class MeatBag < LunaPark::Entities::Nested
    attr :id
    attr :name

    attr :heiht,    Values::Height, :wrap
    attr :weight,   Values::Weight, :wrap
    attr :birthday, Values::Date,   :parse
  end
end

Как можно догадаться из названия, данная Реализация позволяет делать древовидные структуры.


Давайте удовлетворим мою страсть к крупногабаритной бытовой технике. В прошлой статье мы проводили аналогию между "крутилкой" стиральной машины и архитектурой. А сейчас мы опишем такой важный бизнес-объект как холодильник:


Refregerator


class Refregerator < LunaPark::Entites::Nested
  attr :id, 
  attr :brand
  attr :title

  namespace :fridge do
    namespace :door do
      attr :upper, Shelf, :wrap
          attr :lower, Shelf, :wrap  
    end
    attr :upper, Shelf, :wrap
    attr :lower, Shelf, :wrap
  end

  namespace :main do
    namespace :door do
        attr :first,  Shelf, :wrap
        attr :second, Shelf, :wrap  
      attr :third,  Shelf, :wrap
    end

    namespace :boxes do
        attr :left,  Box, :wrap
        attr :right, Box, :wrap
    end

    attr :first,  Shelf, :wrap
    attr :second, Shelf, :wrap  
    attr :third,  Shelf, :wrap
    attr :fourth, Shelf, :wrap
  end

  attr :last_open_at, comparable: false
end  

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


У класса LunaPark::Entites::Nested есть еще 2 важных свойства:


Сравнимость:


module Entites
  class User < LunaPark::Entites::Nested
    attr :email
    attr :registred_at
  end
end

u1 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now)
u2 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now)

u1 == u2 # => false

Два указанных пользователя не эквивалентны, т.к. они были созданы в разное время и поэтому значение атрибута registred_at будет отличаться. Но если мы вычеркнем атрибут из списка сравниваемых:


module Entites
  class User < LunaPark::Entites::Nested
    attr :email
    attr :registred_at, comparable: false
  end
end

то получим два сопоставимых объекта.


Эта Реализация так же обладает свойством оборачиваемости — мы можем использовать метод класса`wrap


Entites::User.wrap(email: 'john.doe@mail.com', registred_at: Time.now)

Вы можете использовать в качестве Entity — Hash, OpenStruct или любой понравившийся вам gem, который поможет вам реализовать структуру вашей сущности.


Сущность — это модель бизнес объекта, оставьте ее простой. Если какое-то свойство не используется вашим бизнесом, не описывайте его.


Изменения сущности


Как вы заметили, класс Сущность не имеет никаких методов собственного изменения. Все изменения делаются из вне. Объект-значения тоже иммутабелен. Все те функции, которые в нем присутствуют, по большому счету декорируют сущность или создают новые объекты. Сама сущность остается неизменной. Для разработчика Ruby on Rails такой подход будет непривычен. Со стороны может показаться, что мы вообще используем ООП-язык для чего-то другого. Но если присмотреться поглубже — это не так. Разве окно может открыться само по себе? Автомобиль доехать до работы, гостиница забронироваться, милый котик получить нового подписчика? Это все внешние воздействия. Что-то происходит в реальном мире, а мы отражаем это у себя. По каждому запросу мы вносим изменения в свою модель. И тем самым поддерживаем ее в актуальном состоянии, достаточном для наших бизнес задач. Стоит разделять состояние модели и процессы, вызывающие изменения этого состояния. Как это сделать, мы рассмотрим в следующей статье.

Комментарии (18)


  1. questor
    15.01.2019 17:51

    Деньги это не просто число, это число (сумма) + валюта

    Ну да, есть такой паттерн. Только вот допустим, если посмотреть количество скачиваний Nuget-пакета для c# — можно удивиться какая низкая популярность. Видимо, большинство фигачит через decimal.


    Представим, что мы создаем сайт онлайн-казино "Три топора"

    А вы не хотите напомнить читателям, что игорная деятельность в рунете запрещена законом? Вот буквально сегодня прошла новость "ФНС нашла способ закрыть крупнейшее нелегальное интернет-казино".


    Не знаю какая позиция самого издательского дома ТМ по отношению к таким статьям (вряд ли это проплаченная реклама), но вот допустим во многих новостных изданиях я вижу постоянные скобки после названий террористических организаций — мол, это запрещённая в РФ организация. В общем, прямо по краю упоминание. Можно было бы и более нейтральный пример подобрать к статье.


    1. g6uru Автор
      15.01.2019 18:19

      Касательно денег, зачастую, в базе данных, для того чтобы сохранить деньги, используются поля с плавающей запятой. Это не совсем удобно, в дальнейшем возникают ряд ошибок связанных с округлением. По факту, не может в учете указваться половина копейки. Удобнее все хранить в фракциях — копейках, центах и.т.д., использовать целочисленный тип. Валюта тоже важна, кроме собственно валюты, она показывает разрядность тех или иных фракций. Если у рубля и доллара это одна сотая, то у иены ее нет, у сатоши одна девятимиллионная. Валюты могу так же разделять тестовые и реальные платежи. Если в проекте все эти проблемы не стоят, проще использовать decimal и не усложнять свою жизнь. В руби gem 'money' достаточно популярен, 2000 звезд на GitHub хороший показатель.


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


  1. naychenko
    16.01.2019 08:53

    После последнего абзаца, как-то сильно "запахло" анемичными моделями.


    1. g6uru Автор
      16.01.2019 10:16

      Не хочется скатываться до холивара. Ко всему надо подходить рационально и не уходить в крайности. Давайте разберем пример.


      Задача


  1. naychenko
    16.01.2019 11:50

    Два заявления

    Ко всему надо подходить рационально и не уходить в крайности.
    и
    класс Сущность не имеет никаких методов собственного изменения
    не совместимы. Согласно ООП и DDD, сущности точно могут менять собственное состояние. А вы им это не даете делать в принципе — это точно ошибка.
    Вы пытаетесь подменить понятие «Сущность/Агрегат» на понятие «Состояние сущности/агрегата». Сущность — это данные и поведение.


    1. g6uru Автор
      16.01.2019 12:02

      Приведите конкретный пример.


  1. naychenko
    16.01.2019 13:18

    dddsample-core — это реализация на Java примеров, о которых говорит Эрик Эванс в своей книге. Они хорошо прокомментированы, так что будет легко разобраться.
    Вот например реализация сущности Cargo содержит как данные (TrackingId, origin, delivery), причем доступ к ним защищен согласно принципу инкапсуляция ООП, таки поведение (specifyNewRoute, assignToRoute,...)


    1. g6uru Автор
      16.01.2019 14:40

      Хороший пример, методы которые описываются в приведенном классе, изменяют его. Это такие своеобразные, сеттеры + обработка. Но они никогда не вызываются напрямую, вся работа с ними ведется через сервисы: assignToRoute, specifyNewRoute, deriveDeliveryProgress.


      Данный пример и позволил выявить точку нашего взаимонепонимания. Я ни в коем случае не приываю вынести подобные методы изменения атрибутов из класса Сущности. Я призываю к тому что бизнес операции не могу быть описаны в модели и их необходимо выносить на слой бизнес-логики. Пользователь не должен менять сущность напрямую, он должен вызвать процесс, который приведет к изменению Сущности. Есть еще некоторые особенности от типа системы которую мы пишем Запрос-Ответ или Событийная. Тут мы говорим про web и rest, т.е. Запрос-Ответ.


      Руби не позволяет обращаться к атрибуту напрямую все это делается только через методы, инкапсуляция никогда не нарушается.


      class Foo
        arr_accessor :bar
      end
      
      # это по факту:
      
      class AnotherFoo
        def bar=(val)
          @bar = val
        end
      
        def bar
          @bar
        end
      end

      На хабре есть кнопка Ответить, под каждым комментарием, чтобы не создавать новую ветвь дискуссии.


      1. naychenko
        16.01.2019 14:53

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


        • Операция не принадлежит ни одному из объектов предметной области
        • Операция выполняется над различными объектами предметной области

        Злоупотребление приводит к «анемичной модели предметной области», как я говори ранее. Так что оставьте возможность реализации доменной логики в агрегате, если не хотите расстраивать Мартина и Эрика :)


        1. g6uru Автор
          16.01.2019 15:32

          Чтобы расстроить Мартина, надо понять что они имеют ввиду в этой статье как раз делается вывод, что к Entity нет доступа напрямую.


          Сервисы добавляются потому, что есть разные сценарии использования _Сущности.


          Сценарий "Включение телевизора": У нас есть девочка, которая включает телевизор. Она может включить его кнопкой, а может с пульта. Причем кнопкой она может и не дотянуться, т.к. у нее маленький рост.


          Проверка роста, точно, не должна находится в модели телевизора — выносим в сервис. Поиск пульта в другой сервис. Метод включения для Сущности Телевизор который включает светодиод и матрицу — ок.


          Методы изменения в сущности — ок. Сценарии использования — не ок. И сущность не может изменить себя сама.


          1. naychenko
            16.01.2019 16:18

            В вашем примере как минимум три сущности: девочка, пульт и телевизор. Именно девочка выполняет действие «включить телевизор», в рамках которого она понимает, что не может достать до кнопки (зная свой рост), она «ищет пульт» (еще одно действие девочки), и нажимает на нем кнопку. Пульт посылает сигнал телевизору. Телевизор включается, в ответ на сигнал пульта. Все операции — это поведение сущностей, а не сервисов.


            1. g6uru Автор
              16.01.2019 20:04

              Не соглашусь. Во-первых такая схема работает ровно до того момента, пока не появится мальчик — или дублирования кода, или миксины или множественное наследование.
              Во-вторых девочка возможно имеет методы use, take, age, но точно она не должна знать как включать телевизор, это явный RDM.


              1. naychenko
                16.01.2019 20:48

                В таком случае вы не до конца понимаете концепции DDD: единый язык, изолированные контексты, сущности и агрегаты — это части предметной области (домена), которые должны отражать моделируемые объекты из реального мира и одинаково трактоваться и бизнес экспертами и аналитиками и разработчиками ("девочка" — это сущность, а не сервис) и находить соответствующее отражение в коде.


                А вы все сводите только сервисам и "голым" моделям. Это точно не DDD и не ООП, а возврат к процедурному программирование.


                Посмотрите как Верон моделирует домен с помощью Event Storming. Он выделяет события, агрегаты и команды. Агрегаты исполняют команды (реализуют поведение), "выбрасывая" события. Сервисы вторичны в этой истории, и используются для моделирования процессов над агрегатами (бизнес логики вне агрегатов).


                1. g6uru Автор
                  17.01.2019 00:44


                  1. naychenko
                    17.01.2019 10:58

                    Да, именно это! Агрегаты исполняют команды (реализуют поведение), «выбрасывая» события. Наконец мы друг друга поняли.


            1. Ph-s
              17.01.2019 14:26

              Взаимодействие девочки с окружающими её системами, будь оно заключено в классе девочки так как в этом примере, экспоненциально увеличивает сопряженность системы.
              Как будет изменяться ответственность девочки когда у телевизора появится «переключить канал», «вставить кассету», «настроить звук», а далее девочке надо будет управлять чайником, автомобилем, хахалем? Сколько связей будет у этого класса?


              1. naychenko
                17.01.2019 14:35

                Связей будет столько — сколько есть в вашем бизнес домене. Для упрощения реализации используйте разбиение домена на изолированные контексты (в каждом из них будет своя девочка со связями нужными только в этом конексте, а значит их будет меньше). А вы для упрощения решения задачи предлагаете не разбивать домен на изолированные контексты, а создавать кучу разных сервисов в одном контексте. Это не DDD!

                Не заменяйте сущности предметной области сервисами для упрощения разработки — это «коверкает» единый язык контекста и противоречит самой концепции DDD, где мы пытаемся моделировать предметную область на одном языке между программистами и экспертами.


        1. g6uru Автор
          16.01.2019 15:53

          И соглашусь, что последний абзац стоит переписать.