Инструменты Domain Driven Design

Синий кит — отличный пример того, как проектирование сложного проекта пошло не по плану. Кит внешне похож на рыбу, но он млекопитающее: кормит детенышей молоком, у него есть шерсть, а в плавниках до сих пор сохранились кости предплечья и кистей с пальцами, как у сухопутных. Он живет в океанах, но не может дышать под водой, поэтому регулярно поднимается на поверхность глотнуть воздуха, даже когда спит. Кит самое большое животное в мире, длиной с девятиэтажный дом, а массой как 75 автомобилей Volkswagen Touareg, но при этом не хищник, а питается планктоном.

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

to9eqb8yxhrfep8tl32q9augiv4.jpeg

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

Что такое DDD и какие инструменты в нем есть, мы расскажем в статье на основе доклада Артема Малышева. Подход DDD в Python, инструменты, подводные камни, контрактное программирование и проектирование продукта вокруг решаемой проблемы, а не используемого фреймворка — все это под катом.

Полная презентация доклада.

Артем Малышев (proofit404) — независимый разработчик, пишет на Python 5 лет, активно помогал с Django Channels 1.0. Позже сфокусировался на архитектурных подходах: изучил, какого инструментария не хватает архитекторам на Python, и начал проект dry-python. Сооснователь компании Drylabs.

Сложность


Что такое программирование?

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

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

Хорошие инженеры должны уменьшать привнесенную сложность и увеличивать естественную, чтобы повышать полезность продукта.

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

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

Еще один пример привнесенной сложности — мой любимый «callback hell».

pa1hs5z7giwwav8wxyvbllgqdaw.png

Когда мы пишем в рамках событийно-ориентированной архитектуры (EDA) и выбираем не самый хороший современный фреймворк, то получаем код, в котором непонятно, что и когда происходит. Читать такой код тяжело — это опять привнесенная сложность.

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

  • AsyncIO или Gevent;
  • PostgreSQL или MongoDB;
  • Python или Go;
  • Emacs или Vim;
  • табы или пробелы;


Правильный ответ хорошего программиста на все эти вопросы: «Без разницы!» Хорошие разработчики не спорят из-за сферических коней в вакууме, а решают проблемы бизнеса и работают над полезностью продукта. Некоторые из них уже давно создали набор практик, которые уменьшают привнесенную сложность и помогают больше думать о бизнесе.

Один из них — Эрик Эванс. В 2004 году он написал книгу «Domain Driven Design» («Предметно-ориентированное проектирование»). Она «выстрелила» и дала импульс больше думать о бизнесе, а технические детали отодвинуть на второй план.

v64pkrz6lrqylss53fxhvojvock.jpeg

Что такое DDD?


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

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

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

Говорите с бизнесом на одном языке. DDD — это философия о том, как с экспертами из бизнеса говорить на одном языке в рамках определенной области и к этой области применять терминологию. У нас появляется общий язык или диалект в рамках связанного контекста, который мы считаем истиной. Мы создаем границы вокруг архитектурных решений.

DDD не о технологиях.


Сначала техническая часть, потом — DDD. Скульптор, который высекает статую из камня не читает мануал о том, как держать молоток и долото — он уже знает, как ими работать. Чтобы привнести DDD в ваш проект, освойте техническую часть: выучите до конца Django, прочитайте туториал и перестаньте спорить, что брать — PostgreSQL или MongoDB.

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

Первая «синяя» книга вышла почти 20 лет назад. Люди пытались писать в этом стиле, ходили по граблям, и поняли, что философия хорошая, но на практике непонятная. Поэтому появилась вторая книга — «красная», именно о том, как программистам мыслить и писать в DDD.

j3nfdd-vnh3shjuuxvxrh7ogwj8.jpeg
«Красная» и «синяя» книги — это столпы, на которых стоит все DDD.

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

В красной книге проскакивает идея, как лучше всего DDD привносить в проект, как структурировать работу вокруг этого подхода. Появляется новая терминология — «Model-Driven Design», в котором на первое место ставится наша модель внешнего мира.

