Русский
Русский
English
Статистика
Реклама

Доменная модель

Чем меня не устраивает гексагональная архитектура. Моя имплементация DDD многоуровневая блочная архитектура

15.05.2021 18:08:01 | Автор: admin


* В данной статье примеры будут на TypeScript


Краткое предисловие


Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С).


Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса.


Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD.


Гексагональная архитектура это один из подходов реализации DDD.


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


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


Вместо этого Я покажу картинку (рис.1):


рис.1


Скажите пожалуйста, что Вам понятно из этой картинки?


Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё.


И, как бы ни было смешно, это первая проблема для меня.


Визуализация должна давать понимание, а не добавлять вопросов.


В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются.


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


Что мы имеем:


  • Реальная жизнь. Здесь есть бизнес процессы, которые мы должны автоматизировать.
  • Приложение, которое решает проблемы из реальной жизни, которое в свою очередь, не находится в вакууме. У приложения есть:
    1. Пользователи, будь то АПИ, кроны, пользовательские интерфейсы и т.д.
    2. Сам код приложения.
    3. Объекты данных БД, другие АПИ.

Движение идёт сначала сверху вниз, потом обратно, то есть:


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

Всё логично.


Теперь углубимся в код приложения.


Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.?


В ответ на этот вопрос родилась следующая схема (рис.2):


рис.2


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


Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже.


Многоуровневая блочная архитектура


Пробежимся по схеме.


На рисунке (рис.2), слева, мы видим названия сущностей, справа назначение уровней и их зависимости друг от друга.


Сверху вниз:


  1. Порты уровень взаимодействия, который зависит от уровня бизнес процессов. Уровень отвечает за взаимодействие с приложением, то есть хранит контроллеры. Пользоваться приложением можно только через порты.
  2. Ядро приложения уровень бизнес процессов, является центром всех зависимостей. Всё приложение строится исходя из бизнес процессов.
  3. Домены уровень бизнес логики, который зависит от уровня бизнес процессов. Домены образуются и выстраиваются на основании тех бизнес процессов, которые мы хотим автоматизировать. Домены отвечают за конкретную бизнес логику.
  4. Адаптеры уровень агрегации данных, который зависит от уровня бизнес логики. Сверху получает интерфейсы данных, которые должен реализовать. Отвечает за получение и нормализацию данных из объектов данных.
  5. Объекты данных уровень хранения данных, который не входит в приложение, но т.к. приложение не существует в вакууме, мы должны учитывать их.

Несколько правил


По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:


  1. Бизнес процессы должны возвращать однозначный ответ.
    Например создание клиента, при наличии партнерской программы. Можно сделать бизнес процесс, который создает клиента, а если у него есть партнерский код добавляет его ещё и в партнеры, но это не правильно. Из за подобного подхода ваши бизнес процессы становятся непрозрачными и излишне сложными. Вы должны создать 2 бизнес процесса создание клиента и создание партнера.
  2. Домены не должны общаться на прямую между собой. Всё общение между доменами происходит в бизнес процессах. Иначе домены становятся взаимозависимыми.
  3. Все доменные контроллеры не должны содержать бизнес логики, они лишь вызывают доменные методы.
  4. Доменные методы должны быть реализованы как чистые функции, у них не должно быть внешних зависимостей.
  5. У методов все входящие данные уже должны быть провалидированы, все необходимые параметры должны быть обязательными (тут помогут data-transfer-object-ы или просто DTO-шки).
  6. Для unit тестирования уровня нужен нижестоящий уровень. Инъекция (DI) производится только в нижестоящий уровень, например тестируете домены подменяете адаптеры.

Как происходит разработка, согласно этой схеме


  1. Выделяются бизнес процессы, которые мы хотим автоматизировать, описываем уровень бизнес процессов.
  2. Бизнес процессы разбиваются на цепочки действий, которые связаны с конкретными областями (домены).
  3. Решаем как мы храним данные и с какими внешними сервисами взаимодействуем подбираем адаптеры и источники данных, которые наши адаптеры поддерживают. Например в случае с БД мы решаем хранить наши данные в реляционной базе данных, ищем ORM, которая умеет с ними работать и при этом отвечает нашим требованиям, затем под неё выбираем БД, с которой наша ORM умеет работать. В случае с внешними API, часто придется писать свои адаптеры, но опять таки с оглядкой на домены, потому что у адаптера есть 2 главные задачи: получить данные и отдать их наверх в необходимом домену, адаптированном виде.
  4. Решаем как мы взаимодействуем с приложением, то есть продумываем порты.

Небольшой пример


Мы хотим сделать небольшую CRM, хранить данные хотим в реляционной БД, в качестве ORM используем TypeORM, в качестве БД PostgresSQL.


Будет показан не весь код сервера, а лишь основные моменты, которые Вы сможете применить в своём приложении уже сейчас

Для начала реализуем бизнес процесс создания клиента.


Подготовим структуру папок:


рис.3


Для удобства добавим алиасы:


@clients = src/domains/clients@clientsEnities = src/adapters/typeorm/entities/clients@adapters = src/adapters

Из чего состоит бизнес процесс в самом простом виде:


  • на вход мы получаем данные о клиенте
  • нам нужно сохранить его в БД

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


Формируем доменные модели, которые должны реализовать наши адаптеры. В нашем случае это 2 модели: клиент и контактные данные


domains/clients/models/Client.ts

import { Contact } from './Contact';export interface Client {  id: number;  title: string;  contacts?: Contact[];}

domains/clients/models/Contact.ts

import { Client } from './Client';export enum ContactType {  PHONE = 'phone',  EMAIL = 'email',}export interface Contact {  client?: Client;  type: ContactType;  value: string;}

Под них формируем TypeORM enitity


