Статьи

DDD для разработчиков Rails. Часть 3: Агрегаты.

Мои предыдущие статьи о DDD для разработчиков Rails

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

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

сводные показатели

На этот раз я хотел бы перейти к другому строительному блоку Domain Driven Design. Я хотел бы поговорить об агрегатах.

Мы все сталкивались с такой ситуацией раньше:

Вы начинаете с красиво оформленных групп объектов. Все объекты имеют четкие обязанности, и все взаимодействия между ними являются явными. Затем необходимо учитывать дополнительные требования, такие как транзакции, интеграция с внешними системами, генерация событий. Удовлетворить их всех и не соединить все объекты — нетривиальная задача. Обычно это происходит, когда ловушки базы данных, условные проверки и удаленные вызовы добавляются на специальной основе. Результат — больше связей между объектами. Следовательно, границы групп объектов становятся размытыми, а применение инвариантов становится более сложным. Вспомните все случаи, когда вы думали: «Может быть, мне нужно перезагрузить этот объект?» Это указывает на то, что ваши объекты взаимосвязаны, и вы не можете с уверенностью рассуждать о своем коде. Вместо этого вы просто угадываете.

Определение агрегатов — хорошее средство для описанной ситуации.

  • «Агрегат — это кластер связанных объектов, которые рассматриваются как единое целое с целью изменения данных». (См. Ресурсы )
  • Агрегат состоит из нескольких Сущностей и Объектов Значения, один из которых выбран как корень Агрегата.
  • Все внешние ссылки ограничены рутом. Объекты за пределами Агрегата могут содержать ссылки только на корень.
  • Доступ к другим членам Агрегата происходит через корень. Следовательно, никто (кроме Агрегата) не должен содержать ссылки на эти объекты.
  • Поскольку все внешние объекты могут содержать ссылку только на корень, применение инвариантов становится проще.
  • Агрегаты помогают уменьшить количество двунаправленных ассоциаций между объектами в системе, поскольку вы можете хранить ссылки только на корень. Это значительно упрощает дизайн и уменьшает количество невидимых изменений в графе объектов.

пример

Это может звучать слишком абстрактно, поэтому я хотел бы показать вам пример. Я собираюсь моделировать онлайн книжный магазин. Главной обязанностью модели станет продажа и доставка книг. Надеемся, что этот пример внесет некоторую ясность в определение агрегатов и продемонстрирует, как они могут быть реализованы в Rails.

эскиз

Это эскиз, иллюстрирующий все классы, которые будут формировать модель.

эскиз

Как видите, у меня есть:

  • Объекты: Заказ, Товар, Пользователь, Книга, Оплата
  • Объекты значения: Адрес
  • Услуги: Сервис отгрузки, Сервис оплаты
  • Просмотр: OrderPresenter

Определение совокупных границ

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

Есть несколько практических правил:

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

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

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

Ситуация с классом Payment отличается. Концептуально оплата является важной частью заказа. Кроме того, вы не можете одновременно оплатить заказ и изменить его. Решено, что Оплата становится частью Агрегата.

Обновленный эскиз с определенной границей:

Эскиз 2

Реализация

Давайте начнем с кода. Сначала давайте определим классы Book и User:

class Book
include DataMapper::Resource
property :id, Serial
property :title, String
property :author, String
property :price, Decimal
end
class User
include DataMapper::Resource
property :id, Serial
property :name, String
property :address_country, String
property :address_city, String
validates_presence_of :name
def address= address
self.address_country = address.country
self.address_city = address.city
end
def address
Address.new(address_country, address_city)
end
end
class Address < Struct.new(:country, :city)
end

Здесь нет ничего действительно интересного. Есть две сущности (книга и пользователь) и один объект значения (адрес). Это становится интересным, когда мы реализуем классы Order и Item:

class Order
include DataMapper::Resource
property :id, Serial, key: true
property :status, Enum[:new, :ready, :paid, :shipped, :closed, :canceled]
belongs_to :buyer, ‘User’
has n, :items
has 1, :payment
property :shipping_address_country, String
property :shipping_address_city, String
def shipping_address= address
self.shipping_address_country = address.country
self.shipping_address_city = address.city
end
def shipping_address
Address.new(shipping_address_country, shipping_address_city)
end
def self.make buyer
create buyer: buyer, shipping_address: buyer.address, status: :new
end
def self.get buyer, id
#…
end
def self.all_orders buyer
#…
end
def self.active_orders buyer
#…
end
def make_item book, quantity
ensure_status :new
amount = book.price * quantity
items.create book: book, quantity: quantity, amount: amount
end
def make_payment masked_card
ensure_status :ready
self.payment = Payment.new(masked_card: masked_card, amount: total_amount)
self.status = :paid
save
end
def mark_as_ready
#…
end
def mark_as_shipped
#…
end
def total_amount
#…
end
private
def ensure_status required_status
raise InvalidOrderStatus.new(self) if status != required_status
end
end
class Item
include DataMapper::Resource
property :id, Serial
belongs_to :book
property :quantity, Integer
property :amount, Decimal
end
class Payment
include DataMapper::Resource
property :id, Serial
property :masked_card, String
property :amount, Decimal
property :created_at, DateTime
property :updated_at, DateTime
end

