Domain modeling made functional на русском
Domain modeling made functional на русском
Доменное моделирование в функциональном стиле. Часть 3
В прошлый раз мы поговорили о второй части книги. Обсудили функциональную декомпозицию доменной модели и узнали, как использовать типы, чтобы отразить бизнес-требования. В конце главы написали код, который одновременно был документацией и компилируемой основой для реализации системы.
В этот раз мы познакомимся с функциональной композицией, частичным применением и монадами.
Глава 8. Понятие функций
В этой главе автор рассказывает об ФП в целом и его ключевых концепциях. В частности — что такое функции и функциональная композиция.
Функции — это штуки
Штуками мы будем называть то, что можно передать как вход или параметр и отдать как результат. Мы можем это делать с функциями в F# (и TS/JS).
«Функцию-вход» можно использовать в другой функции, чтобы уменьшить дублирование и вытащить общие действия. «Функция-параметр» может «настроить» работу другой функции. «Функция-выход» сама может быть «настроена» разными параметрами.
Функция, которая принимает на вход или возвращает другую функцию, называется функцией высшего порядка.
Если мы к этому добавим каррирование и частичное применение, то получим гибкий механизм для «настройки» поведения программ.
Композиция
Композиция функций — это совмещение нескольких функций в функцию сложнее, где выход первой становится входом следующей и т. д. Такое соединение функций называется пайпингом (piping). Оно работает, если тип результата первой функции совпадает с типом аргумента следующей.
Композиция функций, как «стыковка» выхода первой функции и входа второй
Пример выше — то же, что и:
Результат композиции — новая функция
. Потому что результат тот же. Нам становится неважно, что именно было по середине процесса, важны вход и результат.
Собственно именно благодаря композиции мы можем из маленьких функций строить большие приложения:
Основная проблема в композиции — это несовпадение типов аргументов и результатов. В следующих главах автор рассказывает, как эту проблему решать.
Глава 9. Композиция пайплайна
В этой и следующей главе мы реализуем процесс приёмки заказа в коде. Мы хотим получить нечто типа:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
В примере на TypeScript я пока что использую последовательно вложенные функции вместо пайплайна, как в F#. Но есть пропозал “Pipe Operator for JavaScript”, который возможно в будущем немного приблизит синтаксис к труъшному ФП. Он слегка отличается от пайпа в F#, и в пропозале даже есть обоснование, почему они решили выбрать именно такой синтаксис.
Я в конспекте не использую этот оператор, потому что на момент написания пропозал находится в stage 2. Что-то может поменяться в синтаксисе или принципах работы в будущем.
Сперва мы реализуем каждую функцию отдельно, а потом попробуем скомпоновать их в одну большую. По пути научимся манипулировать типами данных так, чтобы выход одной функции подходил ко входу следующей.
Используем типы как путеводитель
Чтобы компилятор сделал за нас всю работу по проверке типов, мы можем указать тип для функции явно. Тогда, если мы сделаем ошибку в параметрах или возвращаемом результате, то узнаем об этом сразу же. На примере функции валидации это могло бы выглядеть так:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Реализуем валидацию
Для простоты в этой главе мы отбросим эффекты, поэтому тип проверки адреса пока упростим:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Тогда тип валидатора будет следующим:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Чтобы создать валидированный заказ из невалидированного, нам потребуется:
Функции toCustomerInfo и toAddress — это хелперы, которые будут создавать доменные типы из неваладированных данных или выбрасывать ошибки, если данные не подходят. Внутри них мы будем пользоваться той же логикой — преобразовать невалидированные данные в домен, а если не получится — выбросить ошибку.
Фрагмент кода на F#. Показать на других языках: F# TypeScript
В случае с проверкой адреса нам также потребуется вызвать сторонний сервис (зависимость):
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Для создания пунктов заказа мы пройдёмся по каждому невалидированному пункту с List.map и проделаем то же самое. Но код создания пунктов заказа, а также реализацию остальных шагов и создание событий я предлагаю посмотреть в оригинале :—)
Внедряем зависимости
В функциональном программировании мы не используем DI-контейнеры, а наоборот держим все зависимости явными. Книжка вводная, говорит автор, поэтому мы не будем затрагивать такие вещи, как Reader Monad и Free Monad. Будем просто «внедрять зависимости» через верхнеуровневую функцию.
Посмотрим на пример хелперов, которые мы писали ранее:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Функции checkAddressExists и checkProductCodeExists — это зависимости. Когда мы используем их в других функциях, мы должны указать зависимости и там:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
И так до тех пор, пока мы не доберёмся до «корня композиции» — верхнеуровневой функции, которая будет настраивать все эти зависимости. Такие функции удобнее тестировать, потому что все зависимости легко подменить, а сами функции не содержат состояния.
Если зависимостей у функции слишком много, то стоит подумать, можно ли упростить функцию, чтобы избавиться от каких-то из них. Если нет, то можно собрать зависимости в рекорд и передавать их как один аргумент.
Если какие-то штуки нужны только для одной конкретной функции, то такие зависимости можно не поднимать до самого верха. Когда мы передаём одну функцию в другую, стоит держать тип этой функции как можно проще.
Глава 10. Работа с ошибками
Мы хотим создать согласованную и прозрачную схему работы с ошибками. В этой главе мы рассмотрим функциональный подход к обработке ошибок и узнаем, как отделять доменные ошибки (domain errors) от остальных.
Сигнатура функций должна сообщать обо всех возможных результатах её работы явно. Поэтому такая сигнатура будет обманчивой:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
У нас могут возникнуть ошибки, и мы хотим это отобразить прямо в типе:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Работаем с доменными ошибками
Потенциальные ошибки мы можем поделить на 3 группы:
Доменные ошибки стоит включить в доменную модель и покрыть типами. Исключения должны завершить процесс и обработаться на верхнем уровне. Инфраструктурные можно обработать и так, и так, будет зависеть от архитектуры и требований. В этой книге мы уделяем внимание только доменным ошибкам.
Мы можем типизировать каждую ошибку, а затем для каждого процесса собрать юнион возможных ошибок для этого процесса:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Это будет не только документацией, но и сделает модель ошибок расширяемой. Мы не можем сразу определить все ошибки, которые могут возникнуть, поэтому нам надо подумать о добавлении новых в будущем. Юнион отлично для этого подходит.
Кроме этого при добавлении новых ошибок все паттерн-матчинги по этому юниону сами напомнят о том, что их надо обновить.
Пишем цепочки из функций, возвращающих Result
Проблема с функциями, которые возвращают Result в том, что их сложно составлять в пайплайн. Они как будто добавляют развилки на путях:
Функция как бы раздваивает путь выполнения программы
Хочется сделать так, чтобы если «поезд свернул» на путь с ошибкой, то дальше он только и шёл по этому пути:
Хочется, чтобы был «проезд» по второму пути
Но сопоставить две Result-функции просто так мы не сможем, потому что у них отличаются формы входов и выходов:
Выходы и входы двух таких функций не совпадают
Нам хочется получить такие адаптеры, которые бы корректировали «форму входов» Result-функций, чтобы их можно было соединять в цепочки:
В любой момент управление программы может «свернуть» на путь с ошибкой и будет идти до конца пайплайна уже по нему
Один из таких адаптеров — это bind :
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Монады и всякое такое прочее
Монада — это паттерн, который позволяет соединять монадические функции в цепочки. А монадическая функция — это функция, которая возвращает некое «усовершенствованное» значение.
Технически «монада» — это термин для сущности, у которой есть:
Глава 11. Сериализация
Домен — это хорошо, но ему надо как-то общаться с инфраструктурой, которая может не понимать наших типов и вообще быть написана на других языках. В этой главе мы поговорим о том, как сериализовывать и десериализовывать данные.
Хранение и сериализация
Хранение (persistence) — это способность состояния переживать по времени процесс, который его породил. Сериализация (serialization) — это процесс превращения специфических домену структур в формат, который легко хранить (JSON, XML и т. д.).
Проектирование под сериализацию
Чтобы сериализация была безболезненной, нам надо конвертировать доменные объекты в DTO, а уже их — сериализовать. При десериализации — делать наоборот.
При десериализации мы получаем данные извне контекста, валидируем их и создаём доменный объект с уже проверенными данными
Пример сериализации
Допустим, мы хотим научиться сериализовывать тип Person :
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Далее объявим тип для DTO и функции, которые будут конвертировать домен в DTO и обратно:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Далее нужно будет дописать сериалайзер, который будет превращать DTO в нужный формат. Я оставлю это снаружи конспекта, там довольно много кода, но он простой, поэтому рекомендую глянуть самим.
Как правильно переводить типы в DTO
Есть несколько рекомендаций по «переводу» типов в DTO:
Глава 12. Хранение
Мы спроектировали приложение таким, чтобы ему было неважно, как его данные будут хранить (persistence ignorance). Но хранить их всё-таки придётся, поэтому поговорим и об этом.
Двигаем хранение к краям процесса
Мы хотим, чтобы доменная логика была чистой, поэтому помещаем её в центре процесса, а всё, что имеет побочные эффекты — по краям. Допустим, мы хотим реализовать логику для оплаты инвойса, где нам надо:
Функцию оплаты лучше сделать чистой, а всё, что связано с чтением и записью в БД — отделить.
По сути это почипо-бутерброд, о котором писал в своё время Марк Зиманн.
Разделение на команды и запросы
В ФП все объекты считаются неизменяемыми, то же будем думать и о хранилище. Каждый раз, когда мы что-то обновляем в нём, оно превращается в «копию с изменениями».
Хранилище возвращает копию себя после каждого «изменения»
С типах мы бы могли выразить это так:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Здесь видно, что одна из сигнатур отличается: ReadData возвращает данные, а все остальные — новое состояние хранилища. То есть ReadData состояние не изменяет.
На этом строится принцип разделения на команды и запросы — CQS, Command-Query Separation:
Этот принцип приводит к следующему — CQRS, Command-Query Responsibility Segregation, который говорит, что модели для записи и чтения данных лучше хранить отдельно. Дело в том, что объект, например, пользователя, который требуется для записи может (и скорее всего будет) отличаться от объекта, который возвращается при чтении. Поэтому лучше разделить эти модели в разные модули, чтобы они могли эволюционировать независимо.
CQS и CQRS — это не совсем одно и то же. Подробнее о разнице на Википедии и вот тут.
Делаем хранилища для разных контекстов независимыми
Есть ещё пара рекомендаций, как облегчить хранение:
Это делает разные контексты расцепленными, что позволяет развивать их независимо друг от друга.
Конкретные примеры работы с разными типами хранилищ, записью, чтением и транзакциями я предлагаю посмотреть прямо в книжке.
Остальные главы
В 13-й главе автор рассказывает и приводит примеры того, как держать код чистым при развитии приложения. Что делать, если появляются новые требования, как быть, когда старый дизайн надо поменять и т. д.
Я не стал конспектировать её, потому что пришлось бы скопировать всю книгу 😃
Рекомендую прочитать эту главу (да и книгу полностью) самим.
Заключение
Мне было забавно, что часть принципов из этой книги я когда-то придумал для себя сам. Частичным применением для «управления зависимостями» и проектированием в типах с отдельными типами для разных этапов жизненного цикла данных я пользовался, когда переписывал Тяжеловато.
Функциональный пайплайн и разделение по фичам я использовал на прошлой работе даже несмотря на то, что проект был построен в парадигме ООП. Непредставимость невалидных данных, конечно, спроектировать в TS сложнее, потому что JS-рантайм дышит в спину, но всё же, идея как-то сидела в голове. Какими-то идеями я даже пользовался, когда последний раз блог переписывал.
Идеи сами по себе доказали свою работоспособность, но теперь у меня есть ещё и авторитетный источник, на который я могу ссылаться при случае 😃
Новое в книге для меня было в основном связано с DDD и начальными этапами проектирования. Было неочевидно, что слишком абстрактные типы могут навредить в начале работы. Ну и читать код на F# тоже было интересно.
Доменное моделирование в функциональном стиле. Часть 2
В прошлый раз мы поговорили о первой части книги. Обсудили, что такое домен, зачем он нужен и как декомпозировать большие домены на небольшие компоненты, которые могут развиваться независимо друг от друга.
Во этот раз мы спроектируем один из процессов в функциональном стиле. Рассмотрим функциональную декомпозицию доменной модели и узнаем, как использовать типы, чтобы отразить бизнес-требования. К концу главы мы напишем код, который одновременно будет документацией и компилируемой основой для реализации системы.
Глава 4. Понимание типов
В этой главе мы попробуем отразить бизнес-требования с помощью системы типов. Узнаем, что такое типы, как их объявлять и использовать, а также — как они могут представлять доменную модель.
Понимание функций
У каждой функции есть сигнатура — описание её поведения в виде типов. В большей части случаев F# сможет определить типы самостоятельно. Например:
Если функция может работать с разными типами, то она называется дженериком (generic function). Дженерик-типы в сигнатуре начинаются в кавычки:
В TypeScript это бы выглядело так:
Типы и функции
Тип — это имя для некоторого набора возможных значений.
Мы можем обозначить такой тип как преобразование входных данных к выходным:
Типы не обязательно должны содержать примитивы. Они могут отражать и «сложные вещи»:
Функции — тоже «вещи», поэтому мы можем использовать наборы функций, как типы тоже! В сигнатуре такой тип будет заключён в скобки:
Значение — это то, что может быть использовано как аргумент или результат. Все значения в ФП по умолчанию неизменяемы и у них нет никаких «методов», которые бы что-то делали. Значения — это только данные.
Композиция типов
Композиция — создание чего-то из чего-то поменьше. Она применима и к типам тоже. Типы можно компоновать с помощью логического «И» и логического «ИЛИ».
Например, если мы описываем тип для фруктового салата, где нам нужны бананы, яблоки и вишни, то мы напишем рекорд-тип (record type):
Фрагмент кода на F#. Показать на других языках: F# TypeScript
А если мы описываем закуску, в которой можно выбрать яблоко, банан или вишню, то опишем его как юнион-тип (discriminated union):
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Это и есть простейшая композиция типов. Система, в которой сложные типы сложены из простых с помощью операций И и ИЛИ называется алгебраической системой типов.
Строим доменную модель с помощью композиции типов
Композиция типов может помочь при моделировании систем. Допустим, мы хотим смоделировать оплату платежей в интернет-магазине. Начнём с обёрток над примитивными типами:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
В F# of позволяет создать обёртку над типом, так что две обёртки над int будут разными типами. В TypeScript это воспроизвести можно (например, с помощью брендирования), но я решил не перегружать этим конспект и буду использовать везде алиасы. Это работает не так строго, но понять суть книги не помешает.
Затем распишем в виде юниона виды принимаемых карт, а в виде рекорда — всю информацию о карте:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Если магазин принимает несколько методов оплаты, то мы снова можем использовать юнион:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Сумму и валюту тоже можем описать базовыми типами:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Ну а тип оплаты полностью может выглядеть так:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Более того мы можем описать и то, как будет происходить оплата или как конвертировать валюты — в виде типов функций:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Забавно, что примерно таким же способом я проектировал магазин в своём докладе и посте о «Чистой архитектуре во фронтенде» ещё до прочтения книги. Тут в целом разговор о создании такой «мета-программы», которая описывает предметную область в типах. Мне это пригождается ещё со времён переписывания Тяжеловато на TypeScript.
Моделирование необязательных значений, ошибок и коллекций
О тонкостях F# лучше прочесть напрямую в книге или в руководстве по языку.
Глава 5. Моделирование домена в типах
В этой главе мы состряпаем доменную модель, используя систему типов так, что её смогут читать не только «программисты на фарше», но и доменные эксперты.
Замечать паттерны в модели
Каждая область знаний уникальна, но кое-что всё же будет повторяться от проекта к проекту. Например:
Моделирование простых значений
Эксперты не думают о простых значениях, как о «строках» или «числах». Они думают о «кодах товаров», «количестве» и «ценах». Это значит две вещи:
В F# есть нативный способ объявить такие типы, чтобы они были разными:
В конспекте в коде на TypeScript мы не будем обращать внимание на этот нюанс. Как я говорил выше, можно использовать брендирование, чтобы добиться такого же результата. Мы же просто будем использовать алиасы, чтобы код был проще для понимания.
В F#, кстати, тоже можно использовать алиасы, но там это связано с производительностью. Больше способов увеличения производительности — в книге.
Моделирование сложных значений
Часть тесно связанных данных мы можем представить в виде рекорд-типов:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
На первых этапах может быть непонятно, какие из типов одинаковые, а какие — нет. Это стоит уточнить у экспертов. Если они говорят о ShippingAddress и BillingAddress как о разных вещах, то лучше сделать их разными типами. Они могут развиваться в разных направлениях, и разделять типы будет сложнее, чем сложить в один.
В начале проектирования может также не хватать знаний об ограничениях или структуре каких-то типов. Это не проблема, сейчас можно заменить неизвестные типы на явно неизвестные (explicitly undefined). Это позволит продолжить проектирование, не отвлекаясь на ошибки компилятора. (Понятно, что после уточнения ограничений неизвестные структуры надо будет обновить.)
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Данные, которые предоставляют выбор, мы можем представить в виде юнион-типов:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Моделирование процессов как функций
Юнион- и рекорд-типы играют роль существительных в общем языке. Роль глаголов будут играть функциональные типы (function types). Например, процесс валидации заказа мы бы могли отобразить как:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Если процесс возвращает несколько событий, то мы можем использовать рекорд-тип, чтобы показать это:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Если процесс принимает несколько групп данных на вход, то можно поступить двумя способами: использовать несколько аргументов или использовать рекорд с несколькими полями:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Второй способ больше подходит, когда входные данные тесно связаны и обязательны. Если же один из параметров скорее зависимость, чем прямой аргумент, то лучше использовать первый способ — он поможет в будущем с «функциональным внедрением зависимостей».
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Вопрос идентичности
В DDD принято делить вещи на «сущности» (entities) и «объекты значения» (value objects). У первых есть уникальная идентичность, вторые же — одинаковы, если у них одинаковое содержимое.
Сорян, но вместо «объектов значений» я буду использовать оригинал — value objects, нормального адекватного перевода я так и не нашёл.
Например, если мы говорим об именах людей, то два разных человека могут иметь одинаковые имена. Имя тогда может представлять собой value object. В F# два рекорд-значения одного типа одинаковы, если значения их полей одинаковы. Это называется структурной идентичностью (structural identity):
Но иногда нам надо смоделировать вещи, которые остаются теми же, даже если их содержимое меняется. Например, человек, переехав на новый адрес, всё ещё остаётся тем же человеком. Такие вещи называются сущностями.
Сущностям нужен идентификатор, который позволит отличать одну сущности от другой. Иногда такие идентификаторы предоставляет предметная область (серийный номер, номер соц. страхования и т. д.), иногда их приходится создавать самостоятельно (GUID, UUID).
Хранить идентификаторы удобнее «внутри» самой сущности, чем «снаружи», потому что это делает удобнее паттерн-матчинг.
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Для паттерн-матчинга в TS можно использовать сторонние библиотеки, например, ts-pattern.
Для value objects обязательна иммутабельность — потому что при изменении какого-то поля, объект становится другим, то есть он не может «просто поменяться». Сущности меняться могут, но не будем их просто «менять», вместо этого мы будем создавать копии с изменениями, сохраняя идентификатор. Это делает все изменения в сущности явными:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Совокупности (агрегаты)
Подумаем над таким вопросом: если пользователь поменяет пункт заказа — должен ли поменяться весь заказ? Ответ: да, должен, по трём причинам:
Под консистентностью мы имеем в виду, что при изменении даже одного товарного пункта, нам потребуется пересчитать общую сумму заказа. Если этого не сделать, данные станут неконсистентными.
Под инвариатностью мы имеем в виду, что если в заказе должен быть хотя бы один товарный пункт, нам надо проверить, что после удаления нескольких товаров, там останется хотя бы один товар.
Неизменяемость на самом деле запускает цепочку изменений от внутренних структур до «корневой», то есть от товарного пункта до заказа, которому он принадлежит. В терминах DDD список товаров будет называться совокупностью (агрегатом, aggregate), а заказ — корнем (aggregate root).
Агрегаты — это отдельные, независимые единицы хранения информации (units of persistence). Нам стоит проводить границы так, чтобы иммутабельность не запускала лишних обновлений структур. Например, если в заказе есть ссылка на пользователя, который сделал этот заказ, как лучше сослаться на него: используя всего пользователя или только его ID?
Фрагмент кода на F#. Показать на других языках: F# TypeScript
В первом случае при изменении имени пользователя придётся менять и заказы, во втором — не придётся. Второй способ предпочтительнее, потому что не тащит за собой лишний изменений.
Глава 6. Целостность и согласованность домена
В этой главе мы рассмотрим понятия целостности (integrity) и согласованность (consistency). Мы хотим убедиться, что данным внутри ограниченного контекста можно доверять, что они провалидированы и честны, а также что разные части домена согласованы между собой и нет данных, которые бы противоречили друг другу.
Целостность простых значений
В домене редко бывают неограниченные строки, числа и т. д. Чаще всего у бизнеса есть какие-то валидные диапазоны для значений. Мы хотим использовать эти диапазоны так, чтобы невалидное значение было просто невозможно создать.
В F# для этого можно использовать умный конструктор. Вначале мы делаем конструктор приватным, а затем добавляем собственный, который будет валидировать данные:
В TypeScript автоматизировать это сложнее, но можно договориться не создавать типы руками. Вместо этого использовать механизмы с предварительной валидацией, например, фабрики:
Единицы измерения
В F# можно использовать единицы измерения, чтобы тегать типы:
В TypeScript тоже при желании можно научиться тегать типы, но опять же это будет не так надёжно и удобно.
Отображаем бизнес-требования через систему типов
Одно из главных правил моделирования домена звучит как:
Сделайте невалидные состояния данных невозможными, непредставимыми
То есть постарайтесь построить модель типов таким образом, чтобы невалидные данные было невозможно представить в этих типах.
Проще всего понять это на примере. Допустим, мы хотим написать модуль для подтверждения и восстановления аккаунтов через почту. У нас есть два варианта пользовательских почт:
На подтверждённые адреса мы хотим отправлять ссылки на восстановление аккаунта и не хотим отправлять ссылки на подтверждение почты. И наоборот — мы не хотим отправлять ссылки на восстановление на неподтверждённые адреса, а только — ссылки на подтверждение почты.
Одним из вариантов решения мог бы быть такой тип:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Но флаг IsVerified — это очень ненадёжное решение. Главным образом потому что из описания неясно, когда он должен быть включён, а когда — выключен. Но также потому что правила для его переключения будут находиться в рантайме, а значит, их можно пропустить, забыть, не заметить и написать код, который приведёт данные в невалидное состояние.
Вместо этого лучше описать ограничение прямо в типе:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Теперь видно, что «просто так» создать подтверждённый адрес почты — нельзя. Если мы создаём новый адрес, то он будет по умолчанию неподтверждённый. Такая система типов может заменить часть рантайм юнит-тестов. (Ну. по крайней мере в F#.)
Согласованность
Согласованность — это больше бизнесовый термин, чем технический, потому что как и какие именно данные должны быть согласованы, зависит от нужд бизнеса.
Согласованность внутри одного агрегата обеспечить проще всего, достаточно посчитать все зависимые данные из исходного источника. (Как в примере с общей суммой — достаточно посчитать её из списка товаров.) Если дополнительные данные надо сохранять, то тогда, конечно, перед сохранением следует дополнительно убедиться, что данные согласованы.
На примере изменения пункта в заказе это могло бы выглядеть так:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Здесь заказ — это агрегат. Мы намеренно считаем и обновляем данные на уровне заказа (агрегата), потому что только ему известно, как согласовать общую цену из списка товаров. Если мы будем сохранять эти данные, то сохранять заказ и пункты списка нужно будет в одной транзакции.
Согласованность между разными контекстами обеспечить сложнее, но это не всегда нужно. В большей части случаев достаточно обеспечить конечную согласованность (eventual consistency) и настроить общение через сообщения.
Если сообщение теряется, то есть три стула:
Если мгновенная согласованность — это требование, то можно посмотреть в сторону 2-фазного комита (2 Phase Commit) и прочих занимательных вещей.
Согласованность между разными агрегатами внутри одного контекста сильно зависит от требований. Общее правило — за одну транзакцию обновляйте один агрегат, но обеспечьте конечную согласованность между агрегатами.
Иногда, правда, нужно обновить два агрегата в одной транзакции — например, перевести деньги с одного банковского аккаунта на другой. Но чаще всего это можно передизайнить, чтобы из:
…Получилась отдельная транзакция, в которой бы разные агрегаты уже не были напрямую ответственны за обновление данных:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Глава 7. Моделируем процессы как пайплайны
В этой главе мы попробуем применить систему типов для описания приёма заказов. Сейчас процесс состоит из нескольких шагов:
Мы представим эти шаги как части большого пайплайна — процесса в целом. Каждый шаг будет как-то трансформировать входные данные. Мы постараемся сделать каждый шаг без состояния и побочных эффектов (stateless).
Входные данные процесса
Входными данными должен быть доменный объект. Мы посчитаем, что такие объекты мы достаём из десериализованных DTO и будем просто держать это в уме.
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Но мы помним, что по-настоящему процесс запускает на сам объект, а команда. Как правило, в команды мы часто добавляем дополнительные данные типа пользователя или таймштампа. В нашем случае мы можем представить это так:
Команд может быть много, поэтому сделаем общий дженерик-тип команды, который заберёт на себя описание дополнительных данных:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
А специфичную команду для конкретного процесса мы сможем создать, передав тип-параметр:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Моделирование заказа как набора состояний
Из интервью с экспертами нам становится понятно, что «Заказ» (Order) — это не статический документ, а скорее набор данных, которые проходят через разные трансформации, то есть пребывают в разных состояниях.
Смоделировать все эти состояния можно разными путями. Наивный путь — это просто создать кучу флагов для разных состояний:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Но у него куча минусов:
Другой способ — использовать отдельные типы для каждого состояния.
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Плюс такого подхода в том, что можно добавить ещё одно состояние без особых усилий. Достаточно добавить новый тип в юнион.
Верхнеуровневый тип Order отражает заказ в любой момент его жизненного цикла, а отдельные типы — в отдельных состояниях. Тип Order можно использовать для хранения или передачи между контекстами.
Автоматы (State Machines)
Юнион из примера похож на автоматы — модель переходов между разными состояниями. Автоматное программирование на самом деле вещь хорошая, потому что оно побуждает:
О применении конечных автоматов во фронтенде я тоже когда-то писал 🙃
Моделирование шагов процесса с помощью типов
Первым делом опишем валидацию. Мы помним, что она принимает невалидированный заказ, а также ссылается на две «зависимости». Эти зависимости мы тоже можем сперва описать типами, которые станут «интерфейсом» для реализации позже. Тогда получится нечто вроде:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Весь шаг валидации тогда мы опишем, как такую сигнатуру:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Обратите внимание, что зависимости мы располагаем в начале, перед аргументом — это нам нужно для частичного применения аргументов в будущем. Это будет функциональным аналогом внедрения зависимостей.
На самом деле в ФП никакого внедрения быть не может, об этом у себя в блоге писал Марк Зиманн. Но автор в книжке часто употребляет этот термин в скобках, чтобы провести параллели и выстроить ассоциации.
Описание остальных шагов я предлагаю вам прочесть в оригинале, здесь же мы перейдём сразу к возвращаемым событиям :–)
Нам нужны события OrderPlaced для отправки и BillableOrderPlaced для биллинга. Для первого мы можем использовать алиас над уже имеющимся типом, а второй создадим с нуля:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Возможно, нам понадобится добавить каких-то ещё событий, поэтому пусть процесс возвращает список событий, типом которых будет:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Так мы сделаем код расширяемым, при появлении нового события нам не придётся менять весь код процесса.
Документируем эффекты
Части процесса могут вызывать эффекты. Например, в валидации у нас есть вызов стороннего сервиса, это тоже стоит обозначить в типах:
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Фрагмент кода на F#. Показать на других языках: F# TypeScript
Теперь мы видим, что шаг валидации не только может отдать ошибку, но ещё и будет работать асинхронно Мы можем использовать это знание при проектировании и работе с требованиями: мы можем решить, достаточно ли мы доверяем сторонним сервисам чтобы использовать их, или нам стоит написать валидацию с нуля.
Может возникнуть вопрос, а надо ли показывать зависимости в типах? Правильного ответа нет, но автор предлагает следовать таким правилам:
Это помогает задокументировать, что каждый шаг действительно требует для своей работы и что он действительно вернёт.
В других частях
В этот раз мы поговорили о том, как спроектировать флоу программы в функциональном стиле, в чём отличие от ООП, как типы могут помогать документировать и отражать бизнес-требования. В следующей части мы пробежим по шагам реализации доменной модели, познакомимся с функциональной композицией, частичным применением и, ух, монадами.
Domain Modeling Made Functional
Tackle Software Complexity with Domain-Driven Design and F#
by Scott Wlaschin
You want increased customer satisfaction, faster development cycles, and less wasted work. Domain-driven design (DDD) combined with functional programming is the innovative combo that will get you there. In this pragmatic, down-to-earth guide, you’ll see how applying the core principles of functional programming can result in software designs that model real-world requirements both elegantly and concisely—often more so than an object-oriented approach. Practical examples in the open-source F# functional language, and examples from familiar business domains, show you how to apply these techniques to build software that is business-focused, flexible, and high quality.
eBook Formats:
PDF for desktop/tablets
epub for Apple Books, e-readers
mobi for Kindle readers
Get all eBook formats here for $27.95 (USD)
Paperback Formats:
Domain-driven design is a well-established approach to designing software that ensures that domain experts and developers work together effectively to create high-quality software. This book is the first to combine DDD with techniques from statically typed functional programming. This book is perfect for newcomers to DDD or functional programming—all the techniques you need will be introduced and explained.
Model a complex domain accurately using the F# type system, creating compilable code that is also readable documentation—ensuring that the code and design never get out of sync. Encode business rules in the design so that you have “compile-time unit tests,” and eliminate many potential bugs by making illegal states unrepresentable. Assemble a series of small, testable functions into a complete use case, and compose these individual scenarios into a large-scale design. Discover why the combination of functional programming and DDD leads naturally to service-oriented and hexagonal architectures. Finally, create a functional domain model that works with traditional databases, NoSQL, and event stores, and safely expose your domain via a website or API.
Solve real problems by focusing on real-world requirements for your software.
What You Need
Resources
Contents & Extracts
Агрегаты
Следить за обновлениями блога можно в моём канале: Эргономичный код
Введение
Я считаю, что именно агрегаты из Domain-Driven Design лежат в основе поддерживаемых информационных систем. Однако эта концепция малоизвестна за пределами DDD-сообщества и довольно сложна для понимания, поэтому я решил написать очередной пост посвящённый агрегатам. В основном для чтобы структурировать собственное понимание агрегатов и создать «методичку» для своих команд, но и широкой общественности, я надеюсь, этот пост тоже может быть полезен.
Что такое агрегат? (TLDR)
Для того чтобы обеспечить соблюдение инвариантов, агрегат должен удовлетворять следующим требованиям:
Выступать единицей персистанса (все сущности всегда загружаются и сохраняются вместе). «Точкой входа» персистанса (загружаемым и сохраняемым объектом) является корень агрегата.
Все модификации состояния агрегата должны осуществляться через корень.
Все сущности должны входить только в один агрегат.
Например, отчёт с непересекающимися отчётными периодами и составителем моделируется двумя агрегатами, которые на Котлине будут выглядеть так:
Почему агрегата именно два, а не один или три? Ответ на этот вопрос лежит в принципах декомпозиции модели информации системы.
Принципы декомпозиции модели информации на агрегаты
При проектировании агрегатов (как и всех других элементов ПО) следует руководствоваться принципом высокой связанности/низкой сцепленности. В случае агрегатов этот принцип выражается в соблюдении следующих ограничений:
Агрегаты не должны иметь циклических связей.
Агрегаты должны определять область жизни всех сущностей, в них входящих. Эта область определяется областью жизни корня агрегата. Некорневые сущности не могут появляться раньше корня и продолжать существовать после его удаления.
Агрегаты должны обеспечивать соблюдение инвариантов. Агрегаты предоставляют такое API, которое не позволит клиенту перевести модель в невалидное состояние.
Агрегаты должны быть минимального необходимого размера. Имеется в виду и количество типов сущностей в агрегате, и количество экземпляров сущностей и их размер в байтах.
Агрегаты должны храниться целиком в одной системе хранения данных на одном узле. Разные агрегаты одной системы могут храниться на разных узлах или в разных хранилищах.
Агрегаты могут ссылаться на другие агрегаты только через идентификаторы корней. Внутри агрегата сущности могут свободно ссылаться друг на друга.
Так вот, почему агрегатов всё-таки именно два? Потому что отчёты и составители ценны сами по себе и имеют независимые жизненные циклы. А периоды не имеют смысла без отчёта и инвариант отсутствия пересечения определяется на кластере объектов отчёта и его отчётных периодов.
Методика декомпозиции модели информации на агрегаты
Я предпочитаю идти от обратного и на первом этапе считать каждую сущность отдельным агрегатом, а потом искать причины для объединения сущностей в агрегаты. Поэтому первой версией разбиения информации на агрегаты является сама ER-диаграмма.
Получив список инвариантов, я выбираю те, что затрагивают несколько типов или экземпляров сущностей. Сущности, которые участвуют в обеспечении одного инварианта, объединяю в агрегаты. Если речь идёт о разных типах, то в агрегат я объеднияю сами эти сущности. Если речь идёт о разных экземплярах одной сущности, то я присоединяю их списком к одной из существующих или специально созданной для этого сущности.
Затем я проверяю получившиеся агрегаты на соответствие принципам.
Принцип акцикличных агрегатов я сейчас нарушаю крайне редко, а нарушения сразу же видны на ER-диаграмме. При разбиении циклов я пользуюсь принципом стабильных зависимостей и удаляю ссылку из более «стабильного» агрегата. Стабильность определяется по значимости для бизнеса, вероятности изменений в будущем и количеству входящих связей. Значимость для бизнеса и вероятность изменений определяются посредством гадания на кофейной гуще.
Что такое диаграмма эффектов?
Чтобы проверить принцип изменения одного агрегата в одной транзакции, я строю диаграмму эффектов для того чтобы увидеть операции, которые меняют несколько агрегатов. С такими агрегатами можно поступить по-разному:
В первую очередь стоит посмотреть на вариант с использованием шины событий. В этом случае в первой транзакции остаётся изменение первого агрегата и генерация события, а в изменения остальных агрегатов уходят в транзакции обработчиков события.
Если разбиение через события приводит к появлению каскада событий, то можно просто разбить операцию на несколько транзакций.
Если выполнять декомпозицию по описанной выше методике, то агрегаты с большим количеством видов сущностей у меня ни разу не появлялись. Поэтому для проверки принципа малых агрегатов остаётся удостоверится в отсутствии «больших» атрибутов и связей «один к действительно многому».
Для проверки всех остальных принципов у меня нет устоявшихся инструментария и эвристик и их нарушение я ищу «методом вдумчивого взгляда».
Процесс «проверить-подрихтовать-обновить диаграммы» я повторяю до тех пор, пока не получу результат, проходящий проверку.
Частые ошибки проектирования агрегатов
Моделирование лишних связей
Но и в контексте проектирования агрегатов можно внести в модель лишние связи. Чаще всего причинами внесения лишних связей являются:
Но напомню, что единственной причиной добавления ссылки на объект является вхождение объекта в агрегат, а единственной причиной включения объекта в агрегат является его участие в обеспечении инварианта. Поэтому если связь не требуется для обеспечения инварианта, то её включение необходимо дважды обдумать. Потому что лишние связи ведут к повышению сцепленности дизайна и как следствие усложнению системы и деградации производительности.
Анемичная доменная модель
Ещё одной распространённой ошибкой является анемичная доменная модель. Анемичная доменная модель характеризуется в первую очередь сущностями, у которых все свойства доступны для чтения и записи через геттеры и сеттеры. При этом всё поведение сущности ограничивается геттерами и сеттерами. Эта ошибка ведёт к утери возможности обеспечить соблюдение инвариантов.
Кроме того, последствием анемичной модели становится погребение существенных для агрегата трансформаций в методах сервисов приложения. Что влечёт за собой жёсткую сцепку трансформаций и ввода-вывода. Из-за чего:
Усложняется задача тестирования трансформаций.
Снижается переиспользуемость трансформаций.
Усложняется задача понимания кода из-за смешения разных уровней абстракции в сервисе приложения.
Давайте сравним решения одной и той же задачи с помощью анемичной и «полнокровной» доменных моделей.
Требования к системе следующие:
В каждый момент времени для каждого символа пользователя может быть активен только один из гридов символа.
Гриды уникально идентифицируются своим именем.
Статистика может меняться только у активного грида.
Каждый пользователь может вести торги одновременно по нулю и более символов.
Так же есть ограничение на API системы: обновление информации осуществляется посредством отправки клиентом списка активных в данный момент пар и их гридов.
Реализация этой задачи с анемичной доменной моделью будет выглядеть примерно так:
Также здесь в одном методе смешаны и работа с БД (1) и бизнес-правила (2).
Для того чтобы защитить инварианты, необходимо большую часть логики перенести в доменную модель. Также необходимо исключить возможность неконтролируемых операций записи.
Если оставаться в парадигме изменяемой модели данных, то это можно сделать путём сокращения области видимости сеттеров до внутренней ( internal ) в случае Котлина. Но тогда придётся выделять агрегаты в разные модули, что очень не удобно.
В том числе (но не только) по этому, я рекомендую пойти простым путём: сделать сущности неизменяемыми, с закрытым конструктором и опубликованным фабричным методом вместо него, который будет гарантировать соблюдение инвариантов.
Как программировать связи?
Как защитить инварианты?
Для того чтобы гарантировать сохранность своих инвариантов, агрегат должен не позволять внешним клиентам менять состояние напрямую. Для достижения этого необходимо следовать принципу «Tell Don’t Ask». В случае агрегатов это означает предоставление корнем агрегата API внесения изменений вместо API получения изменяемых объектов внутренних сущностей.
При этом для получения информации об агрегате есть несколько подходов:
Использовать неизменяемые классы для моделирования сущностей агрегатов. Объекты таких классов можно безопасно передавать клиентам, поэтому агрегат может предоставить прямой доступ к своим частям.
Плюсы: минимум дополнительного кода, хорошо масштабируется по количеству методов запроса информации.
Минусы: повышает сцепленность между клиентами и агрегатом.
Предоставлять API в том числе для получения информации только на уровне корня агрегата. В этом случае внутренние сущности вообще не попадают в публичное API агрегата.
Плюсы: полностью скрывает устройство агрегата и минимизирует связанность между клиентами и агрегатом.
Минусы: плохо масштабируется по количеству методов запроса информации.
Использовать копии изменяемых объектов. Этот подход похож на первый, тем что даёт клиентам доступ к частям агрегата, но клиентам выдаются не сами объекты частей, а их копии.
Плюсы: может быть использован в случае, когда нет возможности сделать объекты неизменяемыми.
Минусы: те же, что и у первого подхода, и необходимость в дополнительном коде копирования объектов в каждом геттере и, как следствие, большей нагрузки на сборщика мусора.
Использовать «read-only» представления. Похож на третий подход, но вместо копий предполагается возвращать «read-only» представления изменяемых сущностей.
Плюсы: нет необходимости в коде копирования объектов и снижение нагрузки на сборщика мусора.
Я сам использую преимущественно первый подход, подключая второй в случаях, когда вижу необходимость в сокрытии структуры агрегата.
Как реализовать выборку данных для UI?
Существует несколько походов, и у каждого из них свои плюсы и минусы.
Сборка DTO из агрегатов. Заключается в том, чтобы вытащить нужные агрегаты из репозиториев и собрать из них DTO.
Отдельные модели для записи и чтения. В дополнение к модели для записи (агрегаты), создаётся дополнительная денормализованная модель для чтения.
Сборка DTO в СУБД. Современные СУБД (PostgreSQL, в частности) имеют встроенные средства для формирования JSON и позволяют собрать финальную DTO непосредственно SQL-запросом.
Варианты 1-3 подробно рассмотрены в книгах по DDD, вариант 4 хорошо описан в посте Лукаса Едера Stop Mapping Stuff in Your Middleware. Use SQL’s XML or JSON Operators Instead
Я сейчас в качестве варианта по умолчанию использую первый, а третий или четвёртый задействую в «горячем» коде. Второй вариант я пока что ни разу не использовал.
Зачем объединять сущности в агрегаты?
Почему агрегаты должны быть маленькими?
Из соображений производительности. Так как агрегаты являются единицей персистанса, большие агрегаты приведут к передаче больших объёмов данных по сети. И так как агрегаты являются единицей согласованности, большие агрегаты приведут к «большим» транзакциям (по количеству затронутых объектов и длительности), что повлечёт за собой большое количество конфликтующих транзакций. Это, в свою очередь, станет причиной либо ошибкам согласованности, либо большим накладным расходам на синхронизацию транзакций.
Когда не стоит объединять сущности в агрегаты?
Когда можно включать в агрегат много видов сущностей?
Агрегат может включать много видов сущностей, при соблюдении двух условий:
Почему в транзакции можно менять только один агрегат?
В-третьих, агрегаты могут храниться на разных машинах. А по определению агрегата это значит, что придётся иметь дело с распределёнными транзакциями. С которыми я бы предпочёл иметь дело в последнюю очередь.
Как обеспечить выполнение принципа «модификация одного агрегата в одной транзакции»?
В первую очередь, необходимо понять действительно ли эти модификации
должны быть строго согласованы, или можно обойтись согласованностью в
конечном итоге. Для этого автор Implementing Domain-Driven Design предлагает следующий алгоритм:
«Закрыть» этот неудобный инвариант и перейти к согласованности в конечном итоге.
Убрать из агрегата «лишние» сущности, которые были включены в него по причинам отличным от обеспечения инварианта.
Разбить большой агрегат, новым способом, который обеспечит соблюдение всех инвариантов. Возможно для этого придётся отказаться от некоторых инвариантов.
Если же модификации могут быть согласованными в конечном итоге, то операцию необходимо разбить на две. Для этого надо разбить код на два транзакционных метода в слое сервисов приложения. Затем либо оба этих метода публикуются для клиентов, либо они связываются через публикацию доменного события первым методом и его обработку вторым.
Заключение
Clustering Entities (5) and Value Objects (6) into an Aggregate with a carefully crafted consistency boundary may at first seem like quick work, but among all DDD tactical guidance, this pattern is one of the least well understood.
— Vaughn Vernon, Implementing Domain-Driven Design
и её невозможно полностью понять, прочитав один пост.
Но я постарался собрать в этом посте необходимый минимум информации для того, чтобы спроектировать первый агрегат.
Domain Driven Design на практике
Эванс написал хорошую книжку с хорошими идеями. Но этим идеям не хватает методологической основы. Опытным разработчикам и архитекторам на интуитивном уровне понятно, что надо быть как можно ближе к предметной области заказчика, что с заказчиком надо разговаривать. Но не понятно как оценить проект на соответствие Ubiquitous Language и реального языка заказчика? Как понять, что домен разделен на Bounded Context правильно? Как вообще определить используется DDD в проекте или нет?
Последний пункт особенно актуален. На одном из своих выступлений Грег Янг попросил поднять руки тех, кто практиукует DDD. А потом попросил опустить тех, кто создает классы с набором публичных геттеров и сеттеров, располагает логику в «сервисах» и «хелперах» и называет это DDD. По залу прошел смешок:)
Как же правильно структурировать бизнес-логику в DDD-стиле? Где хранить «поведение»: в сервисах, сущностях, extension-методах или везде по чуть-чуть? В статье я расскажу о том, как проектирую предметную область и какими правилами пользуюсь.
Все люди лгут
Не специально конечно:) Дело в том, что бизнес-приложения создаются для широкого спектра задач и удовлетоврения интересов различных групп пользователей. Бизнес-процессы от начала до конца в лучшем случае понимает только топ-менеджмент. Не редко понимает неверно, кстати. Внутри подразделенеий пользователи видят только некоторую часть. Поэтому результатом интервьюирования всех заинтересованных сторон обычно становится клубок противоречий. Из этого правила вытекает следующее.
Сначала аналитика, потом проектирование и лишь затем — разработка
Начинать нужно не со структуры БД или набора классов, а с бизнес-процессов. Мы используем BPMN и UML Activity в сочетнии с контрольными примерами. Диаграммы хорошо читаются даже теми, кто не знаком со стандартами. Контрольные примеры в табличной форме помогают лучше обозначить пограничные кейсы и устранить противоречия.
Абстрактные разговоры — просто потеря времени. Люди убеждены, что детали не значительны и «незачем вообще их обсуждать, ведь все уже ясно». Просьба заполнить таблицу контрольных примеров наглядно показывает, что вариантов на самом деле не 3 а 26 (это не преувеличение, а результат аналитики на одном из наших проектов).
Таблицы и диаграммы — основной инструмент коммуникации между бизнесом, аналитикой и разработкой. Параллельно составлению BPMN — диаграмм и таблиц контрольных примеров начинаем записывать термины в тезаурус проекта. Словарь поможет позже для проектирования сущностей.
Выделяем контексты
Единую предметную модель для всего приложения можно создать только в случае, когда на уровне топ-менеджмента принята и реализована политика использования единого непротиворечивого языка в рамках всей организации. Т.е. когда отдел продаж говорит производству «аккаунт», они оба понимают слово одинаково. Это один и тот же аккаунт, а не «аккаунт в CRM» и «юр.лицо клиента».
В реальной жизни я такого не видел. Поэтому желательно сразу грубо «нарезать» предметную модель на несколько частей. Чем меньше они связаны, тем лучше. Обычно все-таки получается нащупать некоторый набор общих терминов. Я называю это ядром предметной области. Любой контекст может зависеть от ядра. При этом крайне желательно избегать зависимостей между контекстами. Потенциально такой подход приводит к «распуханию» ядра, однако взаимная зависимость контекстов порождает сильную связность, что хуже «толстого» ядра.
Архитектура
Структура проекта
.NET-проекты я структурирую следущим образом:
Моделируем сущности
Под сущностью будем понимать объект предметной области, обладающий уникальным идентификатором. Для примера возьмем класс, описывающий российскую компанию, в контексте получения аккредитации в неком ведомстве.
Чтобы правильно выбрать агрегаты и отношения зачастую одной итерации недостаточно. Сначала я накидываю основную структуру классов, определяю отношения один к одному, один ко многим и многие ко многим и описываю структуру данных. Затем трассирую структуру по бизнес процессам, сверяясь с BMPN и контрольными примерами. Если какой-то кейс не укладывается в структуру, значит при проектировании допущена ошибка и структуру необходимо изменить. Результирующую структуру можно оформить в виде диаграммы и дополнительно согласовать с экспертами в предметной области.
Эксперты могут указать на ошибки и неточности проектирования. Иногда в процессе выясняется, что для некоторых сущностей нет подходящего термина. Тогда я предлагаю варианты и через некоторое время находится подходящий. Новый термин вносится в тезаурус. Очень важно обсуждать и договариваться о терминологии совместно. Это исключает большой пласт проблем непонимания в будущем.
Выбор уникального идентификатора
Настоящие конструкторы
Для материализации объектов ORM чаще всего используют reflection. EF сможет дотянуться до protected-конструктора, а программисты – нет. Им придется создать корректное юр. лицо, идентифицируемое по ИНН и КПП. Конструктор снабжен гардами. Создать не корректный объект просто не получится. Extension-метод ValidateProperties вызывает валидацию по DataAnnotation — атрибутам, а NullIfEmpty не дает передать пустые строки.
Для валидации ИНН специально написан атрибут следующего вида:
Конструктора без параметров объявлен защищенным, чтобы его использовала только для ORM. Для материализации используется reflection, поэтому модификатор доступа — не помеха. В «настоящий» конструктор переданы оба необходимых поля: ИНН и КПП. Остальные поля юр.лица в контексте системы не обязательные и заполняются представителем компании позже.
Инкапсуляция и валидация
Можно еще больше усилить систему типов. Однако в описанном по ссылке подходе есть существенный недостаток: отсутствие поддержки со стороны стандартной инфраструктуры ASP.NET. Поддержку можно дописать, но такой инфраструктурный код чего-то стоит и его нужно сопровождать.
Свойства для чтения, специализированные методы для изменения
Альтернативный вариант — использовать паттерн «состояние» и вынести поведение в отдельные классы.
Спецификации
Некоторое время было не ясно, что лучше писать extension’ы модифицирующие Queryable или возиться с деревьями выражений. В конечном итоге, реализация LinqSpecs оказалась самой удобной.
Extension-методы
Ad hoc полиморфизм для интерфейсов (чтобы не приходилось реализовывать методы в каждом наследнике) рано или поздно появится в C#. Пока приходится довольствоваться extension-методами.
Extension-методы подходят для использования в LINQ для большей выразительности. Однако, методы ByInnAndKpp и ByInn нельзя использовать внутри других выражений. Их не сможет разобрать провайдер. Более подробно про использование extension-методов а-ля DSL рассказал Дино Эспозито на одном из DotNext.
Вопрос эквивалентности вариантов с Select и SelectMany с точки зрения IQueryProvider я еще до конца не изучил. Буду благодарен любой информации на эту тему в комментариях.
Связанные коллекции
Желательно использовать только в блоке Select для преобразования в SQL-запрос, потому что код вида company.Documents.Where(…).ToList() не построит запрос к БД, а сначала поднимет в оперативную память все связанные сущности, а потому применит Where к выборке в памяти. Таким образом, наличие коллекций в модели может крайней негативно отразиться на производительности приложения. При этом рефакторинг будет произвести сложно, потому что придется передавать необходимые IQueryable из вне. Чтобы контролировать качество запросов нужно поглядывать в miniProfiler.
Сервисы (Service)
Менеджеры (Manager)
TPT для union-type
Иногда одна сщуность может быть связана с одной из нескольких других. Для создания непротиворечивой системы хранения можно использовать TPT, а для control flow — pattern matching. Этот подход подробно описан в отдельной статье.
Queryable Extensions для проекций в DTO
CQRS для отдельных подсистем
При работе в условиях высокой неопределенности риск ошибки проектирования также велик. Прежде чем проектировать структуру БД, принимать решения о денормализации или писать хранимые процедуры есть смысл прибегнуть к быстрому макетированию и проверить гипотезы. Когда есть уверенность: что на входе, а что на выходе можно заняться оптимизацией.
Подход ограничено-применим для очень-очень нагруженных ресурсов из-за накладных расходов на IOC-контейнер и memory traffic для per request lifestyle. Однако, все IQuery можно сделать singleton’ами, если не инжектировать зависимости от БД в конструктор, а вместо этого использовать конструкцию using.
Работа с унаследованным кодом
При работе с существующей кодовой базой следует определиться с форматом работы: «поддержка» или «развитие». В первом случае не предполагается появление новой функциональности и доработка системы. Максимум — добавить несколько новых отчетов, пару форм тут и там. Во втором — есть необходимость значительной переработки предметной модели и / или архитектуры в целом. Если проект необходимо именно «поддерживать», а не «развивать», лучше следовать существующим правилам, независимо от того на сколько они удачные. Если перед вами откровенный говнокод, от предложения посупортить его лучше отказаться.
Развитие проекта — задача более сложная. Тема рефакторинга выходит за рамки данной статьи. Отмечу лишь два самых полезных паттерна: «антикоррупционный слой» и «душитель». Они очень похожи. Основная идея — выстроить «фасад» между старой и новой кодовыми базами и постепенно есть слона переписывать всю систему по кусочкам. Фасад берет на себя роль барьера, не позволяющего проблемам старой кодовой базы просочиться в новую и обеспечивающего отображение старой бизнес-логики в новую. Будьте готовы, что фасад будет состоять из сплошь из хаков, уловок и костылей и рано или поздно канет в лету вместе со всей старой кодовой базой.