adapters/typeorm/entities/clients/Client.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Contact } from './Contact';@Entity({ name: 'clients' })export class Client implements ClientModel {  @PrimaryGeneratedColumn()  id: number;  @Column()  title: string;  @OneToMany((_type) => Contact, (contact) => contact.client)  contacts?: Contact[];}

adapters/typeorm/entities/clients/Contact.ts

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';import { Contact as ContactModel, ContactType } from '@clients/models/Contact';import { Client } from './Client';@Entity({ name: 'contacts' })export class Contact implements ContactModel {  @PrimaryGeneratedColumn()  id: number;  @Column({ type: 'string' })  type: ContactType;  @Column()  value: string;  @ManyToOne((_type) => Client, (client) => client.contacts, { nullable: false })  client?: Client;}

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


Реализуем доменный метод создания клиента и доменный контроллер.


domains/clients/methods/createClient.ts

import { Repository } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Client } from '@clientsEnities/Client';export async function  createClient(repo: Repository<Client>, clientData: ClientModel) {  const client = await repo.save(clientData);  return client;}

domains/clients/index.ts

import { Connection } from 'typeorm';import { Client } from '@clientsEnities/Client';import { Client as ClientModel } from '@clients/models/Client';import { createClient } from './methods/createClient';export class Clients {  protected _connection: Connection;  constructor(connection: Connection) {    if (!connection) {      throw new Error('No connection!');    }    this._connection = connection;  }  protected getRepository<T>(Entity: any) {    return this._connection.getRepository<T>(Entity);  }  protected getTreeRepository<T>(Entity: any) {    return this._connection.getTreeRepository<T>(Entity);  }  public async createClient(clientData: ClientModel) {    const repo = this.getRepository<Client>(Client);    const client = await createClient(repo, clientData);    return client;  }}

Т.к. TypeORM немного специфичная библиотека, внутрь мы прокидываем (для DI) не конкретные репозитории, а connection, который будем подменять при тестах.


Осталось создать бизнес процесс.


businessProcesses/createClient.ts

import { Client as ClientModel } from '@clients/models/Client';import { Clients } from '@clients';import { db } from '@adapters/typeorm'; // Я складываю TypeORM соединения в объект dbexport function createClient(clientData: ClientModel) {  const clients = new ClientService(db.connection)  const client = await clients.createClient(clientData)  return  client}

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


Что нам даёт данная архитектура?


  1. Понятную и удобную структуру папок и файлов.
  2. Удобное тестирование. Т.к. всё приложение разбито на слои выберете нужный слой, подменяете нижестоящий слой и тестируете.
  3. Удобное логирование. В примере видно, что логирование можно встроить на каждый этап работы приложения от банального замера скорости выполнения конкретного доменного метода (просто обернуть функцию метода функцией оберткой, которая всё замерит), до полного логирования всего бизнес процесса, включая промежуточные результаты.
  4. Удобную валидацию данных. Каждый уровень может проверять критичные для себя данные. Например тот же бизнес процесс создания клиента по хорошему в начале должен создать DTO для модели клиента, который провалидирует входящие данные, затем он должен вызвать доменный метод, который проверит, существует ли уже такой клиент и только потом создаст клиента. Сразу скажу про права доступа Я считаю что права доступа это адаптер, который Вы должны также прокидывать при создании доменного контроллера и внутри в контроллерах проверять права.
  5. Легкое изменение кода. Допустим Я хочу после создания клиента создавать оповещение, то есть хочу обновить бизнес процесс. Захожу в бизнес процесс, в начале добавляю инциализацию домена notifications и после получения результата создания клиента делаю notifications.notifyClient({ client: client.id, type:SUCCESS_REGISTRATION })

На этом всё, надеюсь было интересно, спасибо за внимание!

Подробнее..

Ценности DDD

08.11.2020 18:18:22 | Автор: admin
Основоположником DDD (Domain Driven Design, предметно-ориентированное проектирование) является Эрик Эванс, который в довольно далеком 2003 году подарил миру свою знаменитую книгу о предметно-ориентированном проектирование. Безусловно, не все, что описано в книге придумал автор с нуля. Многие идеи и практики существовали и до него, но у Эванса получилось все это систематизировать и правильно расставить акцента. Давайте попробуем разобраться, что же именно предлагает Эванс.

На мой субьективный взгляд DDD стоит на трех основных столпах (и это если что не три буквы Д):

  • Доменная модель
  • Ограниченный контекст
  • Агрегаты


Доменная модель


Transaction Script и Domain Model


Данное понятие существовало и до Эванса. Например, Мартин Фаулер ставит доменную модель в противоположность так называемому подходу Transaction Script, который представляет своего рода более процедурный стиль кодирования, чем объектно-ориентированный. Обычно при таком подходе акцент смещается в сторону базы данных и манипуляций с данными, чем в сторону работы с бизнес-логикой. Обычно такой подход реализуется как некая сущность, отображаемая на базу данный, с большим количеством геттеров и сеттеров. И управляющей класс, некий сервис, который и вызывает эти сеттеры и по определенным правилам обновляет базу данных. Доменный слой, построенный на основе подобных сущностей, Фаулер также называет анти паттерном анемичная модель. Фаулер обосновывает это тем, что в настоящем объекте должны быть не только данные, но и поведение.

If all your logic is in services, you've robbed yourself blind.


