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

Ddd

Очень технический выпуск про DDD и проектирование сложных систем

12.02.2021 16:06:23 | Автор: admin

В свежем выпуске подкаста Сушите вёсла обсудили методологии проектирования сложных систем. Много говорили о Domain Driven Design, Event Sourcing и CQRS. Тема непростая, но, как говорится, очень интересная.

Артём Кулаков и Рома Чорыев разработчики Redmadrobot, они записывают подкаст, где обсуждают различные стороны создания ИТ-продуктов. Ниже ссылка на новый выпуск, тайминг и ответы на душещипательные вопросы. Но вначале небольшой дисклеймер:

Почему все больше и больше ведется разговоров о различных аспектах и методологиях проектирования систем? Потому что наши системы стали действительно большими. Чтобы разобраться, как проектировать такие системы, мы позвали Алексея Мерсона системного архитектора из Karuna. В выпуске попробовали разобраться, что такое Domain Driven Design, как он связан с Event Sourcing и при чем тут CQRS и микросервисы. Снять удалось только первый слой, да и то неравномерно. Но всем, кто хочет начать погружаться в тему, этот выпуск будет несомненно полезен. И обязательно ознакомьтесь с материалами к выпуску.

Тайминг

02:29 Гость студии Алексей Мерсон и как он начинал;

05:02 .Net и DDD;

12:26 почему сейчас все чаще говорят о DDD;

15:30 полезная литература о DDD;

23:01 как начать проектировать систему по DDD;

25:05 Event storming и Miro;

45:15 что такое Event sourcing;

55:00 CQRS и его связь с DDD и Event sourcing;

01:06:10 с чего начать.

DDD что это и почему сейчас?

Domain-Driven Design предметно-ориентированное проектирование. Понятие известно давно, но в последнее время в русскоязычном сообществе о нем говорят всё чаще.

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

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

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

Как спроектировать сложную систему с нуля?

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

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

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

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

Event Storming фото Daniel GomesEvent Storming фото Daniel Gomes

Изначально активность проводили офлайн, но сейчас подобное можно спокойно провести онлайн, например, в Miro.

Event Sourcing (не путать с Event storming)

Event Sourcing еще одна популярная сегодня тема. Это архитектурный шаблон, упоминание которого часто всплывает в связи с DDD.

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

Недавно в сообществе SpbDotNet был митап, где рассказывали о том, как одна команда применяла Event Sourcing. Они применяли его в приложении, которое реализовывало аукционы. Там есть ставки, и они, по сути, события, которые конкурируют: важно, в какой момент каждое из них произошло, важно получить из них правильную последовательность, чтобы понять конечный статус агрегата, например, лота или аукциона. И в этой ситуации Event Sourcing вполне оправдан.

Но иногда случается, что Event Sourcing это просто ненужное усложнение процесса. Ведь его легко сделать неправильно и очень сложно сделать правильно, потому что в нем есть много мест, где можно свернуть не туда.

Event Sourcing лучше всего применять в конкурентных и неизменяемых историях, говорит Алексей. Классическое место для этого шаблона финансовые транзакции. Есть финансовые события: зачисления или списания, и разработчику важно, чтобы их последовательность была, во-первых, четкой. Во-вторых, ее невозможно было отменить необходим однозначный конечный баланс. И Event Sourcing дает возможность сделать это так, чтобы цепочка событий всегда шла только вперед.

Подробнее обо всём начинается с 45:15

CQRS yay or nay?

Ребята обсудили, что CQRS это, скорее, паттерн, применяемый в технической области. Связан ли CQRS и DDD?

DDD больше заточено на side effects и изменения, а CQRS больше относится к отображению. И это его качество, как раз, применяется в Event Sourcing, потому что фактически есть только набор ивентов, а показывать, как правило, нужно данные, состояния объектов. А для того чтобы данные эти получить, нужно из ивентов делать проекции. В общем, если смотреть на CQRS под этим углом, получается история о синхронном взаимодействии с точки зрения UI/UX.

Подробное обсуждение этого непростого вопроса с 55:00.

Где и как научиться всему этому? (желательно до выхода на пенсию)

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

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

Можно ещё подписаться на сообщество DDDevotion и спросить там обо всем, что интересует. Подписчики могут проконсультировать в самых разных вопросах, например, как применять DDD в PHP, а также в других стэках.

Под конец разговора Рома ещё задал интересный вопрос: Kакая проблема должна стоять перед разработчиком, чтобы он понял, что пришло время углубиться в DDD? Если коротко, то сложно ответить коротко :) А подробно рассказали с 1:10:00.

Полезные материалы

Предыдущие выпуски подкаста Сушите вёсла

Подробнее..

Чем меня не устраивает гексагональная архитектура. Моя имплементация 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 })

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

Подробнее..

Три мушкетара Event Sourcing, Event Storming и Event Store вступают в бой Часть 1 пробуем Event Store ДБ

10.08.2020 00:11:52 | Автор: admin

Привет, Хабр! Решил я значит на время отойти от Scala, Idris и прочего ФП и чуть чуть поговорить о Event Store базе данных в которой можно сохранят события в потоки событий. Как в старой доброй книге у нас тоже мушкетеров на самом деле 4 и четвертый это DDD. Сначала я с помощью Event Storming выделю команды, события и сущности с ними связанные. Потом сделаю на их основе сохранение состояния объекта и его восстановление. Буду я делать в этой статье обычный TodoList. За подробностями добро пожаловать под кат.


Содержание


  • Изучаю Scala: Часть 2 Todo лист с возможностью загрузки картинок

Ссылки


Исходники
Образы docker image
Event Store
Event Soucing
Event Storming

Собственно говоря Event Store это БД которая предназначена для хранения событий. Так же она умеет создавать подписки на события чтобы их можно было как-то обрабатывать. Так же там есть проекции которые так же реагируют на события и на их основе аккумулирую какие-то данные. Например можно при событии TodoCreated увеличивать какой-то счетчик Count в проекции. Пока что в этой части я буду использовать Event Store как Read и Write Db. Дальше в следующих статьях я создам отдельную БД для чтения в которую будут данные записываться на основе событий сохраненных в БД для записи тобишь Event Store. Так же будет пример того как делать Путешествия во времени откатывая систему к состоянию которая она имела в прошлом.
И так начнем Event Stroming. Обычно для его проведения собирают все заинтересованных людей и экспертов которые рассказывают какие события в той предметной области которую ПО будет моделировать. Например для ПО завода ИзделиеИзготовлено. Для игры ПолученУрон. Для Финансового ПО ДенгиЗачисленыНаСчет и прочее в этом духе. Так как наша предметная область это максимально простой TodoList то событий у нас будет немного. И так, запишем на доске события нашей предметной области (домена)

Теперь добавим команды которые эти события вызывают.

Далее сгруппируем эти события и команды вокруг сущности с изменением состояния которой они связаны.

