/domain-driven-hexagon

Руководство по предметно-ориентированному проектированию, гексагональной архитектуре, лучшим практикам и т. д.

Primary LanguageTypeScriptMIT LicenseMIT

Предметно-ориентированный шестиугольник

Основной упор в этом проекте сделан на предоставление рекомендаций по разработке программных приложений. В этом файле readme представлены некоторые техники, инструменты, лучшие практики, архитектурные шаблоны и рекомендации, собранные из разных источников.

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

Примеры кода написаны с использованием NodeJS, TypeScript, фреймворка NestJS и Typeorm для доступа к базе данных.

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

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

Архитектура

Основывается на следующих концепциях:

И многие другие источники (дополнительные ссылки ниже в каждой главе).

Прежде чем мы начнем, вот ПЛЮСЫ и МИНУСЫ использования такой полной архитектуры.

Плюсы:

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

Минусы:

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

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

Диаграмма

Предметно-ориентированный шестиугольник Диаграмма в основана на этой и других, найденных в Интернете

Вкратце, поток данных выглядит так (слева направо):

  • Запрос, команда CLI или событие отправляется контроллеру с использованием простого DTO;
  • Контроллер анализирует этот DTO, сопоставляет его с форматом объекта команды или запроса и передает его в прикладную службу;
  • Прикладная служба обрабатывает эту команду или запрос; она исполняет бизнес-логику с использованием доменных служб и / или сущностей и использует инфраструктурный уровень через порты;
  • Инфраструктурный уровень использует преобразователь для преобразования данных в необходимый формат, использует репозитории для извлечения / сохранения данных и адаптеры для отправки событий или выполнения других операций ввода-вывода, преобразовывает данные обратно в доменный формат и возвращает их обратно в прикладную службу;
  • После того, как прикладная служба завершает свою работу, она возвращает данные или подтверждение о выполненных операциях обратно в контроллеры;
  • Контроллеры возвращают данные обратно пользователю (если в приложении есть представления или презентаторы, то возвращаются они).

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

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

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

Подробнее о каждом шаге ниже.

Модули

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

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

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

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

Узнайте больше о преимуществах модульного программирования:

Каждый модуль разделен на уровни, описанные ниже.

Ядро приложения

Ядро ​​системы, компонуется с использованием строительных блоков DDD:

Доменный уровень:

  • Сущности
  • Агрегаты
  • Доменные службы
  • Объекты-значения
  • Доменные ошибки

Прикладной уровень:

  • Прикладные службы
  • Команды и запросы
  • Порты

При необходимости могут быть добавлены другие строительные блоки.


Прикладной уровень

Прикладные службы

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

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

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

Что такое «Сценарии использования»?

wiki:

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

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


Примеры: create-user.service.ts

Подробнее о службах:

Команды и запросы

Этот принцип называется Разделением команд и запросов (CQS). По возможности методы должны быть разделены на «Команды» (операции изменения состояния) и «Запросы» (операции извлечения данных). Чтобы провести четкое различие между этими двумя типами операций, входные объекты могут быть представлены как команды и запросы. Прежде чем DTO достигнет домена, он преобразуется в объект Command или Query.

Команды

Команда – это объект, который сигнализирует о намерениях пользователя, например CreateUserCommand. Он описывает единичное действие (но не выполняет его).

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

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

Некоторые сторонники CQS могут сказать, что команда вообще ничего не должна возвращать. Но вам понадобится хотя бы идентификатор созданного элемента, чтобы получить к нему доступ позже. Для этого вы можете позволить клиентам генерировать UUID ​​(подробнее здесь: CQS versus server generated IDs).

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

Все изменения, сделанные с помощью команд (или событий или чего-либо еще) в нескольких агрегатах, должны быть сохранены в одной транзакции базы данных (если вы используете одну базу данных). Это означает, что внутри одного процесса одна команда / запрос к вашему приложению обычно должны выполнять только одну транзакционную операцию, чтобы сохранить все изменения (или отменить все изменения этой команды / запроса в случае неудачи). Это нужно делать для сохранения консистентности. Для этого вы можете заключить операции с базой данных в транзакцию или использовать что-то вроде шаблона Единица работы. Примеры: create-user.service.ts – обратите внимание, как выполняется получение транзакционного репозитория из this.unitOfWork.

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

Для выполнения команды вы можете использовать «Командную шину» вместо прямого импорта службы. Это отделит командира (Invoker) команды от исполнителя (Receiver), чтобы вы могли отправлять свои команды откуда угодно, не создавая связанности.

Примеры:

Подробнее об этом:

Запросы

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

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

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

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

Примеры:


Благодаря принудительному разделению команд и запросов код становится проще для понимания. Первые что-то меняют, вторые просто извлекают данные.

Кроме того, следование CQS с самого начала упростит разделение моделей записи и чтения в разные базы данных (CQRS), если когда-нибудь в будущем в этом возникнет необходимость.

Примечание: в этом репозитории используется пакет NestJS CQRS, который предоставляет шину команд / запросов.

Подробнее о CQS и CQRS:


Порты

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

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

  • Порты – это в основном просто интерфейсы, которые определяют, что нужно сделать, и не заботятся о том, как это делается.
  • Порты могут быть созданы для абстрагирования операций ввода-вывода, технологических деталей, инвазивных библиотек, устаревшего кода и т. д. из домена.
  • Порты должны создаваться в соответствии с потребностями домена, а не просто имитировать API инструментов.
  • Во время тестирования в порты могут быть переданы фиктивные реализации. Имитация (mocking) делает ваши тесты более быстрыми и независимыми от окружения.
  • При проектировании портов помните о принципе разделения интерфейсов. Разделяйте большие интерфейсы на более мелкие, когда это имеет смысл, но не переусердствуйте, если в этом нет необходимости.
  • Порты также могут помочь отложить принятие решений. Доменный уровень может быть реализован еще до принятия решения о том, какие технологии (фреймворки, базы данных и т. д.) будут использоваться.

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

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

Примеры:


Доменный уровень

Этот уровень содержит бизнес-правила приложения.

Домен должен работать только с объектами домена, наиболее важные из них описаны ниже.

Сущности

Сущности – это ядро ​​домена. Они инкапсулируют бизнес-правила и атрибуты бизнеса. Сущность может быть объектом со свойствами и методами или набором структур данных и функций.

Сущности представляют бизнес-модели и выражают, какими свойствами обладает конкретная модель, что она может делать, когда и при каких условиях она может это делать. Примером бизнес-модели может быть User, Product, Booking, Ticket, Wallet и т. д.

Сущность всегда должна сохранять инвариант:

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