В качестве альтернативы выступает понятие доменной модели. Доменная модель создается, как некое подобие реального мира. Например, если мы разрабатываем ПО для ресторанов и доставки блюд, то наверняка в такой модели нам встретятся такие объекты как: ресторан, блюдо, курьер и может быть, что-то еще при более детальном рассмотрение предметной области.
В отличии от Transaction Script, где логика содержится в сервисах, а данные в сущностях, в доменной модели и логика и данные размещены в доменных объектах. Согласно идеям объектного-программирования такие объекты инкапсулируют свое внутреннее состояние, а для работы с ним предоставляют вполне определенный внешний интерфейс. Например, у объекта корзины может быть метод добавления товара. Тут можно возразить и сказать, что у нашей сущности вполне может быть сеттер для добавления товара. Да, все это верно. Но не стоит забывать, что в идеале класс корзины должен соблюдать ряд бизнес-инвариантов. Например, после добавления товара в корзину, итоговая стоимость корзины должна увеличится на сумму добавленных товаров. В подходе Transaction Script данная логика размещается в сервисе. Но при таком раскладе соблюдение инвариантов не обеспечивается ничем, кроме хороших тестов и внимательности программиста. Существует не нулевая вероятность, что в каком-то другом сервисе проявится ошибка и он изменит данные корзины неверным образом. В случае же с доменной моделью, за корректность изменения данных (за соблюдение инвариантов) отвечает только один объект сама корзина (может быть еще ее внутренние классы, но опять же об этом мы не знаем, за счет соблюдения подхода сокрытия информации). Таким образом мы формируем абстракцию корзины, с которой должны взаимодействовать другие классы модели, через ее определенный интерфейс, а не влияя напрямую на ее внутреннее состояние. Также автоматически начинают соблюдаться еще и такие принципы, как SRP (принцип единственной ответственности), low coupling и high cohesion (слабая внешняя связанность и высокое внутреннее зацепление).

Эванс ставит во главу угла именно доменную модель. Доменная модель в первую очередь позволяет сосредоточится на бизнес-задаче и отвлечься от технических вопросов, связанных с сохранением данных, передачей информации в веб и прочим. Это своего рода еще один уровень абстракции, самый высокий уровень, в котором, по сути присутствуют только бизнес-понятия. Эванс говорит, что код доменного уровня программист может изучать даже вместе со специалистом предметной области. И при небольших комментариях разработчика доменный эксперт вполне должен понимать исследуемый код, т.к. в нем, в хорошей модели, должны фигурировать знакомые ему бизнес-понятия и выполняться знакомые бизнес-операции. Тем самым мы приходим к такому понятию как Единый язык, который в DDD занимает одно из самых значимых мест.

Единый язык


Единый язык это некий набор терминов, относящихся к разрабатываемой доменной модели, который использует команда разработки в общение между собой. Важно заметить, что в состав команды входят не только разработчики, но и бизнес-эксперты. Единый язык это не язык программистов, так же это и не язык бизнес-аналитиков. Единый язык это своего рода некое смешение, которое возникает в результате совместной работы этих двух категорий специалистов. Это позволяет, как программистам при общение с доменными экспертами более погрузиться в предметную область, так и специалистам предметной области понять, что же все же пытаются создать разработчики (Безусловно, доменные эксперты должны иметь поверхностное представление об объектном-моделирование, они не должны впадать в ступор только лишь при упоминание таких слов, как класс и объект). При этом доменные эксперты могут дать обратную связь разработчикам даже до момента написания первой строчки кода. Во время анализа способов использования системы (use cases) разрабатываемой системы, обсуждение, которых должно вестись с активным применением терминов из словаря Единого языка.

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

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

Размышляя на тему DDD и хорошо проработанной доменной модели у меня всегда возникает ассоциация с небезызвестным высказыванием:

Сначала ты работаешь на репутацию, а потом она работает на тебя.


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

Ограниченный контекст


Тут уже все несколько посложнее. Есть понятие предметная область, она же и есть домен (domain). Это та сфера деятельности, в которой работает наш бизнес. Например, тот же самый e-commerce, доставка еды из ресторанов, бухгалтерская сфера или что-то иное. В любом случае это весьма обширная сфера и при разработке ПО нет смысла моделировать всю эту огромную область.
Практически всегда в нашей предметной области есть подобласти (subdomain). Подобласти это своего рода отдельно взятые боли бизнеса, т.е. это бизнес-проблема, бизнес-задача, которую требуется решить в нашем случае за счет автоматизации. Например, нам может требоваться автоматизация для формирования заказов, для производства товаров, для их доставки. Все это разные подзадачи из одной и той же предметной области. Можно переформулировать иначе. На предприятие могут быть разные подразделения: производство, доставка и служба продаж принимающая заказы и наша цель состоит в разработке ПО для данных подразделений предприятия.

Разное использование понятий в зависимости от контекста


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

Области задач и области решений


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

Ограниченный контекст как способ декомпозиции системы


Идея ограниченного контекста это своего рода желание декомпозировать большую систему на более простые компоненты, с которыми понятней и более удобно работать. Также можно сказать, что данная идея реализует все те же принципы проектирования SRP, low coupling и high cohesion, но только на более высоком уровне. Об этом также говорит принцип CCP (Common Closure Principle), который похож на SRP, но только для классов, изменяющимся по одной и той же причине и следовательно должны находится вместе, например, в одном пакете. Также эта идея отлично согласуется с другими подходами, например, с микро сервисной архитектурой и с гибкими командами в Agile.

Закон Конвея


Говоря о декомпозиции систем вспоминается закон Конвея.

Организации проектируют системы, которые копируют структуру коммуникаций в этой организации


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

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

Агрегаты


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

Приходилось ли вам в коде видеть что-то подобное?

payment.GetOrder().getAccount().getClient().getAddress()