Вот так может выглядеть создание заказа:

buyer = current_user
order = Order.make buyer
#At this moment:
#order.shipping_address == buyers_address
#order.status == :new
order.make_item book1, 2
order.make_item book2, 3
order.mark_as_ready

Чтение того же порядка из базы данных может выглядеть так:

order = Order.get(buyer, params[:id])

Я хотел бы отметить несколько важных вещей:

  • Все товары и платежи создаются через экземпляр Order.
  • Я не обновляю атрибуты заказа напрямую.
  • Я не использую методы DataMapper напрямую. Я написал несколько методов, чтобы максимально отделить нашу модель от DataMapper.
  • Нет двунаправленных ассоциаций. Каждый заказ знает о своих товарах, но у предметов нет ссылок на их заказы. Почему? Потому что им не нужно. Порядок является корнем; следовательно, любой клиент может получить товар только через свой заказ. Это означает, что порядок элемента всегда будет известен. * Двунаправленные ассоциации — плохая практика, установленная в сообществе Rails. Если вы можете избежать этого, пожалуйста, сделайте это. *
  • Пользователь не знает о своих заказах. Помимо того, что это ненужная двунаправленная ассоциация, это также значительно усложняет тестирование.

Не веришь мне? Посмотрите на эту строку кода:

@all_orders = current_user.orders.active

Это выглядит так просто. Некоторые люди могут даже сказать, что это лучше, чем этот:

@all_orders = Order.active_orders(user)

Однако, если вы попытаетесь заглушить это в своем тесте, вы можете написать что-то похожее на это:

order =
stub(orders = Object.new).active {[order]}
stub(user = Object.new).orders {orders}
stub(UserSession).current_user {user}

Теперь сравните его с Order.active_orders(user) :

order =
stub(UserSession).current_user {user}
stub(Order).active_orders(user){[order]}

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

Доступ к нашей модели в представлении

Пожалуйста, не заблуждайтесь. Тот факт, что Заказ является корнем Агрегата, не означает, что я не могу получить доступ к его оплате или товарам. Конечно я могу. Единственное правило — не хранить ссылки на эти объекты. Например, мне, наверное, нужен докладчик. Поскольку докладчики хранят ссылки на объекты, которые они представляют, создание ItemPresenter или PaymentPresenter является нарушением границы Aggregate. Вместо этого мы можем создать OrderPresenter и передать ему экземпляр Order. OrderPresenter может получить доступ к элементам заказа или оплате через сам заказ.

class OrderPresenter < Struct.new(:order)
def render_items
order.items.map do |item|
item_row item
end.join(«»)
end
private
def item_row item
«<div>#{item.book.title}#{item.quantity}#{item.amount}</div>»
end
end

Инварианты

Помните, что агрегаты не только об ограничении доступа. Они также определяют границы инвариантов и транзакций. Существует распространенное мнение, что эти инварианты должны быть соблюдены корнем. Также принято делать root ответственным за управление всеми транзакциями. Хотя иногда это может иметь смысл, есть много ситуаций, когда это не так. Просто для примера рассмотрим класс PaymentService:

module PaymentService
def self.process order, credit_card
with_transaction do
order.make_payment mask_card(credit_card)
make_remote_call credit_card, order.payment
end
end
end

PaymentService имеет несколько обязанностей. Во-первых, он маскирует кредитную карту. Затем он обновляет базу данных, чтобы пометить заказ как оплаченный. После этого он делает удаленный вызов какой-либо внешней службе, которая обрабатывает транзакции по кредитным картам. Кроме того, он оборачивает все в транзакцию. PaymentService обеспечивает важный инвариант, что обновление статуса заказа и выполнение удаленного вызова должны выполняться вместе. Альтернативой было бы возложить на Заказ ответственность за вызов внешних служб. Результатом будет нарушение принципа единой ответственности и несколько неприятных зависимостей Заказа от внешних платежных систем.

Завершение

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

Ресурсы


*
Совокупный | Сообщество по разработке доменов