bqoj653kebtxlaj7fkvbcx8xhq8.jpeg

Единственное место, где выбираются технологии — «Smart UI». Это прослойка между внешним миром, пользователем и нами (отсылка к Роберту Мартину и его чистой архитектуре со слоями). Как видим, все идет к модели.

Что такое модель? Это фантомная боль любого архитектора. Все думают, что это UML, но это не так.

Модель — это набор классов, методов и ссылок между ними, которые отражают бизнес-сценарии в программе.

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

Dry-python


Чтобы заполнить нишу моделей, я начал проект dry-python, который вырос в набор библиотек высокоуровневых архитектурных решений для построения Model Driven Design. Каждая из библиотек пытается закрыть один круг в архитектуре и не мешает другим. Библиотеки можно использовать отдельно, а можно вместе, если войти во вкус.

unkbampyzrvkbwwjwd4amsiahuw.jpeg

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

Stories


Бизнес-сценарии делятся на три части:

  • спецификация — описание бизнес-процесса;
  • состояние, в котором может находиться бизнес-сценарий;
  • реализация каждого шага сценария.


Эти части нельзя смешивать. Библиотека Stories разделяет эти части и проводит между ними четкую границу.

Рассмотрим внедрение DDD и Stories на примере. Например, у нас есть проект на Django с мешаниной из Django-сигналов и непонятных «толстых» моделей. Добавим в него пустой пакет services. С помощью библиотеки Stories по частям перепишем эту мешанину в ясный и понятный набор сценариев в нашем проекте.
lor9stle7ok2tx_eiv4nx931abs.png
Спецификация DSL. Библиотека позволяет писать спецификацию и предоставляет для этого DSL. Это способ пошагово описать действия пользователя. Например, чтобы купить subscription, я выполняю несколько шагов: найду заказ, уточню актуальность цены, проверю, может ли позволить себе это пользователь. Это высокоуровневое описание.
pfuyrkvzoktrxxjurabhbzfwmka.png
Контракт. Ниже этого класса напишем контракт на состояние бизнес-сценария. Для этого обозначим область переменных, которые возникают в бизнес-процессе, и за каждой переменной закрепим набор валидаторов.
e0qydendpyyrti651302r_6dxnm.png
Как только кто-нибудь попробует в рамках бизнес-процесса назначить какую-то переменную в эту область — будет отработан набор валидаторов. Мы будем уверены, что состояние процесса во время выполнения всегда рабочее. Но если нет, оно больно падает и громко об этом кричит.

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

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


nype_-46uz-nuannwmrcfsrhojq.png
Есть маркеры сложнее: они могут подтверждать, что состояние рабочее, предлагать удалить или изменить какие-то части бизнес процесса. Также можно писать и в классах.

Запускаем Story. Как запустить Story на выполнение? Это бизнес-объект, который работает как метод: передаем на вход данные, он их валидирует, интерпретирует шаги. Выполняющаяся Story помнит историю исполнения, записывает состояние, которое происходило в ней в бизнес-процессе, и рассказывает нам, кто влиял на это состояние.
o7e7s53zun6l9mprpqg-vkerp-s.png
Панель инструментов отладки. Если пишем на Django и используем панель отладки, можем посмотреть, какие бизнес-сценарии отрабатывались в каждом запросе и их состояния.
i8tdaqss6_wf7uk0e6sdifdqhau.png
Py.test. Если пишем в py.test, то для упавшего теста можем посмотреть, какие бизнес-сценарии выполнялись на каждой строке и что пошло не так. Это удобно — вместо ковыряния в коде, прочитаем спецификацию и поймем, что произошло.

4fprgcpruxp3bbpgcapv7rc44je.png

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

qkdyjspvszlg7vzy803gfqmpt5k.png
ELK. Сейчас мы активно работают над плагином, который пишет это все в Elasticsearch в стек Kibana и строит грамотные индексы.

df7i7dk01va0ekvv7sxlkdhz9pg.png