Данный код представляет своего рода довольно глубокий обход графа объектов нашей предметной модели. В нашей модели имеются несколько объектов-сущностей: Payment, Order, Account, Client И Address. И все эти объекты имеют некоторые связи друг с другом. B это довольно знакомая и распространенная ситуация. И само собой такая тесная связь между объектами вызывает и большую связанность самого кода. И это даже не говоря о том, что такая связь может быть не всегда обязательной и тем самым, подобный невнимательный обход объектов может вызывать исключение NullPointerException.

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

Агрегат как граница транзакционной согласованности


Когда говорят про агрегаты не редко упоминают транзакционную согласованность этих агрегированных объектов. Например, в качестве агрегата, можно рассмотреть корзину товаров. Корзина по мимо своих основных свойств таких, как подытог, скидка и итоговая сумма содержит такие объекты как CartItem. Данный объект представляет элемент корзины и может содержать такие свойства, как добавленный товар и его количество, а также может вычислять подытог, как произведение количества на стоимость товара. Агрегат корзина (как и любой доменный объект) обеспечивает необходимые бизнес-инварианты (например, пересчет стоимости при добавление еще одного товара). Также очевидно, что при сохранение корзины должны одновременно сохраняться и ее элементы в рамках одной транзакции, что удовлетворяет транзакционной согласованности.
По этому при проектировании агрегата всегда можно задаться вопросом:
А должны ли эти объекты сохраняться вместе?


Агрегаты и границы ограниченных контекстов


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

Агрегаты и событийно-ориентированный архитектура


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

Заключение


В статье были рассмотрены на мой субъективный взгляд самые интересные подходы используемые в DDD. Не смотря на то, что Эванс представил свою книгу чуть ли не двадцать лет назад, до России все доходит и небольшим опозданием. И даже 20 лет спустя в нас DDD до сих пор имеет некую таинственность и массу непонимания. Надеюсь, что данная статья сможет внести свой небольшой вклад и прояснить некоторые моменты.
Подробнее..

Перевод Эффективная конструкция агрегатов. Моделирование одиночного агрегата

21.02.2021 12:18:25 | Автор: admin

Эта статья является конспектом материала Effective Aggregate Design Part I: Modeling a Single Aggregate.

Объединение сущностей (entities) и объектов значений (value objects) в агрегат с тщательно продуманными границами согласованности может показаться простым, но из всех тактических DDD шаблонов, агрегат является одним из самых сложных.

Для начала будет полезно рассмотреть некоторые общие вопросы. Является ли агрегат просто способом объединения тесно связанных объектов с общим корнем (Aggregate Root)? Если да, то есть ли какое-то ограничение на количество объектов, которые могут находиться в графе? Поскольку один агрегат может ссылаться на другой, можно ли перемещаться по агрегатам с помощью этих связей и менять данные объектов, входящих в определенный агрегат? И чем является инвариант и граница согласованности? Ответ на последний вопрос в значительной степени влияет на остальные ответы.

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

Разработка приложения ProjectOvation

Давайте рассмотрим агрегаты на примере. Наша фиктивная компания разрабатывает приложение для поддержки проектов, основанных на методологии Scrum. Приложение следует традиционной модели управления проектами по методологии Scrum, то есть имеются продукт (product), владелец продукта (product owner), команды (team), элементы бэклога (backlog items), запланированные релизы (planned releases), спринты (sprints). Терминология Scrum формирует стартовую точку единого языка (ubiquitous language). Каждая организация, которая покупает подписку, регистрируется как арендатор (tenant), это еще один термин для нашего единого языка.

Компания собрала группу талантливых разработчиков. Однако, их опыт с DDD несколько ограничен. Это означает, что команда будет допускать ошибки, связанные с DDD по ходу разработки. Они будут расти, и мы вместе с ними. Их трудности помогут нам распознать и устранить подобные неблагоприятные ситуации, которые мы создали в нашем собственном программном обеспечении.

Как необходимо команде выбирать набор объектов для объединения в кластер? Паттерн агрегат рассматривает композицию и указывает на сокрытие информации. Он также рассматривает границы согласованности и транзакции, но команда не была обеспокоена этим. Вот что вышло. Команда рассмотрела следующие утверждения единого языка:

  • Продукты имеют элементы бэклога, релизы и спринты.

  • Можно добавлять новые элементы бэклога.

  • Можно добавлять новые релизы.

  • Можно добавлять новые спринты.

  • Запланированный элемент бэклога можно привязать к релизу.

  • Запланированный элемент бэклога можно привязать к спринту.

На основе этих утверждений команда спроектировала первый вариант модели. Давайте посмотрим, что у них вышло.

Первая попытка: большой агрегат

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

  • Если элемент бэклога привязан к спринту, мы не должны позволить удалить его из системы.

  • Если спринт имеет элементы бэклога, то мы не должны позволить удалить его из системы.

  • Если релиз имеет запланированные элементы бэклога, то мы не должны позволить удалить его из системы.

  • Если элемент бэклога привязан к релизу, то мы не должны позволить удалить его из системы.

В результате Product был смоделирован как очень большой агрегат. Корневой объект, Product, содержит все BacklogItem, все Release, все Sprint экземпляры, связанные с ним. Такой интерфейс защищал все детали от случайного удаления клиента. Эта конструкция показана в следующем коде и в виде UML-диаграммы ниже.

public class Product extends ConcurrencySafeEntity {    private Set<BacklogItem> backlogItems;    private String description;    private String name;    private ProductId productId;    private Set<Release> releases;    private Set<Sprint> sprints;    private TenantId tenantId;    ...}
Рис. 1. Product смоделирован как очень большой агрегат.Рис. 1. Product смоделирован как очень большой агрегат.