Команды у меня превратятся просто в названия методов сервиса. Приступим к реализации.
Сначала опишем События в коде.
    public interface IDomainEvent    {      //Техническое поле. Для сохранения id события в Event Strore        Guid EventId { get; }       //Техническое поле. Сюда будем записывать номер события в стриме Event Store        long EventNumber { get; set; }    }    public sealed class TodoCreated : IDomainEvent    {       //Id созданного Todo        public Guid Id { get; set; }       //Имя созданного Todo        public string Name { get; set; }        public Guid EventId => Id;        public long EventNumber { get; set; }    }    public sealed class TodoRemoved : IDomainEvent    {        public Guid EventId { get; set; }        public long EventNumber { get; set; }    }    public sealed class TodoCompleted: IDomainEvent    {        public Guid EventId { get; set; }        public long EventNumber { get; set; }    }

Теперь наше ядро сущность
    public sealed class Todo : IEntity<TodoId>    {        private readonly List<IDomainEvent> _events;        public static Todo CreateFrom(string name)        {            var id = Guid.NewGuid();            var e = new List<IDomainEvent>(){new TodoCreated()                {                    Id = id,                    Name = name                }};            return new Todo(new TodoId(id), e, name, false);        }        public static Todo CreateFrom(IEnumerable<IDomainEvent> events)        {            var id = Guid.Empty;            var name = String.Empty;            var completed = false;            var ordered = events.OrderBy(e => e.EventNumber).ToList();            if (ordered.Count == 0)                return null;            foreach (var @event in ordered)            {                switch (@event)                {                    case TodoRemoved _:                        return null;                    case TodoCreated created:                        name = created.Name;                        id = created.Id;                        break;                    case TodoCompleted _:                        completed = true;                        break;                    default: break;                }            }            if (id == default)                return null;            return new Todo(new TodoId(id), new List<IDomainEvent>(), name, completed);        }        private Todo(TodoId id, List<IDomainEvent> events, string name, bool isCompleted)        {            Id = id;            _events = events;            Name = name;            IsCompleted = isCompleted;            Validate();        }        public TodoId Id { get; }        public IReadOnlyList<IDomainEvent> Events => _events;        public string Name { get; }        public bool IsCompleted { get; private set; }        public void Complete()        {            if (!IsCompleted)            {                IsCompleted = true;                _events.Add(new TodoCompleted()                {                    EventId = Guid.NewGuid()                });            }        }        public void Delete()        {            _events.Add(new TodoRemoved()            {                EventId = Guid.NewGuid()            });        }        private void Validate()        {            if (Events == null)                throw new ApplicationException("Пустой список событий");            if (string.IsNullOrWhiteSpace(Name))                throw new ApplicationException("Пустое название задачи");            if (Id == default)                throw new ApplicationException("Пустой идентификатор задачи");        }    }

Подключаемся к Event Store
            services.AddSingleton(sp =>            {//Подключается к TCP и в случае разрыва соединения пытается восстановить соединение. //В самой строке есть опции для всего этого. Можно их в документации на оф сайте глянуть.                var con = EventStoreConnection.Create(new Uri("tcp://admin:changeit@127.0.0.1:1113"), "TodosConnection");                con.ConnectAsync().Wait();                return con;            });

И так, главная часть. Собственно сохранение и чтение событий из Event Store
    public sealed class EventsRepository : IEventsRepository    {        private readonly IEventStoreConnection _connection;        public EventsRepository(IEventStoreConnection connection)        {            _connection = connection;        }        public async Task<long> Add(Guid collectionId, IEnumerable<IDomainEvent> events)        {            var eventPayload = events.Select(e => new EventData(//Id события                e.EventId,//Тип события                e.GetType().Name,//В виде Json (True|False)                true,//Тело события                Encoding.UTF8.GetBytes(JsonSerializer.Serialize((object)e)),//Метаданные события                Encoding.UTF8.GetBytes((string)e.GetType().FullName)            ));//Добавляем в коллекцию событий сущности наше событие            var res = await _connection.AppendToStreamAsync(collectionId.ToString(), ExpectedVersion.Any, eventPayload);            return res.NextExpectedVersion;        }        public async Task<List<IDomainEvent>> Get(Guid collectionId)        {            var results = new List<IDomainEvent>();            long start = 0L;            while (true)            {                var events = await _connection.ReadStreamEventsForwardAsync(collectionId.ToString(), start, 4096, false);                if (events.Status != SliceReadStatus.Success)                    return results;                results.AddRange(Deserialize(events.Events));                if (events.IsEndOfStream)                    return results;                start = events.NextEventNumber;            }        }        public async Task<List<T>> GetAll<T>() where T : IDomainEvent        {            var results = new List<IDomainEvent>();            Position start = Position.Start;            while (true)            {                var events = await _connection.ReadAllEventsForwardAsync(start, 4096, false);                results.AddRange(Deserialize(events.Events.Where(e => e.Event.EventType == typeof(T).Name)));                if (events.IsEndOfStream)                    return results.OfType<T>().ToList();                start = events.NextPosition;            }        }        private List<IDomainEvent> Deserialize(IEnumerable<ResolvedEvent> events) =>            events                .Where(e => IsEvent(e.Event.EventType))                .Select(e =>                {                    var result = (IDomainEvent)JsonSerializer.Deserialize(e.Event.Data, ToType(e.Event.EventType));                    result.EventNumber = e.Event.EventNumber;                    return result;                })                .ToList();        private static bool IsEvent(string eventName)        {            return eventName switch            {                nameof(TodoCreated) => true,                nameof(TodoCompleted) => true,                nameof(TodoRemoved) => true,                _ => false            };        }        private static Type ToType(string eventName)        {            return eventName switch            {                nameof(TodoCreated) => typeof(TodoCreated),                nameof(TodoCompleted) => typeof(TodoCompleted),                nameof(TodoRemoved) => typeof(TodoRemoved),                _ => throw new NotImplementedException(eventName)            };        }    }

Хранилище сущностей выглядит совсем просто. Мы достаем из EventStore события сущности и восстанавливаем ее из них или просто сохраняем события сущности.
    public sealed class TodoRepository : ITodoRepository    {        private readonly IEventsRepository _eventsRepository;        public TodoRepository(IEventsRepository eventsRepository)        {            _eventsRepository = eventsRepository;        }        public Task SaveAsync(Todo entity) => _eventsRepository.Add(entity.Id.Value, entity.Events);        public async Task<Todo> GetAsync(TodoId id)        {            var events = await _eventsRepository.Get(id.Value);            return Todo.CreateFrom(events);        }        public async Task<List<Todo>> GetAllAsync()        {            var events = await _eventsRepository.GetAll<TodoCreated>();            var res = await Task.WhenAll(events.Where(t => t != null).Where(e => e.Id != default).Select(e => GetAsync(new TodoId(e.Id))));            return res.Where(t => t != null).ToList();        }    }

Сервис в котором происходит работа с репозиторием и сущностью
    public sealed class TodoService : ITodoService    {        private readonly ITodoRepository _repository;        public TodoService(ITodoRepository repository)        {            _repository = repository;        }        public async Task<TodoId> Create(TodoCreateDto dto)        {            var todo = Todo.CreateFrom(dto.Name);            await _repository.SaveAsync(todo);            return todo.Id;        }        public async Task Complete(TodoId id)        {            var todo = await _repository.GetAsync(id);            todo.Complete();            await _repository.SaveAsync(todo);        }        public async Task Remove(TodoId id)        {            var todo = await _repository.GetAsync(id);            todo.Delete();            await _repository.SaveAsync(todo);        }        public async Task<List<TodoReadDto>> GetAll()        {            var todos = await _repository.GetAllAsync();            return todos.Select(t => new TodoReadDto()            {                Id = t.Id.Value,                Name = t.Name,                IsComplete = t.IsCompleted            }).ToList();        }        public async Task<List<TodoReadDto>> Get(IEnumerable<TodoId> ids)        {            var todos = await Task.WhenAll(ids.Select(i => _repository.GetAsync(i)));            return todos.Where(t => t != null).Select(t => new TodoReadDto()            {                Id = t.Id.Value,                Name = t.Name,                IsComplete = t.IsCompleted            }).ToList();        }    }

Ну собственно пока ничего впечатляющего. В следующих статься когда я добавлю отдельную БД для чтения все заиграет другими красками. Это сразу нам повесит консистентность со временем. Event Store и SQL БД по принципу мастер слейв. Одна белая ES и много черных MS SQL из которых читают данные.
Лирическое отступление. В свете последних событий не мог не пошутить по поводу мастер слейв и черных белых. Эхе, уходит эпоха, будем внукам говорить что жили во времена когда базы при репликации называли мастер и слейв.
В системах где много чтения и мало записи данных (таких большинство) это даст прирост скорости работы. Собственно сама репликация мастер слейв она направленна на то что у вас замедляется запись (как и с индексами) но взамен ускоряется чтение за счет распределения нагрузки по нескольким БД.
Подробнее..

Как DDD помог нам построить новые ревизии в пиццериях

15.10.2020 18:13:18 | Автор: admin
В пиццериях важно выстраивать систему учёта и управления запасами. Система нужна, чтобы не терять продукты, не проводить лишние списания и правильно прогнозировать закупки на следующий месяц. Важная роль в учёте у ревизий. Они помогают проверять остатки продуктов и сверять фактическое количество и то, что есть в системе.



Ревизия в Додо не бумажная: у ревизора есть планшет, где ревизор отмечает все продукты и создает отчеты. Но до 2020 года в пиццериях ревизия проводилась именно на бумажках просто потому что так было проще и легче. Это, конечно, приводило к недостоверным данным, ошибкам и потерям, люди ошибаются, бумажки теряются, а ещё их много. Мы решили исправить эту проблему и улучшить планшетный способ. В реализации решили использовать DDD. Как у нас это получилось, расскажем дальше.

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

Схема движения продуктов и зачем нужна ревизия


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

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

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

Ревизии


Для подсчета фактического значения как раз используются ревизии (ещё их называют инвентаризациями).

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

Ревизии делятся своими данными с учётом для дальнейшей обработки, например, построения отчётов.

Проблемы в процессе проведения ревизий, или Как работали старые ревизии


Ревизии трудоемкий процесс. Он занимает много времени и состоит из нескольких этапов: подсчет и фиксация остатков сырья, суммирование результатов сырья по зонам хранения, внесение результатов в информационную систему Dodo IS.

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

Как решить проблему


Наша команда Game of Threads занимается развитием учета в пиццериях. Мы решили запустить проект планшет ревизора, который упростит проведение ревизий в пиццериях. Всё решили делать в собственной информационной системе Dodo IS, в которой реализованы основные компоненты для ведения учета, поэтому нам не нужны интеграции со сторонними системами. К тому же инструментом смогут пользоваться все страны нашего присутствия, не прибегая к дополнительным интеграциям.

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

В статье я расскажу о тактических паттернах DDD, которые мы применяли в разработке: агрегатах, командах, доменных эвентах, прикладной службе и интеграции ограниченных контекстов. Стратегические паттерны и основы DDD не будем описывать, иначе статья будет очень длинной. Об этом мы уже рассказывали в материале Что можно узнать о Domain Driven Design за 10 минут?

Новая версия ревизий


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

  • идентификатор шаблона;
  • идентификатор пиццерии;
  • название шаблона;
  • категория ревизии: месячная, недельная, дневная;
  • единицы измерения;
  • зоны хранения и сырьё в этой зоне хранения

Для этой сущности реализован CRUD-функционал и подробно на ней останавливаться не будем.

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

Начиная ревизию ревизор выбирает зону, например холодильник, и идёт считать сырьё там. В холодильнике он видит 5 пачек сыра по 10 кг, вводит в калькулятор 10 кг * 5, нажимает Ввести ещё. Затем замечает на верхней полке ещё 2 пачки, и нажимает Добавить. В результате у него есть 2 замера по 50 и 20 кг.

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


Интерфейс калькулятора.

Так, по шагам, ревизор за 1-2 часа считает всё сырьё, а потом завершает ревизию.

Алгоритм действий довольно простой:

  • ревизор может начать ревизию;
  • ревизор может добавлять замеры в начатой ревизии;
  • ревизор может завершить ревизию.

Из этого алгоритма формируются бизнес-требования к системе.

Реализация первой версии агрегата, команды и события предметной области


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

Тактические шаблоны DDD


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

Граница агрегата набор объектов, которые должны быть согласованы в рамках одной транзакции: должны быть соблюдены все инварианты в рамках этого кластера.

Инварианты бизнес-правила, которые не могут быть противоречивыми.

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

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

Корневой элемент это сущность с уникальным глобальным идентификатором. Дочерние элементы могут иметь только локальную идентичность в рамках целого агрегата. Они могут ссылаться друг на друга и могут иметь ссылку только на свой корневой элемент.

Команды и события


Опишем бизнес-требование командой. Команды это просто DTO с описательными полями.

В команде добавление замера есть следующие поля:

  • значение замера количество сырья в определенной единице измерения, может быть null, если замер удалили;
  • версия замер можно редактировать, поэтому нужна версия;
  • идентификатор сырья;
  • единица измерения: кг/г, л/мл, штуки;
  • идентификатор зоны хранения.

Код команды добавления замера
public sealed class AddMeasurementCommand{    // ctor    public double? Value { get; }    public int Version { get; }    public UUId MaterialTypeId { get; }    public UUId MeasurementId { get; }    public UnitOfMeasure UnitOfMeasure { get; }    public UUId InventoryZoneId { get; }}


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

В событии замер поля такие же, как и в команде Добавление замера, кроме того, что событие хранит ещё идентификатор агрегата на котором оно произошло и его версию.

Код события замер
public class MeasurementEvent : IPublicInventoryEvent{    public UUId MaterialTypeId { get; set; }    public double? Value { get; set; }    public UUId MeasurementId { get; set; }    public int MeasurementVersion { get; set; }    public UUId AggregateId { get; set; }    public int Version { get; set; }    public UnitOfMeasure UnitOfMeasure { get; set; }    public UUId InventoryZoneId { get; set; }}


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

Реализация агрегата Inventory



UML диаграмма агрегата Inventory.

Подход такой: начало ревизии инициирует создание агрегата Inventory, для этого мы используем фабричный метод Create и начинаем ревизию командой StartInventoryCommand.

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

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

  • Изменения (changes) хранятся с момента последнего восстановления агрегата.
  • Состояние восстанавливается методом Restore, который проигрывает все предыдущие события, отсортированные по версии, на текущем экземпляре агрегата Inventory.

Это реализация идеи Event Sourcing в рамках агрегата. О том, как реализовать идею Event Sourcing в рамках хранилища поговорим немного позже. Есть хорошая иллюстрация из книги Вон Вернона:


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

Дальше происходит несколько замеров командой AddMeasurementCommand. Ревизия завершается командой FinishInventoryCommand. Агрегат валидирует своё состояние в мутирующих методах для соблюдения своих инвариантов.

Важно отметить, что агрегат Inventory версионируется целиком, а также каждый его замер. С замерами сложнее приходится решать конфликты в методе обработки события When(MeasurementEvent e). В коде я приведу только обработку команды AddMeasurementCommand.

Код агрегата Inventory
public sealed class Inventory : IEquatable<Inventory>{    private readonly List<IInventoryEvent> _changes = new List<IInventoryEvent>();    private readonly List<InventoryMeasurement> _inventoryMeasurements = new List<InventoryMeasurement>();    internal Inventory(UUId id, int version, UUId unitId, UUId inventoryTemplateId,        UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc)        : this(id)    {        Version = version;        UnitId = unitId;        InventoryTemplateId = inventoryTemplateId;        StartedBy = startedBy;        State = state;        StartedAtUtc = startedAtUtc;        FinishedAtUtc = finishedAtUtc;    }    private Inventory(UUId id)    {        Id = id;        Version = 0;        State = InventoryState.Unknown;    }    public UUId Id { get; private set; }    public int Version { get; private set; }    public UUId UnitId { get; private set; }    public UUId InventoryTemplateId { get; private set; }    public UUId StartedBy { get; private set; }    public InventoryState State { get; private set; }    public DateTime StartedAtUtc { get; private set; }    public DateTime? FinishedAtUtc { get; private set; }    public ReadOnlyCollection<IInventoryEvent> Changes => _changes.AsReadOnly();    public ReadOnlyCollection<InventoryMeasurement> Measurements => _inventoryMeasurements.AsReadOnly();    public static Inventory Restore(UUId inventoryId, IInventoryEvent[] events)    {        var inventory = new Inventory(inventoryId);        inventory.ReplayEvents(events);        return inventory;    }    public static Inventory Restore(UUId id, int version, UUId unitId, UUId inventoryTemplateId,        UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc,        InventoryMeasurement[] measurements)    {        var inventory = new Inventory(id, version, unitId, inventoryTemplateId,            startedBy, state, startedAtUtc, finishedAtUtc);        inventory._inventoryMeasurements.AddRange(measurements);        return inventory;    }    public static Inventory Create(UUId inventoryId)    {        if (inventoryId == null)        {            throw new ArgumentNullException(nameof(inventoryId));        }        return new Inventory(inventoryId);    }    public void ReplayEvents(params IInventoryEvent[] events)    {        if (events == null)        {            throw new ArgumentNullException(nameof(events));        }        foreach (var @event in events.OrderBy(e => e.Version))        {            Mutate(@event);        }    }    public void AddMeasurement(AddMeasurementCommand command)    {        if (command == null)        {            throw new ArgumentNullException(nameof(command));        }        Apply(new MeasurementEvent        {            AggregateId = Id,            Version = Version + 1,            UnitId = UnitId,            Value = command.Value,            MeasurementVersion = command.Version,            MaterialTypeId = command.MaterialTypeId,            MeasurementId = command.MeasurementId,            UnitOfMeasure = command.UnitOfMeasure,            InventoryZoneId = command.InventoryZoneId        });    }    private void Apply(IInventoryEvent @event)    {        Mutate(@event);        _changes.Add(@event);    }    private void Mutate(IInventoryEvent @event)    {        When((dynamic) @event);        Version = @event.Version;    }    private void When(MeasurementEvent e)    {        var existMeasurement = _inventoryMeasurements.SingleOrDefault(x => x.MeasurementId == e.MeasurementId);        if (existMeasurement is null)    {        _inventoryMeasurements.Add(new InventoryMeasurement        {            Value = e.Value,            MeasurementId = e.MeasurementId,            MeasurementVersion = e.MeasurementVersion,            PreviousValue = e.PreviousValue,            MaterialTypeId = e.MaterialTypeId,            UserId = e.By,            UnitOfMeasure = e.UnitOfMeasure,            InventoryZoneId = e.InventoryZoneId        });    }    else    {        if (!existMeasurement.Value.HasValue)        {            throw new InventoryInvalidStateException("Change removed measurement");        }        if (existMeasurement.MeasurementVersion == e.MeasurementVersion - 1)        {            existMeasurement.Value = e.Value;            existMeasurement.MeasurementVersion = e.MeasurementVersion;            existMeasurement.UnitOfMeasure = e.UnitOfMeasure;            existMeasurement.InventoryZoneId = e.InventoryZoneId;        }        else if (existMeasurement.MeasurementVersion < e.MeasurementVersion)        {            throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);        }        else if (existMeasurement.MeasurementVersion == e.MeasurementVersion &&            existMeasurement.Value != e.Value)        {            throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);        }        else        {            throw new NotChangeException();        }    }}// Equals// GetHashCode}


При возникновении события Произведен замер проверяется наличие существующего замера с таким идентификатором. Если такого нет добавляется новый замер.

Если есть нужны дополнительные проверки:

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

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

Сущность замер содержит точно такие же поля, что и команда Добавление замера.

Код сущности замер
public class InventoryMeasurement{    public UUId MeasurementId { get; set; }    public UUId MaterialTypeId { get; set; }    public UUId UserId { get; set; }   public double? Value { get; set; }  public int MeasurementVersion { get; set; }  public UnitOfMeasure UnitOfMeasure { get; set; }  public UUId InventoryZoneId { get; set; }}


Использование публичных методов агрегата хорошо демонстрируют Unit-тесты.

Код юнит теста добавление замера после начала ревизии
[Fact]public void WhenAddMeasurementAfterStartInventory_ThenInventoryHaveOneMeasurement(){    var inventoryId = UUId.NewUUId();    var inventory = Domain.Inventories.Entities.Inventory.Create(inventoryId);    var unitId = UUId.NewUUId();    inventory.StartInventory(Create.StartInventoryCommand()        .WithUnitId(unitId)        .Please());    var materialTypeId = UUId.NewUUId();    var measurementId = UUId.NewUUId();    var measurementVersion = 1;    var value = 500;    var cmd = Create.AddMeasurementCommand()        .WithMaterialTypeId(materialTypeId)        .WithMeasurement(measurementId, measurementVersion)        .WithValue(value)        .Please();    inventory.AddMeasurement(cmd);    inventory.Measurements.Should().BeEquivalentTo(new InventoryMeasurement    {        MaterialTypeId = materialTypeId,        MeasurementId = measurementId,        MeasurementVersion = measurementVersion,        Value = value,        UnitOfMeasure = UnitOfMeasure.Quantity    });}


Собираем всё вместе: команды, события, агрегат Inventory



Жизненный цикл агрегата Inventory при выполнении команды Finish Inventory.

На схеме изображен процесс обработки команды FinishInventoryCommand. Перед обработкой необходимо восстановить состояние агрегата Inventory на момент выполнения команды. Для этого мы загружаем все события, которые были произведены над данным агрегатом, в память и проигрываем их (п. 1).

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

На этом этапе мы выполняем команду FinishInventoryCommand (п. 2). Эта команда сначала проверит валидность текущего состояния агрегата то, что ревизия находится в состоянии InProgress, а затем породит новое изменение состояния, добавив событие FinishInventoryEvent в список changes (п. 3).

Когда команда завершится, все изменения сохранятся в базу данных. В результате в базе появится новая строка с событием FinishInventoryEvent и последней версией агрегата (п. 4).

Тип Inventory (ревизия) агрегат и корневой элемент по отношению к своим вложенным сущностям. Таким образом, тип Inventory определяет границы агрегата. В границы агрегата входит список сущностей типа Measurement (замер), и список всех событий, произведенных над агрегатом (changes).

Реализация всей фичи


Под фичей мы понимаем реализацию конкретного бизнес-требования. В нашем примере мы рассмотрим фичу Добавление замера. Для реализации фичи нам понадобится разобраться с понятием прикладная служба (ApplicationService).

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

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

Код фичи добавление замера
public class AddMeasurementChangeHandler    : IRequestHandler<AddMeasurementChangeRequest, AddMeasurementChangeResponse>{    // dependencies    // ctor    public async Task<AddMeasurementChangeResponse> Handle(        AddMeasurementChangeRequest request,        CancellationToken ct)    {        var inventory =            await _inventoryRepository.GetAsync(request.AddMeasurementChange.InventoryId, ct);        if (inventory == null)        {            throw new NotFoundException($"Inventory {request.AddMeasurementChange.InventoryId} is not found");        }        var user = await _usersRepository.GetAsync(request.UserId, ct);        if (user == null)        {            throw new SecurityException();        }        var hasPermissions =        await _authPermissionService.HasPermissionsAsync(request.CountryId, request.Token, inventory.UnitId, ct);        if (!hasPermissions)        {            throw new SecurityException();        }        var unit = await _unitRepository.GetAsync(inventory.UnitId, ct);        if (unit == null)        {            throw new InvalidRequestDataException($"Unit {inventory.UnitId} is not found");        }        var unitOfMeasure =Enum.Parse<UnitOfMeasure>(request.AddMeasurementChange.MaterialTypeUnitOfMeasure);        var addMeasurementCommand = new AddMeasurementCommand(            request.AddMeasurementChange.Value,            request.AddMeasurementChange.Version,            request.AddMeasurementChange.MaterialTypeId,            request.AddMeasurementChange.Id,            unitOfMeasure,            request.AddMeasurementChange.InventoryZoneId);        inventory.AddMeasurement(addMeasurementCommand);        Handle(inventory, ct);        return new AddMeasurementChangeResponse(request.AddMeasurementChange.Id, user.Id, user.GetName());    }    private void Handle(Domain.Inventories.Entities.Inventory inventory, CancellationToken ct)    {        Task.Run(async () =>        {            try            {                await _inventoryRepository.AppendEventsAsync(inventory.Changes, ct);                await _localQueueDataService.Publish(inventory.Changes, ct);            }            catch (Exception ex)            {                _logger.LogError(ex, "error occured while handling action");            }        }, ct);    }}


Event sourcing


Во время реализации мы решили выбрать подход ES по нескольким причинам:

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

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

Код хранилища агрегатов Inventory
internal sealed class InventoryRepository : IInventoryRepository{    // dependencies    // ctor    static InventoryRepository()    {        EventTypes = typeof(IEvent)            .Assembly.GetTypes().Where(x => typeof(IEvent).IsAssignableFrom(x))            .ToDictionary(t => t.FullName, x => x);    }    public async Task AppendAsync(IReadOnlyCollection<IEvent> events, CancellationToken ct)    {        using (var session = await _dbSessionFactory.OpenAsync())        {            if (events.Count == 0) return;            try            {                foreach (var @event in events)                {                    await session.ExecuteAsync(Sql.AppendEvent,                        new                        {                            @event.AggregateId,                            @event.Version,                            @event.UnitId,                            Type = @event.GetType().FullName,                            Data = JsonConvert.SerializeObject(@event),                            CreatedDateTimeUtc = DateTime.UtcNow                        }, cancellationToken: ct);                }            }            catch (MySqlException e)                when (e.Number == (int) MySqlErrorCode.DuplicateKeyEntry)            {                throw new OptimisticConcurrencyException(events.First().AggregateId, "");            }        }    }    public async Task<Domain.Models.Inventory> GetInventoryAsync(        UUId inventoryId,        CancellationToken ct)    {        var events = await GetEventsAsync(inventoryId, 0, ct);        if (events.Any()) return Domain.Models.Inventory.Restore(inventoryId, events);        return null;    }        private async Task<IEvent[]> GetEventsAsync(        UUId id,        int snapshotVersion,        CancellationToken ct)    {        using (var session = await _dbSessionFactory.OpenAsync())    {            var snapshot = await GetInventorySnapshotAsync(session, inventoryId, ct);            var version = snapshot?.Version ?? 0;                    var events = await GetEventsAsync(session, inventoryId, version, ct);            if (snapshot != null)            {                snapshot.ReplayEvents(events);                return snapshot;            }            if (events.Any())            {                return Domain.Inventories.Entities.Inventory.Restore(inventoryId, events);            }            return null;        }    }    private async Task<Inventory> GetInventorySnapshotAsync(        IDbSession session,        UUId id,        CancellationToken ct)    {        var record =            await session.QueryFirstOrDefaultAsync<InventoryRecord>(Sql.GetSnapshot, new {AggregateId = id},                cancellationToken: ct);        return record == null ? null : Map(record);    }    private async Task<IInventoryEvent[]> GetEventsAsync(        IDbSession session,        UUId id,        int snapshotVersion,        CancellationToken ct)    {        var rows = await session.QueryAsync<EventRecord>(Sql.GetEvents,            new            {                AggregateId = id,                Version = snapshotVersion            }, cancellationToken: ct);        return rows.Select(Map).ToArray();    }    private static IEvent Map(EventRecord e)    {        var type = EventTypes[e.Type];        return (IEvent) JsonConvert.DeserializeObject(e.Data, type);    }}internal class EventRecord{    public string Type { get; set; }    public string Data { get; set; }}


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

Интеграция с внешними ограниченными контекстами


Так выглядит схема взаимодействия ограниченного контекста Inventory с внешним миром.


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

В случае с Auth, Inventory и Datacatalog на каждый сервис приходится один ограниченный контекст. Монолит выполняет несколько функций, но сейчас нас интересуют только функционал учета в пиццериях. Помимо ревизий, к учету также относится движения сырья в пиццериях: поступления, перемещения, списания.

HTTP


Сервис Inventory взаимодействует с Auth по HTTP. Первым делом пользователь сталкивается с Auth, который предлагает пользователю выбрать одну из доступных ему ролей.

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

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

Примечание. Как работает сервис Auth мы подробнее рассказали в статье Тонкости авторизации: обзор технологии OAuth 2.0.

С остальными сервисами Inventory взаимодействует посредством очередей сообщений. В качестве брокера сообщений в компании используется RabbitMQ, а также обвязка над ним MassTransit.

RMQ: потребление событий


Сервис справочников Datacatalog обеспечит Inventory всеми необходимыми сущностями: сырьем для учета, странами, подразделениями и пиццериями.

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

Код контракта события Datacatalog
namespace Dodo.DataCatalog.Contracts.Products.v1{    public class MaterialType  {    public UUId Id { get; set; }    public int Version { get; set; }    public int CountryId { get; set; }    public UUId DepartmentId { get; set; }    public string Name { get; set; }    public MaterialCategory Category { get; set; }    public UnitOfMeasure BasicUnitOfMeasure { get; set; }    public bool IsRemoved { get; set; }    }  public enum UnitOfMeasure  {    Quantity = 1,    Gram = 5,    Milliliter = 7,    Meter = 8,  }  public enum MaterialCategory  {    Ingredient = 1,    SemiFinishedProduct = 2,    FinishedProduct = 3,    Inventory = 4,    Packaging = 5,    Consumables = 6  }}


Это сообщение публикуется в exchange. Каждый сервис может создать свою связку exchange-queue для потребления событий.


Схема публикации события и его потребление через примитивы RMQ.

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

Код потребителя события из Datacatalog
public class MaterialTypeConsumer : IConsumer<Dodo.DataCatalog.Contracts.Products.v1.MaterialType>{  private readonly IMaterialTypeRepository _materialTypeRepository;  public MaterialTypeConsumer(IMaterialTypeRepository materialTypeRepository)  {    _materialTypeRepository = materialTypeRepository;  }  public async Task Consume(ConsumeContext<Dodo.DataCatalog.Contracts.Products.v1.MaterialType> context)  {    var materialType = new AddMaterialType(context.Message.Id,    context.Message.Name,    (int)context.Message.Category,    (int)context.Message.BasicUnitOfMeasure,    context.Message.CountryId,    context.Message.DepartmentId,    context.Message.IsRemoved,    context.Message.Version);    await _materialTypeRepository.SaveAsync(materialType, context.CancellationToken);  }}


RMQ: публикация событий


Часть монолита, которая отвечает за учёт, потребляет данные Inventory для поддержки остального функционала, где требуются данные ревизий. Все события, о которых мы хотим уведомить другие сервисы, мы помечали интерфейсом IPublicInventoryEvent. Когда происходит событие подобного рода, мы их вычленяем из списка изменений (changes) и отправляем в очередь на отправку. Для этого используются две таблицы publicqueue и publicqueue_archive.

Для гарантии доставки сообщений мы используем паттерн, который у нас обычно называют локальная очередь, подразумевая Transactional outbox pattern. Сохранение состояния агрегата Inventory и отправка событий в локальную очередь происходят в одной транзакции. Как только произошла фиксация транзакции, мы сразу же пытаемся отправить сообщения брокеру.

Если сообщение получилось отправить, то оно удаляется из очереди publicqueue. Если нет, то будет предпринята попытка отправить сообщение позднее. Далее подписчики монолита и пайплайны данных потребляют сообщения. Таблица publicqueue_archive вечно хранит данные для удобной переотправки событий, если это в какой-то момент потребуется.

Код публикации событий в брокер сообщений
internal sealed class BusDataService : IBusDataService{  private readonly IPublisherControl _publisherControl;  private readonly IPublicQueueRepository _repository;  private readonly EventMapper _eventMapper;  public BusDataService(    IPublicQueueRepository repository,    IPublisherControl publisherControl,    EventMapper eventMapper)  {    _repository = repository;    _publisherControl = publisherControl;    _eventMapper = eventMapper;  }  public async Task ConsumePublicQueueAsync(int batchEventSize, CancellationToken cancellationToken)  {    var events = await _repository.GetAsync(batchEventSize, cancellationToken);    await Publish(events, cancellationToken);  }  public async Task Publish(IEnumerable<IPublicInventoryEvent> events, CancellationToken ct)  {    foreach (var @event in events)    {    var publicQueueEvent = _eventMapper.Map((dynamic) @event);    await _publisherControl.Publish(publicQueueEvent, ct);    await _repository.DeleteAsync(@event, ct);  }  }}


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

Зачем отправлять события пайплайну данных? Все также для отчетов, но только на новых рельсах. Раньше все отчеты жили в монолите, но теперь их выносят. Это разделяет две ответственности хранение и обработку производственных и аналитических данных: OLTP и OLAP. Это важно как с точки зрения инфраструктуры, так и разработки.

Заключение


Следуя принципам и практикам Domain-Driven Design, нам удалось построить надежную и гибкую систему, которая удовлетворяет бизнес-потребностям пользователей. У нас получился не только достойный продукт, но и хороший код, который легко модифицировать. Надеемся, что и в ваших проектах найдётся место для использования Domain-Driven Design.

Больше информации о DDD вы можете найти в нашем сообществе DDDevotion и на Youtube-канале DDDevotion. Обсудить статью можно в Телеграм в Dodo Engineering chat.
Подробнее..

Ценности 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 до сих пор имеет некую таинственность и массу непонимания. Надеюсь, что данная статья сможет внести свой небольшой вклад и прояснить некоторые моменты.
Подробнее..

Представление модели предметной области (МПО) и точек интеграции

16.01.2021 14:23:21 | Автор: admin
Как известно, описание архитектуры состоит из множества представлений, для соответствующих точек зрения, интересов и заинтересованных сторон.

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

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

Возможности, которые даёт такое представление:
диаграмма МПО позволяет быстро получить понимание о функциональности, которая существует в системе, какие сущности пристутствуют в системе и какие операции с ними можно выполнять;
связь сущностей МПО с точками интеграции ускоряет поиск нужного участка кода при модификации функционала. Понимая какую операцию и по какому объекту надо модифицировать, можно быстро найти точку интеграции, а отталкиваясь от неё проанализировать весь стек вызова;
удобно накапливать информацию о предметной области в простом наглядном стиле;

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

Представление сущности МПО



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

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

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

Методы бизнес-модели содержат все возможные действия, над данной моделью. Это могут быть CRUD и какие либо ещё возможные методы. Выделение метода зачастую ориентируется на точку интеграции, которая используется в данном методе. Есть смысл так же выносить производные методы, для понимания предметной области.

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

Точки интеграции это могут быть хранимые процедуры, функции или веб-сервисы. Описываются по формату %схема/веб-сервис%: хп/хф/метод веб сервиса. Стрелкой сояединяются с соответствующим методом.

image
Рисунок 1. МПО на примере одной сущности

Пример того, как описывается одна сущность МПО изображён на Рисунке 1.

Представление МПО



Итоговое представление МПО может выглядеть примерно как на рисунке. В примере я привёл значительно упрощённую модель. В реальной модели было в 10-15 раз больше сущностей с значительно большим количеством методов и точек интеграции. Однако даже с большими размерами МПО было достаточно просто ориентироваться. Свою роль сыграливозможностям Enterprise Architect и Visual Paradigm, однако в первую очередь помог логический поиск Функциональность->Сущность->Метод->Точка интеграции

image
Рисунок 2. Пример полного представления МПО.

На рисунке 2 изображён пример полного представления МПО. По этому рисунку уже можно сложить мнение о том, какая функциональность реализована в приложении.

Как представление МПО + точки интеграции может упростить разработку



Для наглядности я приложил диаграмму компонентов (Рисунок 3), распределённых по приложениям инфораструктуры проекта:
UIWebSite
MainOnlineBackendService
PCIDSSOnlineBankLogicService

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

image
Рисунок 3. Диаграмма компонентов.

Задача 1:
Допустим, что у нас стоит задача модифицировать функциональность открытия счёта и мы не знакомы с кодом проекта. В такой задаче может прийтись исследовать стэк вызова, а учитывая, что в каждом приложении существует свой набор компонентов, стэк вызова при клике на кнопку Открыть счёт может состоять из 8 уровней:
1 ReactUI
2 RestApiController
3 AccountCachedController
4 AccountServiceWrapper
5 MainOnlineBackendServiceImplementation
6 AccountServiceWrapper
7 OnlineBankLogicServiceImplementation
8 sheme1: sp_createAccount

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

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

Задача 2:
Допустим, что у нас стала возникать ошибка при выполнении какого-либо действия. Для проектов с множеством интеграций частой причиной ошибок является ошибки внешних систем, например было выполнено обновление хранимой процедуры, отключение/недоступность веб-сервиса. На уровне пользователя такие ошибки совсем не очевидны и возникает ощущение, что проблема конкретно в системе, с которой работает пользователь. Чтобы локализовать ошибку чаще всего необходимо проанализировать стэк вызова.

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

Это две типовые распространённые задачи, при которых требуется находить точки интеграции.

Задача 3:
Другое множество, где диаграмма была полезна это различные вопросы:
какая внешняя система отвечает за ту или иную функциональность
какая точка интеграции используется для выполнения того или иного действия
где получить номер карты по её Id
кто отвечает за функционаность со стороны другой системы

Задача 4:
Вы исследуете систему и вам необходимо накапливать информацию о всех понятиях и функционале системы. Диаграмма помогает это выполнять в достаточно локаничном виде. В дальнейшем такая декомпозиция/композиция позволяет спроектировать архитектурные слои
Подробнее..

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

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

Вывод

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

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

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

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

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

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

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

Подробнее..

Domain-driven design, Hexagonal architecture of ports and adapters, Dependency injection и Python

31.05.2021 12:16:05 | Автор: admin

Prologue

- Глянь, статью на Хабр подготовил.
- Эм... а почему заголовок на английском?
- "Предметно-ориентированное проектирование, Гексагональная архитектура портов и адаптеров, Внедрение зависимостей и Пайто..."

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

Intro

Как же летит время! Два года назад я расстался с миром Django и очутился в мире Kotlin, Java и Spring Boot. Я испытал самый настоящий культурный шок. Голова гудела от объёма новых знаний. Хотелось бежать обратно в тёплую, ламповую, знакомую до байтов экосистему Питона. Особенно тяжело на первых порах давалась концепция инверсии управления (Inversion of Control, IoC) при связывании компонентов. После прямолинейного подхода Django, автоматическое внедрение зависимостей (Dependency Injection, DI) казалось чёрной магией. Но именно эта особенность фреймворка Spring Boot позволила проектировать приложения следуя заветам Чистой Архитектуры. Самым же большим вызовом стал отказ от философии "пилим фичи из трекера" в пользу Предметно-ориентированного проектирования (Domain-Driven Design, DDD).

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

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

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

Dependency Injection

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

Допустим нам понадобилась функция, отправляющая сообщения с пометкой "ТРЕВОГА!" в шину сообщений. После недолгих размышлений напишем:

from my_cool_messaging_library import get_message_bus()def send_alert(message: str):    message_bus = get_message_bus()    message_bus.send(topic='alert', message=message)

В чём главная проблема функции send_alert()? Она зависит от объекта message_bus, но для вызывающего эта зависимость совершенно не очевидна! А если вы хотите отправить сообщение по другой шине? А как насчёт уровня магии, необходимой для тестирования этой функции? Что, что? mock.patch(...) говорите? Коллеги, атака в лоб провалилась, давайте зайдём с флангов.

from my_cool_messaging_library import MessageBusdef send_alert(message_bus: MessageBus, message: str):    message_bus.send(topic='alert', message=message)

Казалось, небольшое изменение, добавили аргумент в функцию. Но одним лишь этим изменением мы убиваем нескольких зайцев: Вызывающему очевидно, что функция send_alert() зависит от объекта message_bus типа MessageBus (да здравствуют аннотации!). А тестирование, из обезьяньих патчей с бубном, превращается в написание краткого и ясного кода. Не верите?

def test_send_alert_sends_message_to_alert_topic()    message_bus_mock = MessageBusMock()    send_alert(message_bus_mock, "A week of astrology at Habrahabr!")    assert message_bus_mock.sent_to_topic == 'alert'    assert message_bus_mock.sent_message == "A week of astrology at Habrahabr!"class MessageBusMock(MessageBus):    def send(self, topic, message):        self.sent_to_topic = topic        self.sent_message = message

Тут искушённый читатель задастся вопросом: неужели придётся передавать экземпляр message_bus в функцию send_alert() при каждом вызове? Но ведь это неудобно! В чём смысл каждый раз писать

send_alert(get_message_bus(), "Stackoverflow is down")

Попытаемся решить эту проблему посредством ООП:

class AlertDispatcher:    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def send(message: str):        self._message_bus.send(topic='alert', message=message)alert_dispatcher = AlertDispatcher(get_message_bus())alert_dispatcher.send("Oh no, yet another dependency!")

Теперь уже класс AlertDispatcher зависит от объекта типа MessageBus. Мы внедряем эту зависимость в момент создания объекта AlertDispatcher посредством передачи зависимости в конструктор. Мы связали (we have wired, не путать с coupling!) объект и его зависимость.

Но теперь акцент смещается с message_bus на alert_dispatcher! Этот компонент может понадобиться в различных местах приложения. Мало ли откуда нужно оправить сигнал тревоги! Значит, необходим некий глобальный контекст из которого можно будет этот объект достать. И прежде чем перейти к построению такого контекста, давайте немного порассуждаем о природе компонентов и их связывании.

Componential architecture

Говоря о внедрении зависимостей мы не сильно заостряли внимание на типах. Но вы наверняка догадались, что MessageBus - это всего лишь абстракция, интерфейс, или как бы сказал PEP-544 - протокол. Где-то в нашем приложении объявленo:

class MessageBus(typing.Protocol):    def send(topic: str, message: str):        pass

В проекте также есть простейшая реализация MessageBus-a, записывающая сообщения в список:

class MemoryMessageBus(MessageBus):    sent_messages = []    def send(topic: str, messagge: str):        self.sent_messages.append((str, message))

Таким же образом можно абстрагировать бизнес-логику, разделив абстрактный сценарий пользования (use case) и его имплементацию:

class DispatchAlertUseCase(typing.Protocol):    def dispatch_alert(message: str):        pass
class AlertDispatcherService(DispatchAlertUseCase):    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def dispatch_alert(message: str):        self._message_bus.send(topic='alert', message=message)

Давайте для наглядности добавим HTTP-контроллер, который принимает сообщения по HTTP-каналу и вызывает DispatchAlertUseCase:

class ChatOpsController:    ...    def __init__(self, dispatch_alert_use_case: DispatchAlertUseCase):        self._dispatch_alert_use_case = dispatch_alert_use_case    @post('/alert)    def alert(self, message: Message):        self._dispatch_alert_use_case.dispatch_alert(message)        return HTTP_ACCEPTED

Наконец, всё это необходимо связать воедино:

from my_favourite_http_framework import http_serverdef main():    message_bus = MemoryMessageBus()    alert_dispatcher_service = AlertDispatcherService(message_bus)    chat_opts_controller = ChatOpsController(alert_dispatcher_service)    http_server.start()

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

@post('/alert)def alert(message: Message):    bus = MemoryMessageBus()    bus.send(topic='alert', message=message)    return HTTP_ACCEPTED

Коротко? Ещё как! Поддерживаемо? Вообще никак. Почему? Из-за сильнейшей связанности (coupling) компонентов в коде. Уместив всё в одну функцию таким образом, мы намертво привязали логику отправки оповещений к конкретной реализации шины сообщений. Но это ещё полбеды. Самое ужасное то, что бизнес-составляющая полностью растворилась в технических деталях. Не поймите меня неправильно, подобный код вполне имеет право на существование. Но простит ли растущее приложение такой сжатый подход?

Вернёмся к нашей компонентной архитектуре. В чём её преимущества?

  • Компоненты изолированы и независимы друг от друга напрямую. Вместо этого они связаны посредством абстракций.

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

  • Это значит, что компоненты могут быть протестированы как в полной изоляции, так и в любой произвольной комбинации включающей тестовых двойников (test double). Думаю не стоит объяснять, насколько проще тестировать изолированные части программы. Подход к TDD меняется с невнятного "нуууу, у нас есть тесты" на бодрое "тесты утром, вечером код".

  • С учётом того, что зависимости описываются абстракциями, можно безболезненно заменить один компонент другим. В нашем примере - вместо MemoryMessageBus можно бухнуть DbMessageBus, да хоть в файл на диске писать - тому кто вызывает message_bus.send(...) нет до этого никакого дела.

"Да это же SOLID!" - скажите вы. И будете абсолютно правы. Не удивлюсь, если у вас возникло чувство дежавю, ведь благородный дон @zueve год назад детально описал связь SOLID и Чистой архитектуры в статье "Clean Architecture глазами Python-разработчика". И наша компонентная архитектура находится лишь в шаге от чистой "гексагональной" архитектуры. Кстати, причём тут гексагон?

Architecture is about intent

Одно из замечательных высказываний дядюшки Боба на тему архитектуры приложений - Architecture is about intent (Намерения - в архитектуре).

Что вы видите на этом скриншоте?

Не удивлюсь, если многие ответили "Типичное приложение на Django". Отлично! А что же делает это приложение? Вы вероятно телепат 80го уровня, если смогли ответить на этот вопрос правильно. Лично я не именю ни малейшего понятия - это скриншот первого попавшегося Django-приложения с Гитхаба.

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

Разгадка

Это один из этажей библиотеки Oodi в Хельсинки.

Надеюсь вам было несложно отгадать эту маленькую загадку и вы вынесли из неё главное: архитектура должна встречать нас с порога, буквально с момента окончания git clone.... Как здорово, когда код приложения организован таким образом, что предназначение того или иного файла или директории лежит на поверхности!

В "Гексагональной архитектуре", гексагон в частности призван упростить восприятие архитектуры. Мудрено? Пардон, сейчас всё будет продемонстрировано наглядно.

Hexagonal architecture of Ports and Adapters

"У нас Гексагональная архитектура портов и адаптеров" - с этой фразы начинается рассказ об архитектуре приложения новым членам команды. Далее мы показываем нечто Ктулхуподобное:

Изобретатель термина "Гексагональная архитектура" Алистар Кокбёрн (Alistair Cockburn) объясняя выбор названия акцентировал внимание на его графическом представлении:

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

Итак, на изображении мы видим:

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

"Пользователь может голосовать за публикации, комментарии и карму других пользователей если его карма 5"

будет отображено именно здесь. И как вы наверняка поняли, в домене нет места HTTP, SQL, RabbitMQ, AWS и т.д. и т.п.

Зато всему этому празднику технологий есть место в адаптерах подсоединяемых к портам. Команды и запросы поступают в приложение через ведущие (driver) или API порты. Команды и запросы которые отдаёт приложение поступают в ведомые порты (driven port). Их также называют портами интерфейса поставщика услуг (Service Provider Interface, SPI).

Между портами и доменом сидят дирижёры - сервисы приложения (Application services). Они являются связующим звеном между сценариями пользования, доменом и ведомыми портами необходимыми для выполнения сценария. Также стоит упомянуть, что именно сервис приложения определяет, будет ли сценарий выполняться в рамках общей транзакции, или нет.

Всё это - и порты, и адаптеры и сервисы приложения и даже домен - слои архитектуры, состоящие из индивидуальных компонентов. Главной заповедью взаимодействия между слоями является "Зависимости всегда направлены от внешних слоёв к центру приложения". Например, адаптер может ссылаться на домен или другой адаптер, а домен ссылаться на адаптер - не может.

И... ВСЁ. Это - вся суть Гексагональной архитектуры портов и адаптеров. Она замечательно подходит для задач с обширной предметной областью. Для голого CRUDа а-ля HTTP интерфейс для базы данных, такая архитектура избыточна - Active Record вам в руки.

Давайте же засучим рукава и разберём на примере, как спроектировать Django-приложение по канонам гексагональной архитектуры.

Interlude

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

Во второй части вас ждёт реализация гексагональной архитектуры на знакомом нам всем примере. В первой части мы старались абстрагироваться от конкретных решений, будь то фреймворки или библиотеки. Последующий пример построен на основе Django и DRF с целью продемонстрировать, как можно вплести гексагональную архитектуру в фреймворк с устоявшимися традициями и архитектурными решениями. В приведённых примерах вырезаны некоторые необязательные участки и имеются допущения. Это сделано для того, чтобы мы могли сфокусироваться на важном и не отвлекались на второстепенные детали. Полностью исходный код примера доступен в репозитории https://github.com/basicWolf/hexagonal-architecture-django.

Upvote a post at Hubruhubr

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

Рейтинг публикации меняется путём голосования пользователей.

  1. Пользователь может проголосовать "ЗА" или "ПРОТИВ" публикации.

  2. Пользователь может голосовать если его карма 5.

  3. Проголосовать за данную публикацию можно лишь один раз, изменить голос нельзя.

С чего же начать работу? Конечно же с построения модели предметной области!

Domain model

Давайте ещё раз внимательно прочтём требования и подумаем, как описать "пользователя голосующего за публикацию"? Например (source):

# src/myapp/application/domain/model/voting_user.pyclass VotingUser:    id: UUID    voting_for_article_id: UUID    voted: bool    karma: int    def cast_vote(self, vote: Vote) -> CastArticleVoteResult:        ...

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

# src/myapp/application/domain/model/vote.py# Обозначает голос "За" или "Против"class Vote(Enum):    UP = 'up'    DOWN = 'down'

В свою очередь CastArticleVoteResult - это тип объединяющий оговорённые исходы сценария: ГолосПользователя, НедостаточноКармы, ПользовательУжеПроголосовалЗаПубликацию (source):

# src/myapp/application/domain/model/cast_article_vote_result.py...CastArticleVoteResult = Union[ArticleVote, InsufficientKarma, VoteAlreadyCast]

Как вы думаете, каких данных достаточно для описания результата успешно выполненного сценария?

Ответ

(source)

# src/myapp/application/domain/model/article_vote.py@dataclassclass ArticleVote:    user_id: UUID    article_id: UUID    vote: Vote    id: UUID = field(default_factory=uuid4)

Но самое интересное будет происходить в теле метода cast_article_vote(). И начнём мы конечно же с тестов. Первый же тест нацелен на проверку успешно выполненного сценария (source):

def test_cast_vote_returns_article_vote(user_id: UUID, article_id: UUID):    voting_user = VotingUser(        user_id=user_id,        voting_for_article_id=article_id,        karma=10    )    result = voting_user.cast_vote(Vote.UP)    assert isinstance(result, ArticleVote)    assert result.vote == Vote.UP    assert result.article_id == article_id    assert result.user_id == user_id

Запускаем тест и... ожидаемый фейл. В лучших традициях ТДД мы начнём игру в пинг-понг с тестами и кодом, с каждым тестом дописывая сценарий до полной готовности (source):

MINIMUM_KARMA_REQUIRED_FOR_VOTING = 5...def cast_vote(self, vote: Vote) -> CastArticleVoteResult1:    if self.voted:        return VoteAlreadyCast(            user_id=self.id,            article_id=self.voting_for_article_id        )    if self.karma < MINIMUM_KARMA_REQUIRED_FOR_VOTING:        return InsufficientKarma(user_id=self.id)    self.voted = True    return ArticleVote(        user_id=self.id,        article_id=self.voting_for_article_id,        vote=vote    )

На этом мы закончим моделирование предметной области и приступим к написанию API приложения.

Driver port: Cast article vote use case

Как было сказано ранее, в гексагональной архитектуре, приложение управляется через API-порты.

Чтобы как-то дотянуться до доменной модели, в наше приложение нужно добавить ведущий порт CastArticleVotingtUseCase, который принимает ID пользователя, ID публикации, значение голоса: за или против и возвращает результат выполненного сценария (source):

# src/myapp/application/ports/api/cast_article_vote/cast_aticle_vote_use_case.pyclass CastArticleVoteUseCase(Protocol):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        raise NotImplementedError()

Все входные параметры сценария обёрнуты в единую структуру-команду CastArticleVoteCommand (source), а все возможные результаты объединены - это уже знакомая модель домена CastArticleVoteResult (source):

# src/myapp/application/ports/api/cast_article_vote/cast_article_vote_command.py@dataclassclass CastArticleVoteCommand:    user_id: UUID    article_id: UUID    vote: Vote

Работа с гексагональной архитектурой чем-то напоминает прищурившегося Леонардо ди Каприо с фразой "We need to go deeper". Набросав каркас сценария пользования, можно примкнуть к нему с двух сторон. Можно имплементировать сервис, который свяжет доменную модель и ведомые порты для выполнения сценария. Или заняться API адаптерами, которые вызывают этот сценарий. Давайте зайдём со стороны API и напишем HTTP адаптер с помощью Django Rest Framework.

HTTP API Adapter

Наш HTTP адаптер, или на языке Django и DRF - View, до безобразия прост. За исключением преобразований запроса и ответа, он умещается в несколько строк (source):

# src/myapp/application/adapter/api/http/article_vote_view.pyclass ArticleVoteView(APIView):    ...    def __init__(self, cast_article_vote_use_case: CastArticleVoteUseCase):        self.cast_article_vote_use_case = cast_article_vote_use_case        super().__init__()    def post(self, request: Request) -> Response:        cast_article_vote_command = self._read_command(request)        result = self.cast_article_vote_use_case.cast_article_vote(            cast_article_vote_command        )        return self._build_response(result)    ...

И как вы поняли, смысл всего этого сводится к

  1. Принять HTTP запрос, десериализировать и валидировать входные данные.

  2. Запустить сценарий пользования.

  3. Сериализовать и возвратить результат выполненного сценария.

Этот адаптер конечно же строился по кирпичику с применением практик TDD и использованием инструментов Django и DRF для тестирования view-шек. Ведь для теста достаточно построить запрос (request), скормить его адаптеру и проверить ответ (response). При этом мы полностью контролируем основную зависимость cast_article_vote_use_case: CastArticleVoteUseCase и можем внедрить на её место тестового двойника.

Например, давайте напишем тест для сценария, в котором пользователь пытается проголосовать повторно. Ожидаемо, что статус в ответе будет 409 CONFLICT (source):

# tests/test_myapp/application/adapter/api/http/test_article_vote_view.pydef test_post_article_vote_with_same_user_and_article_id_twice_returns_conflict(    arf: APIRequestFactory,    user_id: UUID,    article_id: UUID):    # В роли объекта реализующего сценарий выступает    # специализированный двойник, возвращающий при вызове    # .cast_article_vote() контролируемый результат.    # Можно и MagicMock, но нужно ли?    cast_article_use_case_mock = CastArticleVoteUseCaseMock(        returned_result=VoteAlreadyCast(            user_id=user_id,            article_id=article_id        )    )    article_vote_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_use_case_mock    )    response: Response = article_vote_view(        arf.post(            f'/article_vote',            {                'user_id': user_id,                'article_id': article_id,                'vote': Vote.UP.value            },            format='json'        )    )    assert response.status_code == HTTPStatus.CONFLICT    assert response.data == {        'status': 409,        'detail': f"User \"{user_id}\" has already cast a vote for article \"{article_id}\"",        'title': "Cannot cast a vote"    }

Адаптер получает на вход валидные данные, собирает из них команду и вызывает сценарий. Oднако, вместо продакшн-кода, этот вызов получает двойник, который тут же возвращает VoteAlreadyCast. Адаптеру же нужно правильно обработать этот результат и сформировать HTTP Response. Остаётся протестировать, соответствует ли сформированный ответ и его статус ожидаемым значениям.

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

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

Application services

Как дирижёр управляет оркестром исполняющим произведение, так и сервис приложения управляет доменом и ведомыми портами при выполнении сценария.

PostRatingService

С места в карьер погрузимся в имплементацию нашего сценария. В первом приближении сервис реализующий сценарий выглядит так (source):

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase  # имплементируем протокол явным образом):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        ...

Отлично, но откуда возьмётся голосующий пользователь? Тут и появляется первая SPI-зависимость GetVotingUserPort задача которой найти голосующего пользователя по его ID. Но как мы помним, доменная модель не занимается записью голоса в какое-либо долговременное хранилище вроде БД. Для этого понадобится ещё одна SPI-зависимость SaveArticleVotePort:

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase):    _get_voting_user_port: GetVotingUserPort    _save_article_vote_port: SaveArticleVotePort    # def __init__(...) # внедрение зависимостей oпустим, чтобы не раздувать листинг    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        voting_user = self._get_voting_user_port.get_voting_user(            user_id=command.user_id,            article_id=command.article_id        )        cast_vote_result = voting_user.cast_vote(command.vote)        if isinstance(cast_vote_result, ArticleVote):            self._save_article_vote_port.save_article_vote(cast_vote_result)        return cast_vote_result

Вы наверняка представили как выглядят интерфейсы этих SPI-зависимостей. Приведём один из интерфейсов здесь (source):

# src/myapp/application/ports/spi/save_article_vote_port.pyclass SaveArticleVotePort(Protocol):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        raise NotImplementedError()

За кадром мы конечно же сначала напишем тесты, а уже потом код :) При написании юнит-тестов роль SPI-адаптеров в тестах сервиса, как и в предыдущих примерах, играют дублёры. Но чтобы удержать сей опус в рамках статьи, позвольте оставить тесты в виде ссылки на исходник (source) и двинуться дальше.

SPI Ports and Adapters

Продолжим рассматривать SPI-порты и адаптеры на примере SaveArticleVotePort. К этому моменту можно было и забыть, что мы всё ещё находимся в рамках Django. Ведь до сих пор не было написано того, с чего обычно начинается любое Django-приложение - модель данных! Начнём с адаптера, который можно подключить в вышеуказанный порт (source):

# src/myapp/application/adapter/spi/persistence/repository/article_vote_repository.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import (    ArticleVoteEntity)from myapp.application.domain.model.article_vote import ArticleVotefrom myapp.application.ports.spi.save_article_vote_port import SaveArticleVotePortclass ArticleVoteRepository(    SaveArticleVotePort,):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        article_vote_entity = ArticleVoteEntity.from_domain_model(article_vote)        article_vote_entity.save()        return article_vote_entity.to_domain_model()

Вспомним, что паттерн "Репозиторий" подразумевает скрытие деталей и тонкостей работы с источником данных. "Но позвольте! - скажете Вы, - a где здесь Django?". Чтобы избежать путаницы со словом "Model", модель данных носит гордое название ArticleVoteEntity. Entity также подразумевает, что у неё имеется уникальный идентификатор (source):

# src/myapp/application/adapter/spi/persistence/entity/article_vote_entity.pyclass ArticleVoteEntity(models.Model):    ... # здесь объявлены константы VOTE_UP, VOTE_DOWN и VOTE_CHOICES    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)    user_id = models.UUIDField()    article_id = models.UUIDField()    vote = models.IntegerField(choices=VOTES_CHOICES)    ...    def from_domain_model(cls, article_vote: ArticleVote) -> ArticleVoteEntity:        ...    def to_domain_model(self) -> ArticleVote:        ...

Таким образом, всё что происходит в save_article_vote() - это создание Django-модели из доменной модели, сохранение её в БД, обратная конвертация и возврат доменной модели. Это поведение легко протестировать. Например, юнит тест удачного исхода выглядит так (source):

# tests/test_myapp/application/adapter/spi/persistence/repository/test_article_vote_repository.py@pytest.mark.django_dbdef test_save_article_vote_persists_to_database(    article_vote_id: UUID,    user_id: UUID,    article_id: UUID):    article_vote_repository = ArticleVoteRepository()    article_vote_repository.save_article_vote(        ArticleVote(            id=article_vote_id,            user_id=user_id,            article_id=article_id,            vote=Vote.UP        )    )    assert ArticleVoteEntity.objects.filter(        id=article_vote_id,        user_id=user_id,        article_id=article_id,        vote=ArticleVoteEntity.VOTE_UP    ).exists()

Одним из требований Django является декларация моделей в models.py. Это решается простым импортированием:

# src/myapp/models.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import ArticleVoteEntityfrom myapp.application.adapter.spi.persistence.entity.voting_user_entity import VotingUserEntity

Exceptions

Приложение почти готово!. Но вам не кажется, что мы кое-что упустили? Подсказка: Что произойдёт при голосовании, если ID пользователя или публикации будет указан неверно? Где-то в недрах Django вылетит исключение VotingUserEntity.DoesNotExist, что на поверхности выльется в неприятный HTTP 500 - Internal Server Error, хотя правильнее было бы вернуть HTTP 400 - Bad Request с телом, содержащим причину ошибки.

Ответ на вопрос, "В какой момент должно быть обработано это исключение?", вовсе не очевиден. С архитектурной точки зрения, ни API, ни домен не волнуют проблемы SPI-адаптеров. Максимум, что может сделать API с таким исключением - обработать его в общем порядке, а-ля except Exception:. С другой стороны SPI-порт может предоставить исключение-обёртку, в которую SPI-адаптер завернёт внутреннюю ошибку. А API может её поймать.

О, я слышу вас, дорогие адепты функционального программирования! "Какие исключения? В топку! Даёшь Either!". В ваших словах много правды и эта тема заслуживает отдельной статьи. В одном я же, я полностью соглашусь с вами - в домене не должно быть исключений!.

Например, в данной ситуации уместным будет исключение VotingUserNotFound (source) в которое оборачивается VotingUserEntity.DoesNotExist (source):

# src/myapp/application/adapter/spi/persistence/exceptions/voting_user_not_found.pyclass VotingUserNotFound(Exception):    def __init__(self, user_id: UUID):        super().__init__(user_id, f"User '{user_id}' not found")# ---# myapp/application/adapter/spi/persistence/repository/voting_user_repository.pyclass VotingUserRepository(GetVotingUserPort):    ...    def get_voting_user(self, user_id: UUID, article_id: UUID) -> VotingUser:        try:            # Код немного упрощён, в оригинале здесь происходит            # аннотация флагом "голосовал ли пользователь за статью".            # см. исходник            entity = VotingUserEntity.objects.get(id=user_id)        except VotingUserEntity.DoesNotExist as e:            raise VotingUserNotFound(user_id) from e        return self._to_domain_model(entity)

А вот теперь действительно, приложение почти готово! Осталось соединить все компоненты и точки входа.

Dependencies and application entry point

Традиционно точки входа и маршрутизация HTTP-запросов в Django-приложениях декларируется в urls.py. Всё что нам нужно сделать - это добавить запись в urlpatterns (source):

urlpatterns = [    path('article_vote', ArticleVoteView(...).as_view())]

Но погодите! Ведь ArticleVoteView требует зависимость имплементирующую CastArticleVoteUseCase. Это конечно же PostRatingService... которому в свою очередь требуются GetVotingUserPort и SaveArticleVotePort. Всю эту цепочку зависимостей удобно хранить и управлять из одного места - контейнера зависимостей (source):

# src/myapp/dependencies_container.py...def build_production_dependencies_container() -> Dict[str, Any]:    save_article_vote_adapter = ArticleVoteRepository()    get_vote_casting_user_adapter = VotingUserRepository()    cast_article_vote_use_case = PostRatingService(        get_vote_casting_user_adapter,        save_article_vote_adapter    )    article_vote_django_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_vote_use_case    )    return {        'article_vote_django_view': article_vote_django_view    }

Этот контейнер инициализируется на старте приложения в AppConfig.ready() (source):

# myapp/apps.pyclass MyAppConfig(AppConfig):    name = 'myapp'    container: Dict[str, Any]    def ready(self) -> None:        from myapp.dependencies_container import build_production_dependencies_container        self.container = build_production_dependencies_container()

И наконец urls.py:

app_config = django_apps.get_containing_app_config('myapp')article_vote_django_view = app_config.container['article_vote_django_view']urlpatterns = [    path('article_vote', article_vote_django_view)]

Inversion of Control Containers

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

IoC-container - это фреймворк управляющий объектами и их зависимостями во время исполнения программы.

Spring был первым универсальным IoC-контейнером / фреймворком с которым я столкнулся на практике (для зануд: Micronaut - да!). Чего уж таить, я не сразу проникся заложенными в него идеями. По-настоящему оценить всю мощь автоматического связывания (autowiring) и сопутствующего функционала я смог лишь выстраивая приложение следуя практикам гексагональной архитектуры.

Представьте, насколько удобнее будет использование условного декоратора @Component, который при загрузке программы внесёт класс в реестр зависимостей и выстроит дерево зависимостей автоматически?

T.e. если зарегистрировать компоненты:

@Componentclass ArticleVoteRepository(    SaveArticleVotePort,):    ...@Componentclass VotingUserRepository(GetVotingUserPort):    ...

То IoC-container сможет инициализировать и внедрить их через конструктор в другой компонент:

```@Componentclass PostRatingService(    CastArticleVoteUseCase):    def __init__(        self,        get_voting_user_port: GetVotingUserPort,        save_article_vote_port: SaveArticleVotePort    ):        ...

К сожалению мне не приходилось иметь дела с подобным инструментарием в экосистеме Питона. Буду благодарен, если вы поделитесь опытом в комментариях!

Directory structure

Помните скриншот "типичного Django-приложения"? Сравните его с тем что получилось у нас:

Чувствуете разницу? Нам больше не нужно лезть в файлы в надежде разобраться, что же там лежит и для чего они предназначены. Более того, теперь даже структура тестов и кода приложения идентичны! Архитектура приложения видна невооружённым глазом и существует "на бумаге", а не только в голове у разработчиков приложения.

Interlude

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

Domain-Driven Design

Эрик Эванс (Eric Evans) популяризировал термин "Domain-Driven Design" в "большой синей книге" написанной в 2003м году. И всё заверте... Предметно-ориентированное проектирование - это методология разработки сложных систем, в которой во главу угла ставится понимание разработчиками предметной области путем общение с представителями (экспертами) предметной области и её моделирование в коде.

Мартин Фаулер (Martin Folwer) в своей статье рассуждая о заслугах Эванса подчёркивает, что в этой книге Эванс закрепил терминологию DDD, которой мы пользуемся и по сей день.

В частности, Эванс ввёл понятие об Универсальном Языке (Ubiquitous Language) - языке который разработчики с одной стороны и эксперты предметной области с другой, вырабатывают в процессе общения в течении всей жизни продукта. Невероятно сложно создать систему (а ведь смысл DDD - помочь нам проектировать именно сложные системы!) не понимая, для чего она предназначена и как ею пользуются.

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

- Майкл Крайтон, "Парк Юрского периода"

Более того, универсальный язык, со всеми оговорёнными терминами, сущностями, действиями, связями и т.д. используется при написании программы - в названиях модулей, функций, методов, классов, констант и даже переменных!

Другой важный термин - Ограниченный Контекст (Bounded Context) - автономные части предметной области с устоявшимися правилами, терминами и определениями. Простой пример: в онлайн магазине, модель "товар" несёт в себе совершенно разный смысл для отделов маркетинга, бухгалтерии, склада и логистики. Для связи моделей товара в этих контекстах достаточно наличие одинакового идентификатора (например UUID).

Понятие об Агрегатах (Aggregate) - наборе объектов предметной области, с которыми можно обращаться как единым целым, классификации объектов-значений и объектов-сущностей.

О DDD можно рассуждать и рассуждать. Эту тему не то что в одну статью, её и в толстенную книгу-то нелегко уместить. Приведу лишь несколько цитат, которые помогут перекинуть мостик между DDD и гексагональной архитектурой:

Предметная область - это сфера знаний или деятельности.

Модель - это система абстракций, представляющих определённый аспект предметной области.

Модель извлекает знания и предположения о предметной области и не является способом отобразить реальность.

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

Эти цитаты взяты из выступления Эрика Эванса на конференции DDD Europe 2019 года. Приглашаю вас насладиться этим выступлением, прежде чем вы введёте "DDD" в поиск Хабра и начнёте увлекательное падение в бездонную кроличью нору. По пути вас ждёт много открытий и куча набитых шишек. Помню один восхитительный момент: внезапно в голове сложилась мозаика и пришло озарение, что фундаментальные идеи DDD и Agile Manifesto имеют общие корни.

Hexagonal Architecture

Так причём же здесь Гексагональная архитектура? Я очень надеюсь, что внимательный читатель уже ответил на этот вопрос.

На заре Гексагональной архитектуры в 2005м году, Алистар Кокбёрн писал:

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

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

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

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

Тесты. Тэст-Дривэн Дэвэлопмэнт. В самом соке, когда тест пишется, к пока не существующему функционалу и мы даём возможность нашей IDE (или по-старинке) создать класс/метод/функцию/концепцию которая пока существует лишь в тесте. Интеграционные тесты, для которых необязательно загружать всю программу и инфраструктуру, а лишь адаптеры и необходимые для теста сервисы.

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

Microservices

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

На десерт - короткое видео на тему от Дейва Фарли: The problem with microservices.

Outro

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

P.S.

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

Подробнее..

Взаимодействия. RPC vs REST vs MQ

15.02.2021 14:04:42 | Автор: admin

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


Если вам необходимо спроектировать взаимодействие двух систем, в каких случаях вы выберете RPC, в каких REST, а в каких MQ?


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




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


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


Во-вторых, модель взаимодействий может быть однонаправленная (one-way) и вызовы вида запрос-ответ. Если вы исповедуете CQS, соблюдаете требование идемпотентности, то скорее всего вызовы будут однонаправленными.


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


Выбор


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


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


Сценарий транзакции


Организует бизнес-логику в процедуры, которые управляют каждая своим запросом.
TS


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


Исходя из определения, данного Мартином Фаулером, вызывать необходимо определённый сценарий и в определённой последовательности. RPC подход появился именно отсюда. Т.е. подойдут такие протоколы как: Sockets, WebSockets, gRPC, SOAP и другие.


Обработчик таблицы


Одна сущность обрабатывает всю бизнес-логику для всех строк таблицы БД или представления.
TM


Для данной формы организации доменной логики характерна работа над отдельными таблицами, с помощью репозиториев, реализующих CRUD-операции. Сервис строится с использованием API-Controller адаптера к репозиторию реализующий удалённый вызов CRUD-процедур с использование протокола HTTP. Таким образом, если ваше приложение базируется на БД с отдельными репозиториями, вам наиболее подходит REST протокол. В ряде случаев, особенно полезным становится использование протокола OData, расширяющего REST.


Модель предметной области


Объектная модель домена, объединяющая данные и поведение.
DDD


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


Агрегат


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


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


Совершенно по-новому в этом отношении смотрится gRPC замена Windows Comunication Foundation. С последним очень часто бывают существенные проблемы соразвития, особенно если интеграция происходит с командой, интеграция с которой оставляет желать лучшего (т.е. худшие варианты карты контекстов тут не подходят). В рамках же смыслового ядра считаю технологию оправданной. А сам RPC подход был бы наиболее верным.


Отдельные возможности открываются для брокеров сообщений как средство получения ответа от сервиса, ведь доменная модель идеально подходит для получения очень чистых событий предметной области, потребителями которых могут быть любые другие сервисы, а сама ШИНА ДОМЕННХ СОБТИЙ может стать превосходным средством масштабирования. Организуя архитектуру определяемую событиями, важно не забывать про возможность циклического вызова, про маркеры корреляции.




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


Просто подумайте




Что почитать


Подробнее..

Как я пробовал внедрять DDD. Тактические паттерны

10.06.2021 14:04:32 | Автор: admin

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


Поначалу мне попали в работу легаси проекты, архитектура которых была Transactional Script или Table Module. Модули требовали рефакторинга, решения тех.долгов, встал вопрос о целесообразности рефакторинга и альтернативных реализаций. Как инженер, я решил, что единственный верный шаг прокачать себя, а затем и команду, теоритически, а потом предпринимать стратегические шаги. Если с TS и TM архитектурами я был хорошо знаком, то шаблон Domain Model был знаком только в самых общих чертах по книге Мартина Фаулера. На фоне общения на конференциях, чтения матёрых книг про рефакторингу, SOLID, Agile, пришло понимание почему именно изучение подобных архитектур оправдано: в Enterprise есть смысл стремиться к максимально адаптируемому к изменениям ПО, а для доменной модели изменения требований стоят несравнимо дешевле в реализации. И меня напрягало, что как раз доменные модели я если и применяю, то понаитию, бессистемно, невежественно. Так началось моё знакомство с предметно-ориентированным проектированием.


В этой первой части, о том какие наработки удалось получить команде.


Тактические паттерны


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


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


В этом слое описывается агрегат ограниченного контекста, связанные сущности, перечисления, спецификации, утверждения и т.п. Сам слой неоднородный, состоит из двух частей:


  1. Абстрактная модель. Публичные интерфейсы модели, которые могут быть доступны в других слоях. Сами интерфейсы пишутся так, чтобы они наследовали интерфейсы из нашего Seedworks, что позволяет избежать зоопарка в различных проектах. Абстрактная модель первое с чего начинается любой сервис, т.к. содержит в себе ОБЩЕУПОТРЕБИМЙ ЯЗК.
  2. Реализация модели. Internal реализация агрегата, содержатся необходимые проверки, скрываются фабрики, бизнес-методы, утверждения и т.д.

Реализация агрегата


Команда рассматривала следующие способы реализации агрегата:


  1. Свойства с модифицированными set'ерами, в которых сокрыта логика обнаружения изменений. Код получается неоправданно усложнённым, и не совсем понятно зачем. Мы имели такую реализацию, когда ещё оперировали анемичной моделью (вспоминаю как страшный сон).
  2. Aggregate Snapshots. Механизм делает регулярно или по триггеру снимки агрегата и, если что-то поменялось, регистрируется событие.
  3. Иммутабельные агрегаты, порождающие через бизнес-методы новую версию агрегата. В нашей команде прижился 3й вариант он сулит самые большие перспективы для распределённой системы.

Итак, строение агрегата.


  • Анемичная модель. Анемичных модели у нас две: обычная, и "дефолтная", с пустыми объект-значениями и корнем. При этом анемичная модель условная часть агрегата, существующая только для организации жизненного цикла данных, т.е. в репозитории, фабриках.
    • Идентификаторы. Мы используем составной ключ <guid, long>. Первая часть идентифицирует агрегат, вторая его версию.
    • Корень агрегата. Обязательная сущность, вокруг которой и строится ограниченный контекст. С этим элементом у нас были проблемы, мы ожидали что корень будет иммутабельным на всём протяжении жизненного цикла агрегата, однако, практика показала другое, нежели в книгах. Позже слышал на DDDevotion от Константина Густова то же самое.
    • Объект-значения. Простой иммутабельный класс: конструкторы закрыты, фабричные методы открыты.
  • Бизнес-методы. В нашей реализации составной объект, состоящий из предусловий и постусловий. Результат выполнения операции усложнённая монада Result или сложная структура, возвращающая две анемичных модели и результат операции. Результаты операций на данный момент делим на:
    • Успешные.
    • Ошибочные по бизнес-проверкам, которые могут порождать новую версию агрегата, однако, могут иметь место проблемы с постусловиями.
    • Фатальная проблема, когда предусловие говорит о том, что данная операция не может быть выполнена.

Доменные сервисы


Этот слой ответственен за работу с агрегатом. Состоит двух механизмов:


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

Сначала мы пытались реализовать поиск через провайдер с применением спецификации, однако, отловили проблемы с EF. Эти проблемы застали думать о CQS. Теперь CQRS+ES у нас из коробки, т.е. доменные события отражаются на материализованных представлениях, и, в свою очередь, с их помощью происходит поиск нужного агрегата. В случае если агрегат не найден в мат.представлениях, провайдер соберёт агрегат с пустой моделью это удобно тем, что бизнес-методы всегда остаются внутри агрегатов.


Слой приложения


Слой приложений довольно обыденный. Где-то свои обработчики, где-то на основе MediatR, но, в любом случае, всем командам ограниченного контекста надлежит получить из DI-контейнера провайдер агрегатов, а затем в обработчике из него (что?) определённую версию агрегата, у которой уже вызывается бизнес-метод.


Слой сервисов


С сервисами всё интересно. По умолчанию, мы продолжаем использовать .NET Core Web API, т.е. REST, протокол. Однако, REST это про архитектуры TableModule и нельзя использовать глаголы PUT, DELETE для модифицирования агрегата. Контроллеры наших микросервисов повторяют методы агрегата, используя глагол POST, ведь для стратегических паттернов нужны идемпотентные операции. В итоге получается дисфункция использования контроллеров. Возможно, следует использовать gRPC.


Инфраструктурный слой


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


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


Как это выглядит в итоге


С одной стороны:


Описание Зарисовка
Гексогональная архитектура микросервисов, декомпозированных по субдомену. image
Команда над доменной сущностью порождает новый объект (версию). image
Сравнение версий агрегата и метаданные команды (источник доменного события). image
Для распространений изменений используется ШДС, что открывает возможности для CQRS и ES. image
Версионирование команд и агрегатов должны помочь избежать блокировок и перепроверок при помощи оптимистичных блокирок. Появлется возможность ветвлений-сессий. image

С другой стороны:


  1. Тактические паттерны освоены костяком команды. Каждый может вести свою команду, распространять подход дальше.
  2. Наработки позволяют начинать работу с контестом даже если единый язык беден, оставив от модели лишь корень. По мере уточнения общеупотребимого языка, модель будет расширяться.
  3. Из всех взятых в работу ограниченных контекстов генерируются доменные события пригодные к использованию в смежных ограниченных контекстах.
  4. Предметная сложность полностью в модели. Даже инфраструктурных сложностей нет как таковых понятная работа по материализованным представлениям, обработчикам слоя приложения. Вместе с решением технической сложности, появляется soft-slills сложности.

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




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

Подробнее..

О репозиториях замолвите слово

26.10.2020 14:14:03 | Автор: admin
image

В последнее время на хабре, и не только, можно наблюдать интерес GO сообщества к луковой/чистой архитектуре, энтерпрайз паттернам и прочему DDD. Читая статьи на данную тему и разбирая примеры кода, постоянно замечаю один момент когда дело доходит до хранения сущностей предметной области начинается изобретение своих велосипедов, которые зачастую еле едут. Код вроде бы состоит из набора паттернов: сущности, репозитории, value objectы и так далее, но кажется, что они для того там чтобы были, а не для решения поставленных задач.
В данной статье я бы хотел не только показать, что, по моему мнению, не так с типичными DDD-примерами на GO, но также продемонстрировать собственную ORM для реализации персистентности доменных сущностей.


Дисклеймер.


Прежде чем приступить к теме статьи, есть несколько моментов, которые необходимо осветить:


  • Данная статья о том, как писать приложения с богатой бизнес логикой. Сервисы на GO зачастую такими не являются, не нужно применять к ним DDDшные подходы.
  • Исходя из того, что я не являюсь ярым фанатом ORM, считаю, что зачастую использование этой технологии попросту излишне. Кроме того, необходимо брать ее лишь в том случае, когда вы отдаете себе отчет в ее целесообразном использовании в проекте, иначе вы попросту используете инструмент для галочки, для того, чтоб был.
  • Оппонировать я буду подходам из этой статьи и (раз, два) примерам проектов.
  • Я буду иллюстрировать свои мысли на примере типичного приложения wish list.

А теперь можно начинать.


Энтерпрайз паттерны в GO и что с ними не так.


Речь здесь пойдет о таких паттернах как: репозиторий, сущность, агрегат и способах их приготовления. Для начала, давайте разберемся, что же это за паттерны такие. Я не буду придумывать определения в стиле от себя, а буду использовать слова признанных мастеров: Ерика Эванса и Мартина Фаулера.


Сущность.


Начнем с сущности. По Эвансу:


Entity: Objects that have a distinct identity that runs through time and different representations. You also hear these called "reference objects".

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


type Wish struct {    id       sql.NullInt64    content  string    createAt time.Time}

Агрегат.


А вот про этот шаблон как то незаслуженно забывают, особенно в контексте GO. А забывают, между прочим, абсолютно зря. Чуть позже мы разберем почему агрегаты намеренно не используются в различных примерах DDD проектов на GO. Итак, определение по Эвансу:


Cluster the entities and value objects into aggregates and define boundaries around each. Choose one entity to be the root of each aggregate and control all access to the objects inside the boundary through the root

Рассмотрим пример aggregate root:


type User struct {    id      sql.NullInt64         name    string                email   Email                  wishes  []*Wish     friends []*User }

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


Репозиторий.


A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction. Objects can be added to and removed from the Repository, as they can from a simple collection of objects, and the mapping code encapsulated by the Repository will carry out the appropriate operations behind the scenes. Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer. Repository also supports the objective of achieving a clean separation and one-way dependency between the domain and data mapping layers.

Определение емкое, поэтому выделю основные моменты:


  • Репозиторий абстрагирует конкретное хранилище ну обычно на этом в GO проектах все и заканчивается. Да, конечно, это важно, например, при написании юнит тестов, но это далеко не вся суть репозиториев.
  • Репозитории создаются только для aggregate root. Это исходит из определения агрегата, потому как все, что мы делаем в доменном слое, должно быть сделано через корень агрегата.
  • Репозиторий предоставляет интерфейс схожий с интерфейсом коллекции.

Давайте рассмотрим типичный для GO пример репозитория и как он используется:


type UserRepository interface {    Save(*User)    Update(*User)    FindById(*User, error)}user1 := &User{}userRepo.Save(user1) // Saveuser2, _ := userRepo.FindById(1) // FindByIduser2.Name = new useruserRepo.Update(user2 ) // Update

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


  • должен ли FindById загружать коллекции друзей и желаний (что ведет к расходам на дополнительные запросы)? Что, если для решения конкретной бизнес задачи эти коллекции мне не нужны?
  • Должен ли Update каждый раз проверять список друзей и желаний не изменилось ли там что-то? Как мне отслеживать эти изменения?
  • Как быть с транзакционностью? В одном кейсе я хочу сделать Save одного пользователя, а в другом кейсе я хочу, чтобы в транзакции было два Savea. Очевидно, в таком случае управление транзакцией должно быть вне метода Save. Как в данном случае избежать протечки инфраструктурной логики в домен?

Обычно в примерах GO кода такие вопросы принято обходить всеми возможными способами:


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

А как насчет схожести интерфейса репозитория к интерфейсу GO-коллекций? Ниже представлен пример работы с коллекцией пользователей реализованной через slice:


var users []*Useruser1 := &User{}users = append(users, user1) // Saveuser2 = users[1] // FindByIduser2.Name = new user // Update

Как видите, эквивалент методу Update для слайса users просто не требуется, потому, что изменения внесенные в агрегат User применяются сразу же.


Обобщим проблемы, которые не дают DDD-like GO коду быть достаточно выразительным, тестируемым и вообще классным:


  • Типичные GO-репозитории создаются для всего подряд, агрегат, сущность может value object who cares? Причина нет ORM или других инструментов позволяющая грамотно работать сразу с графом объектов.
  • Типичные GO-репозитории не стараются походить на коллекции. В результате страдает выразительность и тестируемость кода. Знание о базе данных может протечь в бизнес логику. Причина вновь упираемся в отсутствие подходящей ORM. Можно, опять же, все делать руками, но как показывает практика это слишком неудобно.

D3 ORM. Зачем оно мне?


Хм, похоже что написать свою ORM не самая плохая идея, что я и сделал. Рассмотрим как же она помогает решить описанные выше проблемы. Для начала, как выглядит сущность Wish и агрегат User:


//d3:entity//d3_table:lw_wishtype Wish struct {    id       sql.NullInt64 `d3:"pk:auto"`    content  string    createAt time.Time}//d3:entity//d3_table:lw_usertype User struct {    id      sql.NullInt64      `d3:"pk:auto"`    name    string             `d3:"column:name"`    email   Email              `d3:"column:email"`    wishes  *entity.Collection `d3:"one_to_many:<target_entity:Wish,join_on:user_id,delete:cascade>"`    friends *entity.Collection `d3:"many_to_many:<target_entity:User,join_on:u1_id,reference_on:u2_id,join_table:lw_friend>"`}

Как видите изменений не много, но они есть. Во первых появились аннотации, с помощью которых описывается мета-информация (имя таблицы в БД, маппинг полей структуры на поля в БД, индексы). Во вторых вместо обычных для GO коллекций sliceов D3 ORM накладывает требования на использование своих коллекций. Данное требование исходит из желания иметь фичу lazy/eager loading. Можно сказать, что, если не брать в расчет кастомные коллекции, то описание бизнес сущностей делается полностью нативными средствами.


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


userRepo, _:= d3orm.MakeRepository(&domain.User{})userRepo.Persists(ctx, user1) // Saveuser2, _ := userRepo.FindOne(ctx, userRepo.Select().AndWhere("id", "=", 1)) // FindByIduser2.Name = new user // Update

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


orm.Session(ctx).Flush()

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


Разберем еще некоторые примеры кода для демонстрации тех или иных фич:


  • lazy loading, в данном примере запрос на извлечение из БД желаний пользователя будет создан и выполнен в момент непосредственного обращения к коллекции (в последней строке)

u, _ := userRepo.FindOne(ctx, userRepo.Select().AndWhere("id", "=", 1)) // будет сгенерирован запрос только для таблицы lw_userwishes := u.wishes.ToSlice() // cгенерируется запрос для таблицы lw_wish

  • transactions D3 ORM использует концепцию UnitOfWork или другими словами транзакции на уровне приложения. Все изменения накапливаются пока не будет вызван Flush(). Кроме того транзакцией можно управлять вручную, объединяя несколько Flushей в одну транзакцию

userRepo.Persists(ctx, user1)userRepo.Persists(ctx, user2)orm.Session(ctx).Flush() // стандартное поведение - при вызове Flush создается физическая транзакция, в рамках которой выполняется два insertasession := orm.Session(ctx)session.BeginTx() // переводим в ручной режим управления транзакциейuserRepo.Persists(ctx, user1)userRepo.Persists(ctx, user2)session.Flush() // в ручном режиме тут не будет сгенерировано запросов к базеuserRepo.Persists(ctx, user3)session.Flush()session.CommitTx() // на этой строчке будет сгенерирована транзакция в рамках которой выполняется три inserta 

  • при вызове Persists сохраняются все объекты от корневого (то есть граф объектов). При этом запросы в базу данных на вставку/обновление генерируются только для тех, которые действительно изменились

Подробно о том, как работать с ORM, есть документация, а также демо проект. Краткий список фич:


  • кодогенерация вместо рефлексии
  • автогенерация схемы базы данных на основе сущностей
  • один к одному, один ко многим и многие ко многим связи между сущностями
  • lazy/eager загрузка связей
  • query builder
  • загрузка связей в одном запросе к базе (используется join)
  • кэш сущностей
  • каскадное удаление и обновление связанных сущностей
  • application-level transactions (UnitOfWork)
  • DB transactions
  • поддерживается UUID

А зачем оно вам?


Резюмируя, чем вам может быть полезна D3 ORM:


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

В противном случае не могу советовать использовать D3 ORM.
А еще бы хотел описать случаи, где, по моему мнению, использовать любую ORM плохая идея:


  • если вам действительно важна производительность и вы боритесь за каждую аллокацию
  • если ваше приложение выполняет в основном READ операции. Ну право дело, для этого у нас есть отличный инструмент SQL, зачем нам что-то другое?
  • если ваше приложение тонкий клиент к базе данных. Зачем вводить ненужные абстракции?

Заключение.


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

Подробнее..

Организация бизнес-логики корпоративных приложений. Какие возможны варианты?

04.05.2021 10:05:07 | Автор: admin

Оригинал статьи находится по адресу

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

Три типовых решения при работе с бизнес-логикой по Фаулеру

С одной стороны сложно писать об организации бизнес-логики в приложении. Получается очень абстрактная статья. Благо есть книги, где затронута эта тема и даже есть примеры кода. Мартин Фаулер в книге "Шаблоны корпоративных приложений" выделял три основных типовых решения. Сценарий транзакции (Transaction Script), модуль таблицы (Table Module) и модель предметной области (Domain Model).Самый элементарный из них - это сценарий транзакции. Не будем их здесь обсуждать подробно - они очень хорошо описаны в первоисточнике с примерами. Приведем для дальнейших рассуждений лишь схему все из той же книги:

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

Сколько типовых решений на самом деле?

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

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

Как влияют фреймворки и инструменты разработки на кривую стоимости?

Сразу обозначим, что в качестве хранилища данных может выступать не только реляционная СУБД, но и NoSQL хранилище,NewSQL и даже обычные файлы, сериализованные в json, бинарный формат и т.п. Мы смотрим на ситуацию в комплексе. Если говорить про работу с обычными SQL хранилищами, здесь также огромный выбор. Вы можете писать простые запросы, писать хранимые процедуры, можете использовать ORM, использовать Code First, либо DB First подходы - все это в конечном счете сказывается на стиле в котором написана бизнес логика. В большей степени процедурном, либо в большей степени объектно-ориентированном. Ниже я примерно обозначил свое мнение о популярных схемах работы с БД.

Проблема в том, что используемые средства накладывают ограничения, которые не позволят вам реализовать тот или иной подход в полной мере. Например, с помощью Dapper не удобно работать со сложными ассоциациями внутри доменных сущностей. А при использовании ORM уровня Entity Framework вы добавляете код для отображения сущностей на таблицы. Если говорить о NoSQL СУБД, для примера Neo4j, то там очень выразительный и мощный для своих задач язык. Но опять же это приведет к использованию процедурной парадигмы.

Насколько легко сменить выбранное решение?

Давайте попробуем представить ситуацию, где мы решили кардинально изменить схему работы с хранилищем данных. С чем мы можем в таком случае столкнуться? До этого мы обсудили два важнейших аспекта - сложность кода и стоимость его сопровождения. Но на практике этого оказывается мало. Есть еще как минимум вопрос производительности - создаваемое приложение должно быть быстрым. И это сказывается на стиле написания кода. Чем жестче требования производительности, тем более процедурный код мы получаем на выходе. В каком-то экстремальном случае это может быть сервис или приложение, написанное с использованием полностью SQL, где вся логика скрыта в хитрообразных джойнах, оконных функция и обобщенных табличных выражениях. Работает быстро, но перевести его на ORM уже не так просто - все равно что переписать с нуля. Развитие такого продукта также может столкнуться с сложностями, учитывая график выше и процедурный стиль. Еще один вопрос - консистентность данных. Например, реляционные СУБД предоставляют очень богатые возможности по работе с транзакциями. Разобравшись с ними один раз - можно легко писать код, где вы точно знаете какие данные увидит пользователь, какие сможет изменить. С другой стороны, если вы пользуетесь ORM и выносите всю вашу бизнес-логику в классы работать в терминах транзакций становится сложнее. Обычно происходит реорганизация структуры таблиц и даже бизнес-сценариев таким образом, что они начинают работать в стиле согласованности данных в конечном счете (eventual consistency). Очевидно, что это также затрудняет перевод с одной схемы на другую, если вы заранее не заложили такую возможность. Компетенцию команды также не следует сбрасывать со счетов. Часто разработчики знают хорошо либо SQL, либо ORM и при переходе можно неожиданно столкнуться с проблемами.

Выводы

Из всего, что мы обсудили можно сделать выводы:

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

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

  • Для доставшегося в "наследство" программного кода одно из первых действий - это оценка степени соответствия выбранного типового решения и объема уже реализованной логики. Как показывает практика - это наиболее частая причина технического долга.

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

Подробнее..

Категории

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

  • Имя: Макс
    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