Сущность:

  • Содержит бизнес-логику домена. По возможности избегайте использования бизнес-логики в своих службах, это приводит к анемичной доменной модели (доменные службы являются исключением для бизнес-логики, которую нельзя поместить в единую сущность).
  • Имеет идентичность, которая определяет ее и делает ее отличной от других. Ее идентичность сохраняется на протяжении всего жизненного цикла.
  • Равенство между двумя сущностями определяется путем сравнения их идентификаторов (обычно это поле id).
  • Может содержать другие объекты, например другие сущности или объекты-значения.
  • Отвечает за сбор всего, что связано с понимания состояния и того, как оно меняется в одном и том же месте.
  • Отвечает за координацию операций над принадлежащих ей объектами.
  • Ничего не знает о верхних уровнях (службах, контроллерах и т. д.).
  • Данные доменной сущности должны быть смоделированы с учетом бизнес-логики, а не схемы базы данных.
  • Сущности должны защищать свои инварианты, по возможности не содержать публичных сеттеров – обновлять состояние с помощью методов и при необходимости выполнять инвариантную валидацию при каждом обновлении (это может быть простой метод validate(), который проверяет, не нарушаются ли бизнес-правила при обновлении).
  • Должна быть консистентной при создании. Валидация сущностей и других доменных объектов при их создании и выброс первой возникшей ошибки. Fail Fast.
  • Избегайте использования конструкторов без аргументов (пустых), принимайте и валидируйте все требуемые свойства с помощью конструктора.
  • Для дополнительных свойств, требующих сложной настройки, могут быть использованы Текучий интерфейс и шаблон Строитель.
  • Делайте сущности частично неизменяемыми. Определите, какие свойства не должны изменяться после создания, и сделайте их доступными только для чтения (например, id или createdAt).

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

Примеры:

Подробнее об этом:


Агрегаты

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

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

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

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

Примеры:

  • aggregate-root.base.ts – абстрактный базовый класс.
  • user.entity.ts – агрегаты – это просто объекты, которые должны следовать набору определенных правил, описанных выше.

Подробнее об этом:


Доменные события

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

Например, если пользователь что-то покупает, вы можете:

  • Обновить его корзину покупок;
  • Снять деньги с его кошелька;
  • Создать новый заказ на доставку;
  • Выполнить другие доменные операции, которые не относятся к агрегату, выполняющему команду «купить».

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

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

Доменные события могут быть полезны при создании журнала аудита для отслеживания всех изменений важных сущностей путем сохранения каждого события в базе данных. Узнайте больше о том, почему журналы аудита могут быть полезны: Why soft deletes are evil and what to do instead.

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

Примечание: в этом проекте используется пользовательская реализация для публикации доменных событий. Причина отказа от использования Node Event Emitter или пакетов, которые предлагают шину событий (например, NestJS CQRS) заключается в том, что они не предлагают возможности ожидания для завершения всех событий, что полезно, когда все события становятся частью транзакции. Внутри одного процесса должны быть сохранены либо все изменения, внесенные событиями, либо ни одно из них в случае сбоя одного из событий.

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

Примеры:

  • domain-events.ts – этот класс отвечает за предоставление функционала публикации / подписки для всех, кому необходимо генерировать или прослушивать события. Имейте в виду, что это всего лишь демонстрационный пример концепции и может быть не лучшим решением для приложения в эксплуатационном окружении.
  • user-created.domain-event.ts – простой объект, содержащий данные, относящиеся к опубликованному событию.
  • create-wallet-when-user-is-created.domain-event-handler.ts – это пример обработчика доменного события, который выполняет некоторые действия при возникновении доменного события (в этом случае при создании пользователя он также создает кошелек для этого пользователя).
  • typeorm.repository.base.ts – репозиторий публикует все доменные события для выполнения при сохранении изменений в агрегате.
  • typeorm-unit-of-work.ts – это гарантирует, что все изменения сохраняются в одной транзакции базы данных. Имейте в виду, что это наивная реализация шаблона Единицы работы, поскольку она только оборачивает выполнение в транзакцию. Чтобы правильно реализовать единицу работы, используйте что-то вроде mikro-orm вместо typeorm. Узнайте больше о единице работы с использованием mikro-orm.
  • unit-of-work.ts – здесь вы создаете фабрики для определенных репозиториев домена, которые используются в транзакции.
  • create-user.service.ts – здесь мы получаем пользовательский репозиторий из UnitOfWork и выполняем транзакцию.

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

Чтобы лучше понять доменные события и их реализацию, прочтите это:

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

Примечание: имейте в виду, что если вы используете шаблон Хронология Событий с одним потоком для каждого агрегата, то вы, скорее всего, не сможете сохранить все события в нескольких агрегатах в одной транзакции. В этом случае сохранение событий в нескольких агрегатах должно обрабатываться по-разному (например, с помощью шаблона Сага с компенсирующими событиями или Process Manager или что-то подобное).

Интеграционные события

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

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

Для обработки интеграционных событий в микросервисах вам может потребоваться внешний брокер сообщений или шина событий, например RabbitMQ или Kafka вместе с шаблонами, такими как Transactional outbox, Change Data Capture, Sagas or a Process Manager для поддержания конечной консистентности.

Подробнее об этом:

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


Доменные службы

Eric Evans, Domain-Driven Design:

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

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

Объекты-значения

Некоторые атрибуты и поведения могут быть перемещены из самой сущности и помещены в объекты-значения.

Объекты-значения:

  • Не имеют идентичности. Равенство определяется структурным свойством.
  • Неизменяемы (иммутабельны).
  • Могут использоваться как атрибут «сущностей» и других «объектов-значений».
  • Явно определяют и жестко применяют важные ограничения (инварианты).

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

Представьте, что у вас есть сущность User, которая должна иметь адрес пользователя. Обычно адрес – это просто сложное значение, не имеющее идентичности в домене и состоящее из множества других значений, таких как country, street, postalCode и др.; поэтому его можно смоделировать и рассматривать как «объект-значение» со своей собственной бизнес-логикой.

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

Примеры:

Подробнее об этом:

Обеспечение инвариантов доменных объектов

Замена примитивов на объекты-значения

Большинство кодовых баз работают с примитивными типами – «строки», «числа» и т. д. В доменной модели этот уровень абстракции может быть слишком низким.

Важные бизнес-концепции можно выразить с помощью определенных типов и классов. Вместо примитивов можно использовать объекты-значения, чтобы избежать одержимости элементарными типами. Для примера email с типом string:

email: string;

... вместо этого может быть представлен как объект-значение:

email: Email;

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

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

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

Используемые вместо примитивных типов «объекты-значения» также называются «доменными примитивами». Концепция и название предложены в книге Secure by Design.

Использование объектов-значений вместо примитивов:

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

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

Примеры:

Рекомендуем прочитать:

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

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

Цитата из John A De Goes:

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

Давайте различим два типа защиты от недопустимых состояний: во время компиляции и во время выполнения.

Во время компиляции

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

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

Или, например, представьте, что бизнес-логика требует наличия контактной информации человека по электронной почте, телефону. И email, и phone могут быть представлены как необязательные, например:

interface ContactInfo {
  email?: Email;
  phone?: Phone;
}

Но что произойдет, если и то, и другое не было передано программистом? Нарушено бизнес-правило. Это недопустимое состояние.