Например, у нас есть контракт на состояние бизнес-процесса. Мы знаем, что там есть, например, relation ID отчета. Вместо архаичного исследования того, что там когда-то происходило, мы пишем запрос в Kibana. Он покажет все выполненные Story, которые относятся к определенному пользователю. Дальше мы изучаем состояние внутри наших бизнес-процессов и бизнес-сценариев. Мы не пишем ни одной строки кода логирования, но проект логируется именно на том уровне абстракции, на котором нам интересно смотреть.

Но хочется чего-то более высокоуровневого, например, легких объектов. Такие объекты хранят в себе грамотные структуры данных и методы, которые относятся к принятию бизнес-решений, а не к работе с БД, например. Поэтому мы переходим к следующей части Model-Driven архитектуры — entities, agregates и value objects.

ohc4hhk6ru1lrn-ohyrp83byn1w.jpeg

Entities, agregates и value objects


Как все это взаимосвязано? Например, пользователь сделал заказ продукта, и мы выставляем счет. Что здесь корень агрегации, а что — простой объект?

ml4gkcmc0-hajo2sk5apaklct6y.jpeg

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

С чего начать? Создадим в проекте пустой пакет, куда будем складывать наши агрегаты. Агрегаты лучше писать с чем-то декларативным, вроде dataclasses или attrs.
7e0zdxqhapwuerqxbs8zf6wocse.png
Dataclasses. Если какой-то dataclass укажем агрегатом — напишем на него аннотацию с помощью NewType. В аннотации укажем явный референс, который выразим в системе типов. Если же dataclass — просто структура данных (entity), то сохраним внутри агрегата.
9yg3fe7qn2_xj6pas4m1hewhikc.png
В контексте Stories могут лежать только агрегаты. Доступ к чему-то вложенному в них можно получить только через публичные методы и правила высокого уровня. Это позволяет логично и грамотно выстроить модель, над которой мы трудимся вместе с экспертами из предметной области. Это тот самый единый язык.

xjov7shv-7gsu-u0w6c6aejxkq8.jpeg

Сразу же возникает проблема — репозитории. У меня есть база данных, с которой я работаю через Django, соседний микросервис, в который я отправляю запросы, есть JSON и экземпляр Django-модели. Получать данные и переносить их руками, просто чтобы вызвать или проверить метод красиво? Нет, конечно. В dry-python есть библиотека Mappers, которая позволяет сопоставлять высокоуровневые абстракции и доменные агрегаты с местами, где мы их храним.

Mappers


Добавляем еще один пакет к нашему проекту — репозиторий, в котором будем хранить наши mappers. Это то, как высокоуровневую бизнес логику будем переносить на реальный мир.
gtw8zusqmldesc-5m-sanew8hxy.png
Например, мы можем описать то, как мы сопоставляем какой-нибудь dataclass с Django-моделью.

Django ORM. Сопоставляем модель заказа с описанием Django ORM — смотрим поля.
gwcpv8rjs4cs592qvob0tu0t_rw.png
Например, некоторые поля можем переписать через опциональный config. Произойдет следующее: mapper во время декларации будет сравнивать, как написан dataclass и модель. Например, аннотации int (в Order dataclass есть поле cost с аннотацией int) в Django-модели соответствует integer field с опцией nullable="true". Здесь dataclass предложит добавить optional в dataclass, или убрать nullable из field.

Через Mappers можно добавлять функции, которые что-то читают или пишут. Читающие — это функции, которые получают на вход агрегат и возвращают на него референс. Пишущие поступают наоборот — возвращают агрегаты. Под капотом может быть, например, запрос к базе через Django.

Определения Swagger.Те же операции можно проводить и с микросервисами. Можно написать на них часть swagger-схемы и проверить, насколько swagger-схема конкретного сервиса соответствует вашим доменным моделям. Дальше вернувшийся запрос из библиотеки Request будет прозрачно транслирован в dataclass.
sk2krveeieqjqkd5gmcn1hxettc.png
Запросы GraphQL. GraphQL и микросервисы: схема типов интерфейсов GraphQL отлично валидируется против dataclass. Можно транслировать конкретные запросы GraphQL во внутренние структуры данных.
thomj0ho9pgvu-n8_w7uhkvlmz4.png
Зачем вообще так заботиться о внутренней высокоуровневой модели данных внутри приложения? Для иллюстрации «зачем» расскажу «занимательную» историю.