Большой агрегат выглядел привлекательно, но он не был по-настоящему практичен. Как только приложение стало работать в предполагаемой многопользовательской среде, начинали происходить регулярные сбои транзакций. Наши экземпляры агрегата используют оптимистическую блокировку для защиты объектов от одновременной модификации несколькими клиентами, что позволяет избежать использования блокировок БД. Объекты содержат номер версии, который увеличивается во время внесения изменений и проверяется перед тем, как эти изменения будут сохранены в БД. Если версия сохраняемого объекта больше версии (копии) клиента, то версия клиента считается устаревшей и обновления отклоняются.

Рассмотрим общий многопользовательский сценарий:

  • Два пользователя, Билл и Джо, смотрят одинаковый Product c версией 1 и начинают работать с ним.

  • Билл планирует новый BacklogItem и сохраняет. Версия становится 2.

  • Джо планирует новый Release и пытается сохранить, но он получает ошибку, так как версия его копии Product устарела и равнялась 1.

Такой механизм персистентности используется для борьбы с конкурентным доступом. Этот подход действительно важен для защиты инвариантов агрегата от одновременных изменений.

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

Планирования нового элемента бэклог не должно логически мешать планированию нового релиза. Почему Джо не может сохранить свои изменения? Большой агрегат был спроектирован с учетом ложных инвариантов, а не реальных бизнес-правил. Эти ложные инварианты являются искусственными ограничениями, налагаемыми разработчиками. Помимо проблем с транзакциями, также имеются недостатки производительности и масштабируемости.

Вторая попытка: несколько агрегатов

Теперь рассмотрим альтернативную модель, которая показана на рисунке 2. У нас есть четыре агрегата. Каждая зависимость использует ProductId, который является идентификатором Product-а.

Рис. 2. Product и связанные с ним понятия моделируются как отдельные агрегаты.Рис. 2. Product и связанные с ним понятия моделируются как отдельные агрегаты.

Разбиение большого агрегата на четыре изменит контракт метода для Product. С большим агрегатом сигнатуры методов выглядели следующим образом:

public class Product ... {    ...      public void planBacklogItem(        String aSummary, String aCategory,        BacklogItemType aType, StoryPoints aStoryPoints) {      ...      }    ...      public void scheduleRelease(        String aName, String aDescription,        Date aBegins, Date anEnds) {      ...      }      public void scheduleSprint(        String aName, String aGoals,        Date aBegins, Date anEnds) {        ...      }      ...}

Все эти методы являются командами. То есть они модифицируют состояние Product, добавляя новый элемент в коллекцию, поэтому их возвращаемый тип void. Но с отдельными агрегатами мы имеем:

public class Product ... {    ...      public BacklogItem planBacklogItem(        String aSummary, String aCategory,        BacklogItemType aType, StoryPoints aStoryPoints) {      ...      }        public Release scheduleRelease(        String aName, String aDescription,        Date aBegins, Date anEnds) {        ...      }      public Sprint scheduleSprint(        String aName, String aGoals,        Date aBegins, Date anEnds) {        ...      }      ...}

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

public class ProductBacklogItemService ... {     ...     @Transactional     public void planProductBacklogItem(           String aTenantId, String aProductId,           String aSummary, String aCategory,           String aBacklogItemType, String aStoryPoints) {           Product product =                   productRepository.productOfId(                                 new TenantId(aTenantId),                                new ProductId(aProductId));           BacklogItem plannedBacklogItem =                  product.planBacklogItem(                            aSummary,                            aCategory,                            BacklogItemType.valueOf(aBacklogItemType),                            StoryPoints.valueOf(aStoryPoints));                    backlogItemRepository.add(plannedBacklogItem);      }      ...}

Таким образом, мы решили проблему сбоя транзакции. Теперь любое количество экземпляров BacklogItem, Release и Sprint можно безопасно создавать с помощью одновременных запросов.

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

Моделируйте истинные инварианты в контексте согласованности

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

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

c = a + b

Поэтому, когда, а = 2 и b = 3, с должно равняться 5. Согласно этому правилу, если с не равняется 5, то нарушается инвариант. Чтобы убедиться, что значение с согласовано, мы моделируем границу вокруг этих атрибутов модели.

AggregateType1 {    int a; int b; int c;    operations...}

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

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

Такое ограничение (модификация одного агрегата в рамках одной транзакции) может показаться чрезмерно строгим. Однако это эмпирическое правило и должно быть целью в большинстве случаев.

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

Проектируйте небольшие агрегаты

Теперь давайте подробно ответим на вопрос: какие дополнительные затраты будут связаны с сохранением большого агрегата? Даже если мы гарантируем, что каждая транзакция будет успешной, у нас все равно будут ограничения по производительности и масштабируемости. Увеличение пользователей и, следовательно, увеличение добавляемых ими данных приведет к огромному количеству продуктов, элементов бэклога, релизов, спринтов. Производительность и масштабируемость - это нефункциональные требования, которые нельзя игнорировать.

Что произойдет, когда пользователь захочет добавить элемент бэклога в продукт, которому уже много лет и у которого уже тысячи таких элементов бэклога? Предположим, что в механизме персистентности доступна ленивая загрузка (lazy loading). Мы почти никогда не загружаем все элементы бэклога, релизы и спринты сразу. Тем не менее, тысячи элементов бэклога будут загружены в память, чтобы добавить еще один новый элемент в коллекцию. Хуже, если механизм персистентности не поддерживает ленивую загрузку. Иногда нам приходится загружать несколько коллекций, например, во время добавления элемента бэклога в релиз или в спринт. Все элементы бэклога, а также все релизы или все спринты будут загружены.