Решение: это можно представить как объединение типов:

type ContactInfo = Email | Phone | [Email, Phone];

Теперь необходимо указать либо Email, либо Phone, либо и то, и другое. Если ничего не указано, то IDE сразу же покажет ошибку типа. Теперь валидация бизнес-правил перенесена из времени выполнения на время компиляции, что делает приложение более безопасным и дает более быструю обратную связь, когда что-то используется не по назначению.

Это называется шаблоном Тип-Состояние.

Шаблон Тип-Состояние – это шаблон проектирования API, который кодирует информацию о состоянии объекта во время выполнения в его тип во время компиляции.

Подробнее об этом:

Во время выполнения

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

Доменные объекты должны защищать свои инварианты. Наличие здесь некоторых правил валидации защитит их состояние от повреждения.

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

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

Data should not be trusted. There are a lot of cases when invalid data may end up in a domain. For example, if data comes from external API, database, or if it's just a programmer error.

Enforcing self-validation will inform immediately when data is corrupted. Not validating domain objects allows them to be in an incorrect state, this leads to problems.

Without domain primitives, the remaining code needs to take care of validation, formatting, comparing, and lots of other details. Entities represent long-lived objects with a distinguished identity, such as articles in a news feed, rooms in a hotel, and shopping carts in online sales. The functionality in a system often centers around changing the state of these objects: hotel rooms are booked, shopping cart contents are paid for, and so on. Sooner or later the flow of control will be guided to some code representing these entities. And if all the data is transmitted as generic types such as int or String , responsibilities fall on the entity code to validate, compare, and format the data, among other tasks. The entity code will be burdened with a lot of tasks, rather than focusing on the central business flow-of-state changes that it models. Using domain primitives can counteract the tendency for entities to grow overly complex.

Quote from: Secure by design: Chapter 5.3 Standing on the shoulders of domain primitives

Примечание: Though primitive obsession is a code smell, some people consider making a class/object for every primitive may be an overengineering. For less complex and smaller projects it definitely may be. For bigger projects, there are people who advocate for and against this approach. If creating a class for every primitive is not preferred, create classes just for those primitives that have specific rules or behavior, or just validate only outside of domain using some validation framework. Here are some thoughts on this topic: From Primitive Obsession to Domain Modelling - Over-engineering?.

Рекомендуем прочитать:

How to do simple validation?

For simple validation like checking for nulls, empty arrays, input length etc. a library of guards can be created.

Примеры: guard.ts

Подробнее об этом: Refactoring: Guard Clauses

Another solution would be using an external validation library, but it is not a good practice to tie domain to external libraries and is not usually recommended.

Although exceptions can be made if needed, especially for very specific validation libraries that validate only one thing (like specific IDs, for example bitcoin wallet address). Tying only one or just few Value Objects to such a specific library won't cause any harm. Unlike general purpose validation libraries which will be tied to domain everywhere and it will be troublesome to change it in every Value Object in case when old library is no longer maintained, contains critical bugs or is compromised by hackers etc.

Though, it is fine to do full sanity checks using validation framework or library outside of domain (for example class-validator decorators in DTOs), and do only some basic checks inside of Value Objects (besides business rules), like checking for null or undefined, checking length, matching against simple regexp etc. to check if value makes sense and for extra security.

Note about using regexp

Be careful with custom regexp validations for things like validating email, only use custom regexp for some very simple rules and, if possible, let validation library do it's job on more difficult ones to avoid problems in case your regexp is not good enough.

Also, keep in mind that custom regexp that does same type of validation that is already done by validation library outside of domain may create conflicts between your regexp and the one used by a validation library.