В одном нашем проекте веб-сокеты работали через сервис Pusher. Мы не заморачивались, обернули его в интерфейс, чтобы не вызывать напрямую. Этот интерфейс привязывали во всех Stories и были довольны.

Но бизнес-требования изменились. Оказалось, что гарантий, которые предоставляет Pusher к веб сокетам, недостаточно. Например, нужна гарантированная доставка сообщений и история сообщений за последние 2 минуты. Поэтому мы решили переехать на сервис Ably Realtime. В нем же есть интерфейс — напишем адаптер и привяжем его везде, все будет здорово. На самом деле нет.

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

Вернемся к тестам.

Tests & mocks


Как обычно тестируют такое поведение с внешними сервисами? Что-то мокаем, смотрим, как вызывается сторонняя библиотека, и все — мы уверены, что все хорошо. Но когда библиотека меняется, то форматы аргументов тоже меняются.
zoey341uas00jfbgt0e5nld69ta.png
Можно сэкономить неделю переписывания тысячи тестов и сотни бизнес-кейсов, если тестировать поведение внутренней модели иначе. Например, чем-то похожим на интеграционное тестирование: пишем в поток пользователя, а уже внутри адаптера, Pusher или Ably транслируем этот поток в название нормального канала, чтобы не писать это все в бизнес-логику.

Dependencies


В такой модельной архитектуре появляется много лишних сущностей. Раньше мы брали какую-нибудь Django-функцию и писали ее: запрос, ответ, минимум телодвижений. Здесь надо инициализировать Mappers, положить в Stories и инициализировать, обработать строку запроса HTTP-запроса, посмотреть, какой ответ отдать. Все это выливается в 30–50 строк boilerplate кода вызова Stories внутри Django-view.

С другой стороны у нас уже написаны интерфейсы и Mappers. Мы можем проверить их совместимость с конкретным бизнес-кейсом, например, с помощью библиотеки Dependencies. Как? Через паттерн Dependency injection все декларативно склеим с минимальным boilerplate.
w9_5thkcuryvswlpy39x8dpkoio.png
Здесь мы указываем задачу взять в пакете services класс, поместить в него три mappers, проинициализировать объект Stories и отдать нам. С таким подходом количество boilerplate в коде уменьшается колоссально.

Карта рефакторинга


Применяя все, о чем я говорил, мы разработали схему, по которой переписали большой проект с Django сигналов (неявного «callback hell») на Django по DDD.

Первый шаг без DDD. Сначала у нас не было DDD — мы писали MVP. Когда заработали первые деньги, пригласили инвесторов, и убедили перейти на DDD.

Stories без контрактов. Разбили проект на логичные бизнес-кейсы без контрактов данных.

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

Mappers. Написали Mappers, чтобы избавиться от шаблонов работы с хранилищами данных.

Внедрение зависимостей. Избавились от шаблонов склейки.

Если ваш проект перерос MVP и в нем требуется срочно менять архитектуру, чтобы он не скатился в legacy — посмотрите в сторону DDD.

Какие еще есть способы избежать legacy в Python-проектах, или как с ним бороться, если досталось тяжелое наследство, обсудим на Moscow Python Conf++ 27 марта. Кроме того в расписании конференции доклады о самых разных аспектах работы с Python и об альтернативных вариантах решения привычных задач. А за пределами выступлений у нас unconference, общение с коллегами из профессиональных сообществ, и консультации партнеров, например, как раз Drylabs.

А если DDD интересует вас вне Python, то рекомендую обратить внимание на TechLead Conf — конференцию про процессы и практики разработки качественных IT-продуктов, на которой будет DDD радар. Конференция состоится 8 июня, Call for Papers открыт до 6 апреля.

© Habrahabr.ru