Чтобы увидеть это более наглядно, посмотрим на диаграмму на рисунке 3. Не позволяйте 0..* обмануть вас. Число ассоциаций почти никогда не будет равным нулю и будет постоянно расти с течением времени. Скорее всего, нам придется загружать тысячи и тысячи объектов в память одновременно для выполнения относительно простых операций. И это только для одного члена команды одного арендатора. Мы должны иметь в виду, что это подобная ситуация может произойти одновременно с сотнями и тысячами арендаторов, каждый из которых имеет несколько команд и множество продуктов. И со временем ситуация будет только ухудшаться.

Рис. 3. Модель Product. Несколько больших коллекций загружается во время множества простых операций.Рис. 3. Модель Product. Несколько больших коллекций загружается во время множества простых операций.

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

Если мы собираемся проектировать небольшие агрегаты, то нам необходимо выяснить, что значит небольшой. Крайним случаем будет агрегат с его глобальным идентификатором и одним дополнительным атрибутом, что не рекомендуется делать, если только это действительно не то, что требуется одному конкретному агрегату. Лучше будет, если ограничим агрегат только корневой сущностью (root entity), минимальным количеством атрибутов и/или объектов значений (object value).

Однако, какие именно данные (атрибуты, объекты значения) необходимы? Ответ прост: те, что должны иметь согласованность друг с другом. Например, Product имеет атрибуты name и description. Мы не можем представить эти атрибуты несогласованными, смоделированными в отдельных агрегатах. Если вы изменяете только один из этих атрибутов, то вероятно, потому что вы исправляете ошибку. Даже если эксперты предметной области не будут думать об этом как о явном бизнес-правиле, это неявное правило.

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

Однако иногда использования нескольких сущностей имеет смысл. Например, сумма заказа не должна превышать максимально допустимое значение. Из этого следует, что и сумма всех элементов заказа не должна превышать допустимое значение. Если заказ и элементы заказа будут находиться в разных агрегатах, то одновременное добавление элемента заказа несколькими пользователями может превысить этот лимит. В этом случае лучше объединить сущности Order и OrderItem в один агрегат. Но следует подчеркнуть, что в большинстве случаев инвариантами бизнес-моделей управлять проще, чем в этом примере. Признавая это, помогает нам моделировать агрегаты с как можно меньшим количеством свойств.

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

Не доверяйте каждому сценарию использования

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

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

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

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

Просто потому, что дан сценарий использования, который требует поддержания согласованности в одной транзакции, не означает, что вы должны это делать. Часто в таких случаях бизнес-цель может быть достигнута с помощью конечной согласованности (eventual consistency) между агрегатами. Команда должна критически изучить сценарии использования и оспорить их предположения, особенно когда следование им в том виде, в каком они написаны, приведет к громоздким проектам. Команде, возможно, придется переписать сценарий использования. Новый вариант сценария использования будет указывать на конечную согласованность и приемлемую задержку обновления. Это один из вопросов, который будет рассматриваться во второй части.

Подробнее..

Перевод Эффективная конструкция агрегата. Заставляем агрегаты работать вместе

23.02.2021 10:12:21 | Автор: admin

Эта статья является конспектом материала Effective Aggregate DesignPart II: Making Aggregates Work Together.

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

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

Рис. 1. Изображено два агрегата, а не один.Рис. 1. Изображено два агрегата, а не один.

На Java это выглядело бы следующим образом:

public class BacklogItem extends ConcurrencySafeEntity {  ...  private Product product;  ...}

BacklogItem содержит прямую связь с объектом Product.

Это имеет несколько последствий:

  • BacklogItem и Product не должны вместе изменяться в рамках одной транзакции, а только один из них.

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

  • Если все-таки необходимо изменять несколько экземпляров агрегата за один запрос, это может указывать на необходимость использования конечной согласованности, а не атомарной (транзакционной).

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

Ссылайтесь на другие агрегаты по идентификатору

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

Рис. 2. BacklogItem содержит связи с другими агрегатами за пределами своей границы с помощью идентификаторов.Рис. 2. BacklogItem содержит связи с другими агрегатами за пределами своей границы с помощью идентификаторов.
public class BacklogItem extends ConcurrencySafeEntity {  ...  private ProductId productId;  ...}

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

Модель навигации

Ссылки по идентификатору полностью не исключают доступ к другим агрегатам. Можно использовать репозиторий изнутри агрегата для поиска. Такой метод называется автономной доменной моделью (disconnected domain model). Однако существуют другие рекомендуемые подходы. Используйте репозиторий или доменную службу для поиска зависимых объектов снаружи агрегата, то есть, например, в службах уровня приложения (application service).

public class ProductBacklogItemService ... {    ...    @Transactional    public void assignTeamMemberToTask( String aTenantId,        String aBacklogItemId, String aTaskId,        String aTeamMemberId) {        BacklogItem backlogItem = backlogItemRepository.backlogItemOfId(          new TenantId(aTenantId),          new BacklogItemId(aBacklogItemId)        );        Team ofTeam =        teamRepository.teamOfId( backlogItem.tenantId(), backlogItem.teamId());        backlogItem.assignTeamMemberToTask(          new TeamMemberId(aTeamMemberId), ofTeam,          new TaskId(aTaskId)        );      }      ...}

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

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

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

Поскольку всегда существует несколько ограниченных контекстов, ссылки на идентификаторы позволяют распределенным доменным моделям иметь ассоциации. Когда используется подход Event-Driven, события домена, содержащие идентификатор агрегата, рассылаются в виде сообщений. Подписчики этих сообщений во внешних ограниченных контекстах используют идентификаторы для выполнения операций в собственных доменных моделях. Транзакции между распределенными системами не являются атомарными. В итоге различные системы приводят несколько агрегатов в согласованное состояние.

Используйте конечную согласованность за пределами границ

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