For example, value can be accepted as valid by a validation library, but Value Object may throw an error because custom regexp is not good enough (validating email is more complex than just copy - pasting a regular expression found in google. Though, it can be validated by a simple rule that is true all the time and won't cause any conflicts, like every email must contain an @). Try finding and validating only patterns that won't cause conflicts.


Although there are other strategies on how to do validation inside domain, like passing validation schema as a dependency when creating new Value Object, but this creates extra complexity.

Either to use external library/framework for validation inside domain or not is a tradeoff, analyze all the pros and cons and choose what is more appropriate for current application.

For some projects, especially smaller ones, it might be easier and more appropriate to just use validation library/framework.

Keep in mind that not all validations can be done in a single Value Object, it should validate only rules shared by all contexts. There are cases when validation may be different depending on a context, or one field may involve another field, or even a different entity. Handle those cases accordingly.

Types of validation

There are some general recommendations for validation order. Cheap operations like checking for null/undefined and checking length of data come early in the list, and more expensive operations that require calling the database come later.

Preferably in this order:

  • Origin - Is the data from a legitimate sender? When possible, accept data only from authorized users / whitelisted IPs etc. depending on the situation.
  • Existence - are provided data not empty? Further validations make no sense if data is empty. Check for empty values: null/undefined, empty objects and arrays.
  • Size - Is it reasonably big? Before any further steps, check length/size of input data, no matter what type it is. This will prevent validating data that is too big which may block a thread entirely (sending data that is too big may be a DoS attack).
  • Lexical content - Does it contain the right characters and encoding? For example, if we expect data that only contains digits, we scan it to see if there’s anything else. If we find anything else, we draw the conclusion that the data is either broken by mistake or has been maliciously crafted to fool our system.
  • Syntax - Is the format right? Check if data format is right. Sometimes checking syntax is as simple as using a regexp, or it may be more complex like parsing a XML or JSON.
  • Semantics - Does the data make sense? Check data in connection with the rest of the system (like database, other processes etc). For example, checking in a database if ID of item exists.

Подробнее об этом:

Domain Errors

Exceptions are for exceptional situations. Complex domains usually have a lot of errors that are not exceptional, but a part of a business logic (like seat already booked, choose another one). Those errors may need special handling. In those cases returning explicit error types can be a better approach than throwing.

Returning an error instead of throwing explicitly shows a type of each exception that a method can return so you can handle it accordingly. It can make an error handling and tracing easier.

To help with that use some kind of a Result object type with a Success or a Failure (an Either monad from functional languages like Haskell). Unlike throwing exceptions, this approach allows to define types for every error and will force you to handle those cases explicitly instead of using try/catch. For example:

if (await userRepo.exists(command.email)) {
  return Result.err(new UserAlreadyExistsError()); // <- returning an Error
}
// else
const user = await this.userRepo.create(user);
return Result.ok(user);

@badrap/result - this is a nice npm package if you want to use a Result object.

Returning errors instead of throwing them adds a bit of extra boilerplate code, but makes your application more robust and secure.

Примечание: Distinguish between Domain Errors and Exceptions. Exceptions are usually thrown and not returned. If you return technical Exceptions (like connection failed, process out of memory etc), It may cause some security issues and goes against Fail-fast principle. Instead of terminating a program flow, returning an exception continues program execution and allows it to run in an incorrect state, which may lead to more unexpected errors, so it's generally better to throw an Exception in those cases rather then returning it.

Примеры:

  • user.errors.ts - user errors
  • create-user.service.ts - notice how Result.err(new UserAlreadyExistsError()) is returned instead of throwing it.
  • create-user.http.controller.ts - in a user http controller we unwrap an error and decide what to do with it. If an error is UserAlreadyExistsError we throw a Conflict Exception which a user will receive as 409 - Conflict. If an error is unknown we just throw it and NestJS will return it to the user as 500 - Internal Server Error.
  • create-user.cli.controller.ts - in a CLI controller we do not care about returning a correct status code so we just .unwrap() a result, which will just throw in case of an error.

Подробнее об этом:

Using libraries inside application's core

Whether or not to use libraries in application core and especially domain layer is a subject of a lot of debates. In real world, injecting every library instead of importing it directly is not always practical, so exceptions can be made for some single responsibility libraries that help to implement domain logic (like working with numbers).

Main recommendations to keep in mind is that libraries imported in application's core shouldn't expose:

  • Functionality to access any out-of-process resources (http calls, database access etc);
  • Functionality not relevant to domain (frameworks, technology details like ORMs, Logger etc).
  • Functionality that brings randomness (generating random IDs, timestamps etc) since this makes tests unpredictable (though in TypeScript world it is not that big of a deal since this can be mocked by a test library without using DI);
  • If a library changes often or has a lot of dependencies of its own it most likely shouldn't be used in domain layer.

To use such libraries consider creating an anti-corruption layer by using adapter or facade patterns.

We sometimes tolerate libraries in the center, but be careful with general purpose libraries that may scatter across many domain objects. It will be hard to replace those libraries if needed. Tying only one or just few domain objects to some single-responsibility library should be fine. It is way easier to replace a specific library that is tied to one or few objects than a general purpose library that is everywhere.

In addition to different libraries there are Frameworks. Frameworks can be a real nuisance because by definition they want to be in control and it's hard to replace a Framework later when your entire application is glued to it. Its fine to use Frameworks in outside layers (like infrastructure), but keep your domain clean of them when possible. You should be able to extract your domain layer and build a new infrastructure around it using any other framework without breaking your business logic.

NestJS makes a good job as it uses decorators which are not very intrusive, so you could use decorators like @Inject() without affecting your business logic at all and it's relatively easy to remove or replace it when needed. Don't give up on frameworks completely, but keep them in boundaries and don't let them affect your business logic.

Offload as much of irrelevant responsibilities as possible from the core, especially from domain layer. In addition, try to minimize usage of dependencies in general. More dependencies your software has means more potential errors and security holes. One technique for making software more robust is to minimize what your software depends on - the less that can go wrong, the less will go wrong. On the other hand, removing all dependencies would be counterproductive as replicating that functionality would have been a huge amount of work and less reliable than just using a widely-used dependency. Finding a good balance is important, this skill requires experience.

Подробнее об этом:


Interface Adapters

Interface adapters (also called driving/primary adapters) are user-facing interfaces that take input data from the user and repackage it in a form that is convenient for the use cases(services/command handlers) and entities. Then they take the output from those use cases and entities and repackage it in a form that is convenient for displaying it back for the user. User can be either a person using an application or another server.

Contains Controllers and Request/Response DTOs (can also contain Views, like backend-generated HTML templates, if required).

Controllers

  • Controller is a user-facing API that is used for parsing requests, triggering business logic and presenting the result back to the client.
  • One controller per use case is considered a good practice.
  • In NestJS world controllers may be a good place to use OpenAPI/Swagger decorators for documentation.

One controller per trigger type can be used to have a more clear separation. For example:

Resolvers

If you are using GraphQL instead of controllers you will use Resolvers.

One of the main benefits of a layered architecture is separation of concerns. As you can see it doesn't matter if you use REST or GraphQL, the only thing that changes is user-facing API layer (interface-adapters). All the application Core stays the same since it doesn't depend on technology you are using.

Примеры:


DTOs

Data that comes from external applications should be represented by a special type of classes - Data Transfer Objects (DTO for short). Data Transfer Object is an object that carries data between processes. It defines a contract between your API and clients.

Request DTOs

Input data sent by a user.

  • Using Request DTOs gives a contract that a client of your API has to follow to make a correct request.

Примеры:

Response DTOs

Output data returned to a user.

  • Using Response DTOs ensures clients only receive data described in DTOs contract, not everything that your model/entity owns (which may result in data leaks).

Примеры:


Using DTOs protects your clients from internal data structure changes that may happen in your API. When internal data models change (like renaming variables or splitting tables), they can still be mapped to match a corresponding DTO to maintain compatibility for anyone using your API.

When updating DTO interfaces, a new version of API can be created by prefixing an endpoint with a version number, for example: v2/users. This will make transition painless by preventing breaking compatibility for users that are slow to update their apps that uses your API.

You may have noticed that our create-user.command.ts contains the same properties as create-user.request.dto.ts. So why do we need DTOs if we already have Command objects that carry properties? Shouldn't we just have one class to avoid duplication?

Because commands and DTOs are different things, they tackle different problems. Commands are serializable method calls - calls of the methods in the domain model. Whereas DTOs are the data contracts. The main reason to introduce this separate layer with data contracts is to provide backward compatibility for the clients of your API. Without the DTOs, the API will have breaking changes with every modification of the domain model.

More info on this subject here: Are CQRS commands part of the domain model? (read "Commands vs DTOs" section).

Additional recommendations:

  • DTOs should be data-oriented, not object-oriented. Its properties should be mostly primitives. We are not modeling anything here, just sending flat data around.
  • When returning a Response prefer whitelisting properties over blacklisting. This ensures that no sensitive data will leak in case if programmer forgets to blacklist newly added properties that shouldn't be returned to the user.
  • Interfaces for Request/Response objects should be kept somewhere in shared directory instead of module directory since they may be used by a different application (like front-end page, mobile app or microservice). Consider creating git submodule or a separate package for sharing interfaces.
  • Request/Response DTO classes may be a good place to use validation and sanitization decorators like class-validator and class-sanitizer (make sure that all validation errors are gathered first and only then return them to the user, this is called Notification pattern. Class-validator does this by default).
  • Request/Response DTO classes may also be a good place to use Swagger/OpenAPI library decorators that NestJS provides.
  • If DTO decorators for validation/documentation are not used, DTO can be just an interface instead of class + interface.
  • Data can be transformed to DTO format using a separate mapper or right in the constructor if DTO classes are used.

Локальные DTO

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

Здесь Martin Fowler рассуждает о локальных DTO, вкратце (цитата):

Некоторые люди аргументируют эти (DTO) как часть API служебного уровня, потому что они гарантируют, что клиенты служебного уровня не зависят от базовой модели предметной области. Хотя это может быть удобно, я не думаю, что это стоит затрат на все это сопоставление данных.

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


Infrastructure

The Infrastructure is responsible strictly to keep technology. You can find there the implementations of database repositories for business entities, message brokers, I/O components, dependency injection, frameworks and any other thing that represents a detail for the architecture, mostly framework dependent, external dependencies, and so on.

It's the most volatile layer. Since the things in this layer are so likely to change, they are kept as far away as possible from the more stable domain layers. Because they are kept separate, it's relatively easy make changes or swap one component for another.

Infrastructure layer can contain Adapters, database related files like Repositories, ORM entities/Schemas, framework related files etc.

Adapters

  • Infrastructure adapters (also called driven/secondary adapters) enable a software system to interact with external systems by receiving, storing and providing data when requested (like persistence, message brokers, sending emails or messages, requesting 3rd party APIs etc).
  • Adapters also can be used to interact with different domains inside single process to avoid coupling between those domains.
  • Adapters are essentially an implementation of ports. They are not supposed to be called directly in any point in code, only through ports(interfaces).
  • Adapters can be used as Anti-Corruption Layer (ACL) for legacy code.

Подробнее об этом: Anti-Corruption Layer: How to Keep Legacy Support from Breaking New Systems

Adapters should have:

  • a port somewhere in application/domain layer that it implements;
  • a mapper that maps data from and to domain (if it's needed);
  • a DTO/interface for received data;
  • a validator to make sure incoming data is not corrupted (validation can reside in DTO class using decorators, or it can be validated by Value Objects).

Repositories

Repositories centralize common data access functionality. They encapsulate the logic required to access that data. Entities/aggregates can be put into a repository and then retrieved at a later time without domain even knowing where data is saved, in a database, or a file, or some other source.

We use repositories to decouple the infrastructure or technology used to access databases from the domain model layer.

Martin Fowler describes a repository as follows:

A repository performs the tasks of an intermediary between the domain model layers and data mapping, acting in a similar way to a set of domain objects in memory. Client objects declaratively build queries and send them to the repositories for answers. Conceptually, a repository encapsulates a set of objects stored in the database and operations that can be performed on them, providing a way that is closer to the persistence layer. Repositories, also, support the purpose of separating, clearly and in one direction, the dependency between the work domain and the data allocation or mapping.

The data flow here looks something like this: repository receives a domain Entity from application service, maps it to database schema/ORM format, does required operations and maps it back to domain Entity and returns it back to service.

Keep in mind that application's core is not allowed to depend on repositories directly, instead it depends on abstractions (ports/interfaces). This makes data retrieval technology-agnostic.

Examples

This project contains abstract repository class that allows to make basic CRUD operations: typeorm.repository.base.ts. This base class is then extended by a specific repository, and all specific operations that an entity may need is implemented in that specific repo: user.repository.ts.

Persistence models

Using a single entity for domain logic and database concerns leads to a database-centric architecture. In DDD world domain model and persistance model should be separated.

Since domain Entities have their data modeled so that it best accommodates domain logic, it may be not in the best shape to save in a database. For that purpose Persistence models can be created that have a shape that is better represented in a particular database that is used. Domain layer should not know anything about persistance models, and it should not care.

There can be multiple models optimized for different purposes, for example:

  • Domain with it's own models - Entities, Aggregates and Value Objects.
  • Persistence layer with it's own models - ORM (Object–relational mapping), schemas, read/write models if databases are separated into a read and write db (CQRS) etc.

Over time, when the amount of data grows, there may be a need to make some changes in the database like improving performance or data integrity by re-designing some tables or even changing the database entirely. Without an explicit separation between Domain and Persistance models any change to the database will lead to change in your domain Entities or Aggregates. For example, when performing a database normalization data can spread across multiple tables rather than being in one table, or vice-versa for denormalization. This may force a team to do a complete refactoring of a domain layer which may cause unexpected bugs and challenges. Separating Domain and Persistance models prevents that.

Примечание: separating domain and persistance models may be an overkill for smaller applications, consider all pros and cons before making this decision.

Примеры:

Alternative approach to ORM are raw queries or some sort of a query builder (like knex). This may be a better approach for bigger projects than Object-Relational Mapping since it offers more flexibility and better performance.

Подробнее об этом:

Other things that can be a part of Infrastructure layer:

  • Framework related files;
  • Application logger implementation;
  • Infrastructure related events (Nest-event)
  • Periodic cron jobs or tasks launchers (NestJS Schedule);
  • Other technology related files.

Recommendations for smaller APIs

Be careful when implementing any complex architecture in small-medium sized projects with not a lot of business logic. Some of the building blocks/patterns/principles may fit well, but others may be an overengineering.

For example:

  • Separating code into modules/layers/use-cases, using some building blocks like controllers/services/entities, respecting boundaries and dependency injections etc. may be a good idea for any project.
  • But practices like creating an object for every primitive, using Value Objects to separate business logic into smaller classes, separating Domain Models from Persistence Models etc. in projects that are more data-centric and have little or no business logic may only complicate such solutions and add extra boilerplate code, data mapping, maintenance overheads etc. without adding much benefit.

DDD and other practices described here are mostly about creating software with complex business logic. But what would be a better approach for simpler applications?

For applications with not a lot of business logic consider other architectures. The most popular is probably MVC. Model-View-Controller is better suited for CRUD applications with little business logic since it tends to favor designs where software is mostly the view of the database.

General recommendations on architectures, best practices, design patterns and principles

Different projects most likely will have different requirements. Some principles/patterns in such projects can be implemented in a simplified form, some can be skipped. Follow YAGNI principle and don't overengineer.

Sometimes complex architecture and principles like SOLID can be incompatible with YAGNI and KISS. A good programmer should be pragmatic and has to be able to combine his skills and knowledge with a common sense to choose the best solution for the problem.

You need some experience with object-oriented software development in real world projects before they are of any use to you. Furthermore, they don’t tell you when you have found a good solution and when you went too far. Going too far means that you are outside the “scope” of a principle and the expected advantages don’t appear.

Principles, Heuristics, ‘laws of engineering’ are like hint signs, they are helpful when you know where they are pointing to and you know when you have gone too far. Applying them requires experience, that is trying things out, failing, analysing, talking to people, failing again, fixing, learning and failing some more. There is no short cut as far as I know.

Before implementing any pattern always analyze if benefit given by using it worth extra code complexity.

Effective design argues that we need to know the price of a pattern is worth paying - that's its own skill.

However, remember:

It's easier to refactor over-design than it is to refactor no design.

Подробнее об этом:

Other recommendations and best practices

Exceptions Handling

Unlike Domain Errors, exceptions should be thrown when something unexpected happens. Like when a process is out of memory or a database connection lost. In our case we also throw an Exception in Domain Objects constructor when validation fails, since we know our input is validated before it even reaches Domain so when validation of a domain object constructor fails it is an exceptional situation.

Exception types

Consider extending Error object to make custom generic exception types for different situations. For example: ArgumentInvalidException, ValidationException etc. This is especially relevant in NodeJS world since there is no exceptions for different situations by default.

Keep in mind that application's core shouldn't throw HTTP exceptions or statuses since it shouldn't know in what context it is used, since it can be used by anything: HTTP controller, Microservice event handler, Command Line Interface etc. A better approach is to create custom error classes with appropriate error codes.

When used in HTTP context, for returning proper status code back to user an instanceof or a switch/case check against the custom code can be performed in exception interceptor or in a controller and appropriate HTTP exception can be returned depending on exception type/code.

Exception interceptor example: exception.interceptor.ts - notice how custom exceptions are converted to nest.js exceptions.

Adding a code string with a custom status code for every exception is a good practice, since when that exception is transferred to another process instanceof check cannot be performed anymore so a code string is used instead. code enum types can be stored in a separate file so they can be shared and reused on a receiving side: exception.codes.ts.

When using microservices, exception codes can be packed into a library or a sub-module and reused in each microservice for consistency.

Differentiate between programmer errors and operational errors

Application should be protected not only from operational errors (like incorrect user input), but from a programmer errors as well by throwing exceptions when something is not used as intended.

For example:

  • Operational errors can happen when validation error is thrown by validating user input, it means that input body is incorrect and a 400 Bad Request exception should be returned to the user with details of what fields are incorrect (notification pattern). In this case user can fix the input body and retry the request.
  • On the other hand, programmer error means something unexpected occurs in the program. For example, when exception happens on a new domain object creation, sometimes it can mean that a class is not used as intended and some rule is violated, for example a programmer did a mistake by assigning an incorrect value to a constructor, or value got mutated at some point and is no longer valid. In this case user cannot do anything to fix this, only a programmer can, so it may be more appropriate to throw a different type of exception that should be logged and then returned to the user as 500 Internal Server Error, in this case without adding much additional details to the response since it may cause a leak of some sensitive data.

Error metadata

Consider adding optional metadata object to exceptions (if language doesn't support anything similar by default) and pass some useful technical information about the exception when throwing. This will make debugging easier.

Important to keep in mind: never log or add to metadata any sensitive information (like passwords, emails, phone or credit card numbers etc) since this information may leak into log files, and if log files are not protected properly this information can leak or be seen by developers who have access to log files. Aim adding only technical information to your logs.

Other recommendations

  • If translations of error messages to other languages is needed, consider storing those error messages in a separate object/class rather than inline string literals. This will make it easier to implement localization by adding conditional getters. Also, it is usually better to store all localization in a single place, for example, having a single file/folder for all messages that need translation, and then import them where needed. It is easier to add new translations when all of your messages are in one place rather then scattered across the app.
  • You can use "Problem Details for HTTP APIs" standard for returned exceptions, described in RFC 7807. Подробнее об этом: REST API Error Handling - Problem Details Response
  • By default in NodeJS Error objects are not serialized properly when sending plain objects to external processes. Consider creating a toJSON() method so it can be easily sent to other processes as a plain object. (see example in exception.base.ts). But keep in mind not to return a stack trace when in production.

Примеры:

Подробнее об этом:

Testing

Software Testing helps catching bugs early. Properly tested software product ensures reliability, security and high performance which further results in time saving, cost effectiveness and customer satisfaction.

Lets review two types of software testing:

Testing module/use-case internal structures (creating a test for every file/class) is called White Box testing. White Box testing is widely used technique, but it has disadvantages. It creates coupling to implementation details, so every time you decide to refactor business logic code this may also cause a refactoring of corresponding tests.

Use case requirements may change mid work, your understanding of a problem may evolve or you may start noticing new patterns that emerge during development, in other words, you start noticing a "big picture", which may lead to refactoring. For example: imagine that you defined a White box test for a class, and while developing this class you start noticing that it does too much and should be separated into two classes. Now you'll also have to refactor your unit test. After some time, while implementing a new feature, you notice that this new feature uses some code from that class you defined before, so you decide to separate that code and make it reusable, creating a third class (which originally was one), which leads to changing your unit tests yet again, every time you refactor. Use case requirements, input, output or behavior never changed, but unit tests had to be changed multiple times. This is inefficient and time consuming.

To solve this and get the most out of your tests, prefer Black Box testing (Behavioral Testing). This means that tests should focus on testing user-facing behavior users care about (your code's public API), not the implementation details of individual units it has inside. This avoids coupling, protects tests from changes that may happen while refactoring, makes tests easier to understand and maintain thus saving time.

Tests that are independent of implementation details are easier to maintain since they don't need to be changed each time you make a change to the implementation.

Try to avoid White Box testing when possible. However, it's worth mentioning that there are cases when White Box testing may be useful. For instance, we need to go deeper into the implementation when it is required to reduce combinations of testing conditions, for example, a class uses several plug-in strategies, thus it is easier for us to test those strategies one at a time, in this case White Box tests may be appropriate.

Use White Box testing only when it is really needed and as an addition to Black Box testing, not the other way around.

It's all about investing only in the tests that yield the biggest return on your effort.

Behavioral tests can be divided in two parts:

  • Fast: Use cases tests in isolation which test only your business logic, with all I/O (external API or database calls, file reads etc.) mocked. This makes tests fast so they can be run all the time (after each change or before every commit). This will inform you when something fails as fast as possible. Finding bugs early is critical and saves a lot of time.
  • Slow: Full End to End (e2e) tests which test a use case from end-user standpoint. Instead of injecting I/O mocks those tests should have all infrastructure up and running: like database, API routes etc. Those tests check how everything works together and are slower so can be run only before pushing/deploying. Though e2e tests live in the same project/repository, it is a good practice to have e2e tests independent from project's code. In bigger projects e2e tests are usually written by a separate QA team.

Примечание: some people try to make e2e tests faster by using in-memory or embedded databases (like sqlite3). This makes tests faster, but reduces the reliability of those tests and should be avoided. Подробнее об этом: Don't use In-Memory Databases for Tests.

For BDD tests Cucumber with Gherkin syntax can give a structure and meaning to your tests. This way even people not involved in a development can define steps needed for testing. In node.js world jest-cucumber is a nice package to achieve that.

Примеры:

Подробнее об этом:

Load Testing

For projects with a bigger user base you might want to implement some kind of load testing to see how program behaves with a lot of concurrent users.

Load testing is a great way to minimize performance risks, because it ensures an API can handle an expected load. By simulating traffic to an API in development, businesses can identify bottlenecks before they reach production environments. These bottlenecks can be difficult to find in development environments in the absence of a production load.

Automatic load testing tools can simulate that load by making a lot of concurrent requests to an API and measure response times and error rates.

Example tools:

Примеры:

Подробнее об этом:

Fuzz Testing

Fuzzing or fuzz testing is an automated software testing technique that involves providing invalid, unexpected, or random data as inputs to a computer program.

Fuzzing is a common method hackers use to find vulnerabilities of the system. For example:

  • JavaScript injections can be executed if input is not sanitized properly, so a malicious JS code can end up in a database and then gets executed in a browser when somebody reads that data.
  • SQL injection attacks can occur if data is not sanitized properly, so hackers can get access to a database (though modern ORM libraries can protect from that kind of attacks when used properly).
  • Sending weird unicode characters, emojis etc. can crash your application.

There are a lot of examples of a problems like this, for example sending a certain character could crash and disable access to apps on an iPhone.

Sanitizing and validating input data is very important. But sometimes we make mistakes of not sanitizing/validating data properly, opening application to certain vulnerabilities.

Automated Fuzz testing tools can prevent such vulnerabilities. Those tools contain a list of strings that are usually sent by hackers, like malicious code snippets, SQL queries, unicode symbols etc. (for example: Big List of Naughty Strings), which helps test most common cases of different injection attacks.

Fuzz testing is a nice addition to typical testing methods described above and potentially can find serious security vulnerabilities or defects.

Example tools:

  • Artillery Fuzzer is a plugin for Artillery to perform Fuzz testing.
  • sqlmap - an open source penetration testing tool that automates the process of detecting and exploiting SQL injection flaws

Подробнее об этом:

Configuration

  • Store all configurable variables/parameters in config files. Try to avoid using in-line literals/primitives. This will make it easier to find and maintain all configurable parameters when they are in one place.
  • Never store sensitive configuration variables (passwords/API keys/secret keys etc) in plain text in a configuration files or source code.
  • Store sensitive configuration variables, or variables that change depending on environment, as environment variables (dotenv is a nice package for that) or as a Docker/Kubernetes secrets.
  • Create hierarchical config files that are grouped into sections. If possible, create multiple files for different configs (like database config, API config, tasks config etc).
  • Application should fail and provide the immediate feedback if the required environment variables are not present at start-up.
  • For most projects plain object configs may be enough, but there are other options, for example: NestJS Configuration, rc, nconf or any other package.

Примеры:

  • ormconfig.ts - this is typeorm database config file. Notice process.env - those are environmental variables.
  • .env.example - this is dotenv example file. This file should only store dummy example secret keys, never store actual development/production secrets in it. This file later is renamed to .env and populated with real keys for every environment (local, dev or prod). Don't forget to add .env to .gitignore file to avoid pushing it to repo and leaking all keys.

Logging

  • Try to log all meaningful events in a program that can be useful to anybody in your team.
  • Use proper log levels: log/info for events that are meaningful during production, debug for events useful while developing/debugging, and warn/error for unwanted behavior on any stage.
  • Write meaningful log messages and include metadata that may be useful. Try to avoid cryptic messages that only you understand.
  • Never log sensitive data: passwords, emails, credit card numbers etc. since this data will end up in log files. If log files are not stored securely this data can be leaked.
  • Avoid default logging tools (like console.log). Use mature logger libraries (for example Winston) that support features like enabling/disabling log levels, convenient log formats that are easy to parse (like JSON) etc.
  • Consider including user id in logs. It will facilitate investigating if user creates an incident ticket.
  • In distributed systems a gateway can generate an unique correlation id for each request and pass it to every system that processes this request. Logging this id will make it easier to find related logs across different systems/files.
  • Use consistent structure across all logs. Each log line should represent one single event and can contain things like a timestamp, context, unique user id or correlation id and/or id of an entity/aggregate that is being modified, as well as additional metadata if required.
  • Use log managements systems. This will allow you to track and analyze logs as they happen in real-time. Here are some short list of log managers: Sentry, Loggly, Logstash, Splunk etc.
  • Send notifications of important events that happen in production to a corporate chat like Slack or even by SMS.
  • Don't write logs to a file from your program. Write all logs to stdout (to a terminal window) and let other tools handle writing logs to a file (for example docker supports writing logs to a file). Подробнее об этом: Why should your Node.js application not handle log routing?
  • Logs can be visualized by using a tool like Kibana.

Подробнее об этом:

Health monitoring

Additionally to logging tools, when something unexpected happens in production, it's critical to have thorough monitoring in place. As software hardens more and more, unexpected events will get more and more infrequent and reproducing those events will become harder and harder. So when one of those unexpected events happens, there should be as much data available about the event as possible. Software should be designed from the start to be monitored. Monitoring aspects of software are almost as important as the functionality of the software itself, especially in big systems, since unexpected events can lead to money and reputation loss for a company. Monitoring helps fixing and sometimes preventing unexpected behavior like failures, slow response times, errors etc.

Health monitoring tools are a good way to keep track of system performance, identify causes of crashes or downtime, monitor behavior, availability and load.

Some health monitoring tools already include logging management and error tracking, as well as alerts and general performance monitoring.

Here are some basic recommendation on what can be monitored:

  • Connectivity – Verify if user can successfully send a request to the API endpoint and get a response with expected HTTP status code. This will confirm if the API endpoint is up and running. This can be achieved by creating some kind of 'heath check' endpoint.
  • Performance – Make sure the response time of the API is within acceptable limits. Long response times cause bad user experience.
  • Error rate – errors immediately affect your customers, you need to know when errors happen right away and fix them.
  • CPU and Memory usage – spikes in CPU and Memory usage can indicate that there are problems in your system, for example bad optimized code, unwanted process running, memory leaks etc. This can result in loss of money for your organization, especially when cloud providers are used.
  • Storage usage – servers run out of storage. Monitoring storage usage is essential to avoid data loss.

Choose health monitoring tools depending on your needs, here are some examples:

Подробнее об этом:

Folder and File Structure

So instead of using typical layered style when an entire application is divided into services, controllers etc, we divide everything by modules. Now, how to structure files inside those modules?

A lot of people tend to do the same thing as before: create one big service/controller for a module and keep all logic for module's use cases there, making those controllers and services hundreds of lines long, which is hard to navigate and makes merge conflicts a nightmare to manage. Or they create a folder for each file type, like interfaces or services folder and store all unrelated to each other interfaces/services in there. This is the same approach that makes navigation harder. Every time you need to change something, instead of having all related files in the same place, you have to jump folders to find where the related files are.

It would be more logical to separate every module by components and have all related files close together. For example, check out create-user folder. It has most of the files that it needs inside the same folder: a controller, service, command etc. Now if a use-case changes, most of the changes are usually made in a single component (folder), not everywhere across the module.

And shared files, like domain objects (entities/aggregates), repositories, shared dtos and interfaces etc are stored apart since those are reused by multiple use-cases. Domain layer is isolated, and use-cases which are essentially wrappers around business logic are treated as components. This approach makes navigation and maintaining easier. Check user module for more examples.

This is called The Common Closure Principle (CCP). Folder/file structure in this project uses this principle. Related files that usually change together (and are not used by anything else outside of that component) are stored close together, in a single use-case folder.

The aim here should to be strategic and place classes that we, from experience, know often changes together into the same component.

Keep in mind that this project's folder/file structure is an example and might not work for everyone. Main recommendations here are:

  • Separate you application into modules;
  • Keep files that change together close to each other (Common Closure Principle);
  • Group files by their behavior that changes together, not by a type of functionality that file provides;
  • Keep files that are reused by multiple components apart;
  • Respect boundaries in your code, keeping files together doesn't mean inner layers can import outer layers;
  • Try to avoid a lot of nested folders;
  • Move files around until it feels right.

There are different approaches to file/folder structuring, like explicitly separating each layer into a corresponding folder. This defines boundaries more clearly but is harder to navigate. Choose what suits better for the project/personal preference.

Примеры:

  • Commands folder contains all state changing use cases and each use case inside it contains most of the things that it needs: controller, service, dto, command etc.
  • Queries folder is structured in the same way as commands but contains data retrieval use cases.

Подробнее об этом:

File names

Consider giving a descriptive type names to files after a dot ".", like *.service.ts or *.entity.ts. This makes it easier to differentiate what files does what and makes it easier to find those files using fuzzy search (CTRL+P for Windows/Linux and ⌘+P for MacOS in VSCode to try it out).

Подробнее об этом:

Static Code Analysis

Static code analysis is a method of debugging by examining source code before a program is run.

For JavasScript and TypeScript, Eslint with typescript-eslint plugin and some rules (like airbnb / airbnb-typescript) can be a great tool to enforce writing better code.

Try to make linter rules reasonably strict, this will help greatly to avoid "shooting yourself in a foot". Strict linter rules can prevent bugs and even serious security holes (eslint-plugin-security).

Adopt programming habits that constrain you, to help you to limit mistakes.

For example:

Using explicit any type is a bad practice. Consider disallowing it (and other things that may cause problems):

// .eslintrc.js file
  rules: {
    '@typescript-eslint/no-explicit-any': 'error',
    // ...
  }

Also, enabling strict mode in tsconfig.json is recommended, this will disallow things like implicit any types:

  "compilerOptions": {
    "strict": true,
    // ...
  }

Примеры: .eslintrc.js

Code Spell Checker may be a good addition to eslint.

Подробнее об этом:

Code formatting

The way code looks adds to our understanding of it. Good style makes reading code a pleasurable and consistent experience.

Consider using code formatters like Prettier to maintain same code styles in the project.

Подробнее об этом:

Documentation

Here are some useful tips to help users/other developers to use your program.

Document APIs

Use OpenAPI (Swagger) or GraphQL specifications. Document in details every endpoint. Add description and examples of every request, response, properties and exceptions that endpoints may return or receive as body/parameters. This will help greatly to other developers and users of your API.

Примеры:

Подробнее об этом:

Add Readme

Create a simple readme file in a git repository that describes basic app functionality, available CLI commands, how to setup a new project etc.

Try to make your code readable

Code can be self-documenting to some degree. One useful trick is to separate complex code to smaller chunks with a descriptive name. For example:

  • Separating a big function into a bunch of small ones with descriptive names, each with a single responsibility;
  • Moving in-line primitives or hard to read conditionals into a variable with a descriptive name.

This makes code easier to understand and maintain.

Подробнее об этом:

Avoid useless comments

Writing readable code, using descriptive function/method/variable names and creating tests can document your code well enough. Try to avoid comments when possible and try to make your code legible and tested instead.

Use comments only when it's really needed. Commenting may be a code smell in some cases, like when code gets changed but a developer forgets to update a comment (comments should be maintained, too).

Code never lies, comments sometimes do.

Use comments only in some special cases, like when writing an counter-intuitive "hack" or performance optimization which is hard to read.

For documenting public APIs use code annotations (like JSDoc) instead of comments, this works nicely with code editor intellisense.

Подробнее об этом:

Prefer typed languages

Types give useful semantic information to a developer and can be useful for documenting code, so prefer static typed languages to dynamic typed (untyped) languages for larger projects (for example by using TypeScript over JavaScript).

Примечание: For smaller projects/scripts/jobs static typing may not be needed.

Make application easy to setup

There are a lot of projects out there which take effort to configure after downloading it. Everything has to be set up manually: database, all configs etc. If new developer joins the team he has to waste a lot of time just to make application work.

This is a bad practice and should be avoided. Setting up project after downloading it should be as easy as launching one or few commands in terminal. Consider adding scripts to do this automatically:

Примеры:

  • package.json - notice all added scripts for launching tests, migrations, seeding, docker environment etc.
  • docker-compose.yml - after configuring everything in a docker-compose file, running a database and a db admin panel (and any other additional tools) can be done using only one command. This way there is no need to install and configure a database separately.

Seeds

To avoid manually creating data in the database, seeding is a great solution to populate database with data for development and testing purposes (e2e testing). Wiki description.

This project uses typeorm-seeding package.

Примеры: user.seeds.ts

Migrations

Migrations are used for database table/schema changes:

Database migration refers to the management of incremental, reversible changes and version control to relational database schemas. A schema migration is performed on a database whenever it is necessary to update or revert that database's schema to some newer or older version.

Source: Wiki

Migrations should be generated every time database table schema is changed. When pushed to production it can be launched automatically.

BE CAREFUL not to drop some columns/tables that contain data by accident. Perform data migrations before table schema migrations and always backup database before doing anything.

This project uses Typeorm Migrations which automatically generates sql table schema migrations like this:

Примеры: 1611765824842-CreateTables.ts

Seeds and migrations belong to Infrastructure layer.

Rate Limiting

By default there is no limit on how many request users can make to your API. This may lead to problems, like DoS or brute force attacks, performance issues like high response time etc.

To solve this, implementing Rate Limiting is essential for any API.

Подробнее об этом:

Code Generation

Code generation can be important when using complex architectures to avoid typing boilerplate code manually.

Hygen is a great example. This tool can generate building blocks (or entire modules) by using custom templates. Templates can be designed to follow best practices and concepts based on Clean/Hexagonal Architecture, DDD, SOLID etc.

Main advantages of automatic code generation are:

  • Avoid manual typing or copy-pasting of boilerplate code.
  • No hand-coding means less errors and faster implementations. Simple CRUD module can be generated and used right away in seconds without any manual code writing.
  • Using auto-generated code templates ensures that everyone in the team uses the same folder/file structures, name conventions, architectural and code styles.

Примечание:

  • To really understand and work with generated templates you need to understand what is being generated and why, so full understanding of an architecture and patterns used is required.

Custom utility types

Consider creating a bunch of shared custom utility types for different situations.

Some examples can be found in types folder.

Pre-push/pre-commit hooks

Consider launching tests/code formatting/linting every time you do git push or git commit. This prevents bad code getting in your repo. Husky is a great tool for that.

Подробнее об этом:

Prevent massive inheritance chains

This can be achieved by making class final.

Примечание: in TypeScript, unlike other languages, there is no default way to make class final. But there is a way around it using a custom decorator.

Примеры: final.decorator.ts

Подробнее об этом:

Conventional commits

Conventional commits add some useful prefixes to your commit messages, for example:

  • feat: added ability to delete user's profile

This creates a common language that makes easier communicating the nature of changes to teammates and also may be useful for automatic package versioning and release notes generation.

Подробнее об этом:


Additional resources

Articles

Repositories

Documentation

Blogs

Videos

Books