Эксперты предметной области зачастую нормально относятся к идее отложенной согласованности в отличие от разработчиков. Они знают о реальных задержках, которые постоянно происходят в бизнесе, в то время как разработчики обычно привыкли думать об атомарных операциях. Таким образом, эксперты предметной области часто допускают задержки (секунды, минуты, часы или даже дни) прежде чем произойдет согласованность.

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

public class BacklogItem extends ConcurrencySafeEntity {  ...  public void commitTo(Sprint aSprint) {    ...    DomainEventPublisher    .instance()    .publish(      new BacklogItemCommitted(             this.tenantId(),             this.backlogItemId(),             this.sprintId()          )    );  }  ...}

Затем каждый из подписчиков выполняет в отдельной транзакции операции над другим агрегатом, следуя правилу изменять только один экземпляр агрегата за транзакцию.

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

В некоторых сценариях предметной области сложно определить, следует ли использовать транзакционную или конечную согласованность. Те, кто использует DDD классическим способом, могут склоняться к транзакционной согласованности. Те, кто используют CQRS, могут склоняться к конечной согласованности. Но что правильно в итоге? По правде говоря, ни один из этих подходов не дает конкретного ответа, только техническое предпочтение. Есть ли лучше способ решить эту проблему?

Изучая сценарий использования, спросите, является ли задачей пользователя, выполняющего данный сценарий, сделать данные согласованными. Если это так, попробуйте сделать транзакционную согласованность, но только придерживаясь всех правил агрегата. Если это обязанность другого пользователя или системы, тогда используйте конечную согласованность. Этот совет не только обеспечивает удобное решение проблемы, но и помогает глубже понять предметную область (раскрывает реальные инварианты системы, которые должны поддерживаться транзакционной согласованностью).

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

Причины нарушения правил

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

Причина первая: удобство пользовательского интерфейса

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

public class ProductBacklogItemService ... {    ...    @Transactional    public void planBatchOfProductBacklogItems(       String aTenantId, String productId,       BacklogItemDescription[] aDescriptions) {        Product product = productRepository.productOfId(          new TenantId(aTenantId), new ProductId(productId)        );        for (BacklogItemDescription desc : aDescriptions) {           BacklogItem plannedBacklogItem = product.planBacklogItem(             desc.summary(), desc.category(),             BacklogItemType.valueOf(desc.backlogItemType()),             StoryPoints.valueOf(desc.storyPoints())          );            backlogItemRepository.add(plannedBacklogItem);        }    }    ...}

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

Однако Уди Дахан рекомендует избегать создание сервисов приложения для пакетной обработки данных. Более подробно можно прочесть в оригинале.

Причина вторая: отсутствие технических инструментов

Конечная согласованность требует дополнительные инструменты для внепроцессорной обработки, таких как обмен сообщениями или таймеры. Что делать, если проект, над которым вы работаете, не предусматривает такого механизма? Хотя большинство из нас сочло бы это странным, но автор оригинала сталкивался именно с таким ограничением. Без механизма обмена сообщениями, без таймеров и другого, что можно было бы сделать?

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

Автор упомянул еще один фактор, который способствует нарушению правил - user-aggregate affinity. Я не до конца понял, о чем идет речь, поэтому не стал добавлять его в конспект. Если интересно, можно посмотреть в оригинале.

Причина третья: глобальные транзакции

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

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

Причина четвертая: производительность запросов

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

Вывод

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

Ссылки на все части

Подробнее..

Перевод Эффективная конструкция агрегатов. Понимание через исследование

28.02.2021 10:04:21 | Автор: admin

Эта статья является конспектом материала Effective Aggregate DesignPart III: Gaining Insight Through Discovery.

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

Переосмысление конструкции модели

После итерации рефакторинга, благодаря которой избавились от большого агрегата Product, BacklogItem стал отдельным агрегатом. Новую версию модели можно увидеть на рисунке 1. Агрегат BacklogItem содержит коллекцию экземпляров Task. Каждый BacklogItem имеет глобальный уникальный идентификатор BacklogItemId. Ассоциация с другими агрегатами происходит через идентификаторы. Агрегат BacklogItem кажется довольно небольшим.

Рис.1. Схема модели агрегата BacklogItemРис.1. Схема модели агрегата BacklogItem

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

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

Ответ лежит в едином языке. Имеются следующие инварианты:

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

  • Когда член команды оценивает время в 0 часов, элемент бэклога проверяет все задачи на наличие оставшихся часов. Если их нет, то статус элемента бэклога автоматически меняется на done.

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

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

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

Оценка стоимости агрегата

Как показано на рисунке 1, каждый Task содержит коллекцию экземпляров EstimationLogEntry. Этот журнал фиксирует конкретные случаи, когда член команды выполняет новую оценку оставшихся часов. На практике, сколько элементов Task может содержать BacklogItem, и сколько элементов EstimationLogEntry будет содержать Task? Точно сказать сложно. Во многом это показатель того, насколько сложна задача и сколько будет длиться спринт. Но некоторые расчеты все же могут помочь.

Часы работы обычно пересчитываются каждый день после того, как член команды закончил работать над определенной задачей. Предположим, что большинство спринтов длится две или три недели. Давайте выберем количество дней от 10 до 15, например, пусть будет 12 дней.

Теперь рассмотрим количество часов, выделенных на каждую задачу. Обычно используют количество часов от 4 до 16. Часто, если задача превышает 12 часов, то эксперты Scrum предлагают разбить ее на более мелкие. В качестве теста предположим, что задачи оцениваются в 12 часов (1 час на каждый день спринта). Итак, получается 12 пересчетов для каждой задачи, предполагая, что каждая задача начинается с 12 часов, выделенные на нее.

Остается вопрос: сколько задач потребуется всего для одного элемента бэклога? Пусть будет, например, тоже 12 (я не стал расписывать, как автор пришел к такому числу; можно самому глянуть в оригинале). В итоге получается 12 задач, каждая из которых содержит 12 оценок в журнале, или 144 (12*12) на элемент бэклога. Хотя это может быть больше чем обычно, но это дает нам конкретную оценку для анализа.

Есть еще одно, что следует учесть. Если следовать рекомендациям экспертов Scrum по определению более мелких задач, это бы несколько изменило ситуацию. Удвоение числа задач (24) и уменьшение вдвое числа записей журнала (6) все равно дают 144. Однако это приведет к загрузке большего количества задач (24 вместо 12) во время запроса на оценку часов, потребляя при этом больше памяти. Но для начала давайте использовать 12 задач по 12 часов каждая.

Общие сценарии использования

Теперь важно рассмотреть общие сценарии использования. Как часто в одном пользовательском запросе нужно будет загружать в память все 144 объекта одновременно? Произойдет ли вообще это когда-то? Если нет, то каково максимальное количество объектов? Кроме того, будет ли многопользовательские запросы, которые могут привести к транзакционной конкуренции? Давайте посмотрим.

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

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

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

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

Если задачи сначала оцениваются в 0 часов, а затем оценка уточняется и обновляется, транзакционной конкуренции все равно не будет. Одновременное использование в данном случае не меняет статус элемента бэклога, поскольку статус может смениться на done, если оценка меняется с ненулевого значения на ноль, или откатывается, если статус уже done, и часы меняются с нулевого значения на положительное.

Будут ли ежедневные оценки приводить к проблемам? В первый день спринта обычно нет журналов оценки по заданной задаче элемента бэклога. В конце первого дня каждый член команды, работающий над задачей, сокращает расчетное количество часов на один. Это добавляет новую запись в журнал оценки к каждой задаче, но статус элемента бэклога не изменяется. При этом только один член команды корректирует часы определенной задачи. Только на 12-й день происходит изменение статуса. После того, как будет добавлена последняя 144 запись в журнал для 12 задаче, происходит автоматический переход статуса в done.

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

Потребление памяти

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

Что насчет общего количества задач и оценок в памяти во время каждого повторного оценивания? При использовании ленивой загрузки для задач и журналов оценки у нас будет до 12 + 12 объектов в памяти во время одного запроса, поскольку все 12 задач будут загружены во время обращения к этой коллекции. Чтобы добавить последнюю запись в журнал оценки к одной из задач, нужно загрузить коллекцию записей журнала и это дает еще до 12 объектов. В конечном итоге агрегат содержит один элемент бэклога, до 12 задач и до 12 записей в журнале, что в сумме дает максимум 25 объектов. Это не очень много. Другой факт заключается в том, что максимальное количество объектов не достигается до последнего дня спринта. В течение большей части спринта агрегат будет еще меньше.

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

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

Альтернативная конструкция

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

Рис. 2. BacklogItem и Task как отдельные агрегатыРис. 2. BacklogItem и Task как отдельные агрегаты

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

Реализация конечной согласованности

Когда Task выполняет команду estimateHoursRemaining(), она публикует соответствующие доменное событие для достижения конечной согласованности. Событие имеет следующие свойства:

public class TaskHoursRemainingEstimated implements DomainEvent {     private Date occurredOn;    private TenantId tenantId;    private BacklogItemId backlogItemId;     private TaskId taskId;    private int hoursRemaining;    ...}

Теперь определенный подписчик будет прослушивать это событие и делегировать доменной службе выполнение согласованности. Служба:

  • Использует BacklogItemRepository для получения BacklogItem по идентификатору.

  • Использует TaskRepository для получения всех экземпляров Task, связанных с конкретным BacklogItem

  • Выполняет BacklogItem команду estimateTaskHoursRemaining(), передавав ей значение hoursRemaining и определенный экземпляр Task. BacklogItem может менять свой статус в зависимости от параметров.

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

public class TaskRepositoryImpl implements TaskRepository {    ...    public int totalBacklogItemTaskHoursRemaining(       TenantId aTenantId,        BacklogItemId aBacklogItemId) {            Query query = session.createQuery(            "select sum(task.hoursRemaining) from Task task "            + "where task.tenantId = ? and "            + "task.backlogItemId = ?");            ...    }}

Конечная согласованность немного усложняет пользовательский интерфейс. Если согласование состояния не произойдет в течение нескольких сотен миллисекунд, как пользовательский интерфейс отобразит новое состояние? Необходимо ли размещать бизнес-логику в представлении для определения текущего статуса? Это был бы антипаттерн умный UI. В противном случае представление отобразит устаревший статус. Это легко может быть воспринято как ошибка.

Представление может использовать фоновый запрос с помощью Ajax, но это может быть неэффективно. Поскольку компонент представления не может определить, когда потребуется проверка обновления статуса, большинство запросов Ajax не нужны. 143 из 144 переоценок не вызовут обновление статуса, что приведет к большему количеству избыточных запросов. Вместо этого можно использовать Ajax Push.

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

Время принимать решение

Исходя из всего этого анализа, возможно будет лучше отказаться от разделения Task и BacklogItem. Сейчас оно не стоит дополнительных усилий, риска нарушения истинного инварианта или возможности столкнутся с устаревшим статусом в представлении. Текущий агрегат довольно мал. Даже если в худшем случае будет загружено 50 объектов, а не 25, это все равно кластер небольшого размера.

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

Вывод

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

  • Моделируйте истинные инварианты в границах согласованности.

  • Проектируйте небольшие агрегаты.

  • Ссылайтесь на другие агрегаты по идентификатору.

  • Если необходимо, используйте конечную согласованность за пределами границ.

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

Ссылки на все части

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru