Размышления об архитектурных паттернах

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

 «Какой архитектурный паттерн используется в твоём текущем проекте?»

Этот вопрос заставил меня задумать и взглянуть на проблему выбора архитектуры под другим углом. Возможно, повлияла атмосфера конференции (всё-таки они должны приносить пользу), но я задумался: почему при разработке приложений мы чаще всего закладываем один архитектурный паттерн, а не комбинацию нескольких? Однако, как показал личный опыт и обсуждения в профессиональном сообществе, этот вопрос часто подразумевает поиск единственного правильного ответа. В реальности же, на практике, мы нередко видим, как команды стремятся придерживаться одного доминирующего подхода — будь то акцент на CQRS, строгое следование Hexagonal Architecture или применение DDD в объеме текущего понимания и опыта команды. Но мало кто пытаеться применить сразу несколько подходов, где каждый паттерн решает свою задачу.

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

  • Hexagonal (Ports & Adapters) – для изоляции бизнес-логики от инфраструктурных деталей, обеспечивая гибкость при интеграции с внешними сервисами.

  • Layered Architecture – для чёткого разделения ответственности между уровнями (presentation, business, data), упрощая поддержку и тестирование.

  • Domain-Driven Design (DDD) – для глубокой проработки сложной предметной области через агрегаты, ентити и value-объекты.

  • Clean Architecture – для независимости бизнес-правил от фреймворков и UI, фокусируясь на use cases.

  • CQRS – для разделения операций записи и чтения данных, что критично для высоконагруженных систем.

  • Event Sourcing – для хранения изменений состояния как последовательности событий, обеспечивая аудит и временные запросы.

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

  • Гибкость и Адаптивность: Отдельные компоненты, построенные по разным принципам, могут заменяться или развиваться относительно независимо.

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

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

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

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

Важно осознать, что:

  • Отсутствие явно именованных папок вроде Presentation, Application, Domain или Infrastructure не отменяет логического разделения кода на слои с однонаправленными зависимостями.

  • Отсутствие отдельной папки Domain или подпапок для Aggregates, Entities или ValueObjects не исключает применения идей DDD.

  • Отсутствие папок "Ports" и "Adapters" или иной специфической номенклатуры не исключает следование идеям Hexagonal Architecture, если бизнес-логика изолирована от внешних зависимостей через четко определенные интерфейсы (порты) и их реализации (адаптеры).

  • Неиспользование стандартных фреймворков для инверсии контроля или зависимостей не означает отказ от принципов Clean Architecture, если зависимости направлены внутрь, а бизнес-правила остаются независимыми.

И так далее, думаю суть вы поняли.

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

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


Примеры

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

Пример #1: Игровой сервис (онлайн-магазин внутри игры)

Паттерны:

  • Clean Architecture – ядро с правилами покупок, независимое от движка игры.

  • DDD – чёткие границы контекстов: "Инвентарь", "Платежи", "Акции".

  • Layered Architecture – изоляция UI, бизнес-логики и данных инфраструктуры (Redis + PostgreSQL).

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

Пример #2: Forex-трейдинговая платформа

Паттерны:

  • CQRS – Команды: исполнение ордеров (высокая нагрузка на запись). Запросы: аналитика котировок и история сделок (частое чтение).

  • Event-Driven Architecture – обработка в реальном времен доменных ивентов

  • Hexagonal Architecture – адаптеры для: Брокерских API (MetaTrader, FIX-протокол), Данных Reuters/Bloomberg, Систем аудита для регуляторов.

Результат: Платформа обрабатывает 10,000+ рыночных событий в секунду без потерь данных. Аудит сделок для compliance занимает минуты (ранее — часы).

Пример #3: Платежная система (СRM)

Паттерны:

  • CQRS – Разделения работы с БД на Команды и Запросы для уменьшения нагруски на БД.

  • DDD – чёткие границы контекстов для соблюдениия (low coupling high cohesion)


Быстрый старт с Enterprise Skeleton

Для написания примера c открытым доступом на GitHub - я воспользовался Enterprise Skeleton. Он разработан с прицелом на максимальное ускорение процесса разработки. Он предоставляет готовую к использованию основу, позволяя командам мгновенно приступить к реализации бизнес-логики, минуя этапы рутинной настройки инфраструктуры.

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

Все начинается с клонирования репозитория, как показано в разделе установки. Затем, буквально несколькими командами, вы можете выбрать необходимые вам сервисы, просто раскомментировав соответствующие строки в файле конфигурации. Хотите использовать Nginx и PostgreSQL? Просто убедитесь, что server=nginx и database=postgres активны. Нужен Redis для кэширования? Раскомментируйте строку cache=redis.

Более того, смена базового PHP-фреймворка также тривиальна. Одной командой make framework laravel вы можете переключиться с Symfony (установленного по умолчанию) на Laravel, адаптируя шаблон под предпочтения вашей команды. Финальным шагом является запуск команды `make install`. Эта команда автоматически установит все необходимые зависимости и поднимет окружение, готовое к работе.

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


DDD

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

Применение стратегических паттернов

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

Bounded Contexts (Ограниченные контексты)

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

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

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

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

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

Про то какие есть еще способы комуникации между контекстамы помимо Shared вы можете почитать в моей статте: Связывая Контексты: Руководство по Эффективному Взаимодействию

Ubiquitous language

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

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

Context Map

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

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

Тактические паттерны в каждом контексте

Внутри каждого ограниченного контекста мы применяем тактические паттерны DDD для моделирования предметной области на уровне кода.

OrderContext:

  • Aggregates (Агрегаты): OrderAggregate представляет собой границу транзакции и обеспечивает согласованность связанных сущностей (Order, OrderItem).

  • Entities (Сущности): Order (сам заказ), OrderItem (позиция в заказе) обладают уникальной идентичностью, сохраняющейся во времени.

  • Value Objects (Объекты-значения): OrderId (идентификатор заказа), Money (представление денежных сумм) не имеют собственной идентичности и определяются своими атрибутами.

  • Enums (Перечисления): OrderStatus (статус заказа: новый, оплачен, отменен и т.д.) представляют собой ограниченный набор возможных значений.

  • Domain Events (Доменные события): OrderCreated (заказ создан), OrderPaid (заказ оплачен) фиксируют значимые события, произошедшие в домене.

  • Domain Services (Доменные сервисы): OrderTotalCalculator инкапсулирует бизнес-логику, которая не принадлежит ни одной конкретной сущности или агрегату (расчет общей суммы заказа).

  • Factories (Фабрики): OrderFactory используется для инкапсуляции сложной логики создания объектов OrderAggregate.

PaymentContext:

  • Aggregates (Агрегаты): PaymentAggregate управляет жизненным циклом платежа и обеспечивает согласованность связанной сущности Payment.

  • Entities (Сущности): Payment представляет собой информацию о проведенном платеже.

  • Value Objects (Объекты-значения): TransactionId (идентификатор транзакции), PaymentMethod (способ оплаты).

  • Enums (Перечисления): PaymentStatus (статус платежа: создан, успешно, отклонен и т.д.).

  • Domain Events (Доменные события): PaymentSucceeded (платеж успешно проведен), PaymentFailed (платеж не удался).

  • Domain Services (Доменные сервисы): PaymentGatewayService отвечает за взаимодействие с внешними платежными системами.

  • Factories (Фабрики): PaymentFactory используется для создания объектов PaymentAggregate.

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

Для обеспечения целостности нашей архитектуры и контроля зависимостей между слоями мы используем инструмент статического анализа кода Deptrac. Конфигурационный файл tools/deptrac/deptrac-domain.yaml позволяет нам следить за тем, чтобы зависимости внутри каждого контекста и между ними соответствовали нашей архитектурной задумке, предотвращая нежелательные связи и поддерживая низкую связанность.

Что у нас получилось

Вот такая структура папок

src/
  OrderContext
  PaymentContext
  Shared
  CONTEXT_MAP.md

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

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

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


Layer Architecture

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

  • Presentation Layer (Уровень Представления): Этот слой отвечает за взаимодействие с пользователем или внешними системами. Он включает в себя пользовательские интерфейсы (UI), API-эндпоинты, контроллеры, и все, что связано с представлением данных и приёмом пользовательского ввода. Задачей этого слоя является перевод запросов в формат, понятный для прикладного слоя, и отображение результатов его работы.

  • Application Layer (Прикладной Уровень): Ядро бизнес-логики в терминах конкретных сценариев использования (use cases). Этот слой координирует действия доменного слоя и инфраструктуры для выполнения определённых прикладных задач. Он не содержит бизнес-правил сам по себе, но оркестрирует их выполнение, вызывая методы доменных объектов и взаимодействуя с инфраструктурными сервисами.

  • Domain Layer (Доменный Уровень): Самый важный слой, содержащий сущности предметной области, их поведение, бизнес-правила и агрегаты. Он является "сердцем" системы, инкапсулируя основную бизнес-логику и обеспечивая согласованность данных. Этот слой полностью независим от других слоёв и не должен иметь зависимостей от них.

  • Infrastructure Layer (Инфраструктурный Уровень): Этот слой отвечает за реализацию общих технических задач, таких как доступ к базам данных, взаимодействие с внешними сервисами, логирование, кэширование и т.д. Он предоставляет необходимую поддержку для доменного и прикладного слоёв, позволяя им сосредоточиться на бизнес-логике.

Для строгого соблюдения зависимостей между этими слоями и предотвращения "круговых" или некорректных зависимостей, мы использовали инструмент статического анализа кода Deptrac. Его конфигурация, определённая в файле tools/deptrac/deptrac-layers.yaml, чётко определяет допустимые связи между слоями. 

Запуск команды make deptrac в любой момент разработки позволяет проверить архитектурную целостность и выявить любые нарушения зависемостей.

Что у нас получилось

Вот такая структура папок

src/
  OrderContext
    Application
    DomainModel
    Infrastructure
    Presentation
    README.md 
  PaymentContext
     Application
     DomainModel
     Infrastructure
     Presentation
     README.md
  Shared
  CONTEXT_MAP.md

В результате такого подхода мы можем с уверенностью заявить, что наше решение использует Многослойную (Layered) Архитектуру. И вот почему:

  • Чёткое разделение ответственности: Каждый слой имеет строго определённую функцию, что упрощает понимание и модификацию системы. Изменения в одном слое (например, изменение базы данных в Infrastructure Layer) минимально влияют на другие.

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

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

  • Гибкость в развитии: Возможность заменять или адаптировать отдельные слои без переписывания всей системы (например, переход на другой ORM или фреймворк UI).

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

Более детально про организацию кода можно почитать в статтях:


Clean architecture

Развивая принципы, заложенные в Domain-Driven Design (DDD) и Многослойной Архитектуре, мы пришли к необходимости применения Clean Architecture.

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

  • Entities (Сущности): В нашем случае это наш DomainModel в каждом контексте (OrderContext/DomainModel, PaymentContext/DomainModel). Здесь находится ядро бизнес-правил, агрегаты и сущности предметной области, которые остаются стабильными независимо от изменений в UI, базах данных или внешних сервисах. Это самый внутренний круг.

  • Use Cases (Варианты Использования) / Interactors: Этого у нас нету, мы реализуем на уровне Application в каждом контексте (OrderContext/Application/UseCases, PaymentContext/Application/UseCases). Именно здесь находится специфическая для приложения бизнес-логика, которая оркестрирует взаимодействие между доменными сущностями и внешними компонентами для выполнения конкретного сценария использования (например, "создать заказ", "инициировать платёж"). Use Cases определяют входные и выходные данные, а также бизнес-правила, которые должны быть выполнены для каждого варианта использования.

  • Interface Adapters (Адаптеры Интерфейса): Этот слой соответствует нашему Presentation и Infrastructure слоям. 

    • Presentation (OrderContext/Presentation, PaymentContext/Presentation) действует как адаптер для внешних систем или пользователей, преобразуя данные из формата, понятного для Use Cases, в формат, пригодный для отображения, и наоборот (например, REST-контроллеры, GraphQL-точки).

    • Infrastructure (OrderContext/Infrastructure, PaymentContext/Infrastructure) действует как адаптер для баз данных, внешних API, файловых систем и других технических деталей. Он реализует интерфейсы, определённые в Application или Domain слоях, для взаимодействия с внешним миром (например, репозитории, шлюзы).

  • Frameworks and Drivers (Фреймворки и Драйверы): Это самый внешний круг, который включает в себя конкретные технологии и фреймворки, которые мы используем. От фреймворка мы использем только Request/Response. Что позволяет легко поменять фреймвок при необходимости переписав только слой презинтации.

Реализация Use Cases в Application слое позволила нам сосредоточиться на действиях, которые пользователи или системы хотят выполнять, а не на технических деталях. Например, вместо того чтобы мыслить в терминах "ORM-запрос для создания заказа", мы мыслим в терминах "создать заказ с такими-то данными". Каждый Use Case получает на вход DTO (Data Transfer Object), передаёт его соответствующим доменным объектам, вызывает их методы для выполнения бизнес-логики, а затем возвращает результат, возможно, в виде другого DTO. Это делает бизнес-логику очень чистой, тестируемой и независимой от UI или базы данных.

Что у нас получилось

Вот такая стпуктура папок

src/
  OrderContext
    Application
        UseCases
           CreateOrder
           GetOrderDetails
 ….
  PaymentContext
     Application
        UseCases
           CreateDraftPayment
           InitiatePayment
  ….

В результате такого подхода мы можем с уверенностью заявить, что наше решение активно использует Clean Architecture. И вот почему:

  • Независимость от фреймворков: Мы можем легко поменять веб-фреймворк, ORM или даже базу данных без значительного влияния на основную бизнес-логику в DomainModel и Application слоях.

  • Тестируемость: Центральная бизнес-логика (Domain и Use Cases) полностью независима от внешних зависимостей, что делает её чрезвычайно лёгкой для тестировани�� в изоляции (юнит-тесты).

  • Независимость от UI: Изменение пользовательского интерфейса (например, с веб-интерфейса на мобильное приложение или консольное) не требует изменения бизнес-логики. Presentation слой является просто адаптером.

  • Независимость от баз данных: Аналогично, переход на другую базу данных (например, с реляционной на NoSQL) или изменение схемы не влияет на Domain и Application слои, поскольку они взаимодействуют с Infrastructure через абстракции (интерфейсы репозиториев).

  • Согласованность с DDD и Layered Architecture: Clean Architecture прекрасно интегрируется с нашими ранее принятыми решениями, такими как DDD (обеспечивая фокус на домене) и Layered Architecture (предоставляя чёткие правила зависимостей и организации слоёв).


CQRS

После того как контексты были чётко определены и структурированы с помощью слоёв, а внутри Application у нас теперь есть Use Cases, то  следующим важным шагом стало проектирование коммуникации между слоями. На этом этапе мы приняли ещё одно фундаментальное архитектурное решение, применив паттерн CQRS (Command Query Responsibility Segregation).

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

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

OrderContext - В контексте управления заказами мы чётко разделили операции:

  • POST /api/orders – Это пример команды. Данный эндпоинт предназначен для создания нового заказа. При получении запроса, система выполняет определённый набор бизнес-правил, изменяет состояние предметной области (создаёт новый заказ), и, возможно, генерирует события. Операция является идемпотентной и изменяющей.

  • GET /api/orders/{id} – Это пример запроса. Данный эндпоинт предназначен исключительно для получения деталей существующего заказа по его идентификатору. Он не изменяет состояние системы, а лишь предоставляет данные, возможно, из оптимизированного для чтения хранилища.

PaymentContext - Аналогично, в контексте обработки платежей:

  • POST /api/payments – Это команда, инициирующая новый платёж. Она фиксирует намерение пользователя совершить платёж, изменяет соответствующее состояние в системе платежей (например, устанавливает статус "ожидает подтверждения"), и, возможно, взаимодействует с внешней платёжной системой.

  • POST /api/payments/{id}/callback – Это также команда, нонициируемая внешней платёжной системой. Она обрабатывает обратный вызов (webhook) от платёжной системы, который информирует о статусе платежа (успех, отказ и т.д.). Эта команда также изменяет состояние соответствующего платежа в нашей системе.

Что у нас получилось

Вот такая структура папок

src/
  OrderContext
    Application
        UseCase
           CreateOrder
                CreateOrderCommand
                CreateOrderHandler
           GetOrderDetails
                GetOrderDetailsQuery
               GetOrderDetailsHandler
 ….
  PaymentContext
     Application
        UseCase
           CreateDraftPayment
               CreateDraftPaymentCommand
               CreateDraftPaymentHandler
           InitiatePayment
               InitiatePaymentCommand
               InitiatePaymentHandler
  ….

В результате такого подхода мы можем с уверенностью заявить, что наше решение активно использует Архитектуру CQRS. И вот почему:

  • Независимое масштабирование: Операции записи (команды) и чтения (запросы) часто имеют разные требования к производительности и масштабированию. CQRS позволяет оптимизировать и масштабировать их по отдельности, например, используя различные базы данных или кэши для чтения.

  • Оптимизация производительности: Разделение позволяет создавать специализированные модели данных для чтения (Read Models), которые идеально подходят для запросов и отображения, и оптимизированные для записи модели (Write Models), обеспечивающие согласованность и целостность данных при выполнении команд.

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

  • Повышенная гибкость: Позволяет использовать разные технологии для хранения и обработки команд и запросов (например, Event Sourcing для команд и реляционные БД для запросов).

  • Более чёткие API: Разделение на команды и запросы делает API более интуитивно понятным и предсказуемым. Легко отличить, какая операция изменяет данные, а какая только их читает.


Event-driven architecture

Для обеспечения гибкой и слабосвязанной коммуникации между нашими контекстами, а также для эффективной реализации разделения чтения и записи (CQRS), мы внедрили Event-Driven Architecture (EDA) в сочетании с Event Sourcing.

Event-Driven Architecture для Коммуникации

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

Event Sourcing для CQRS

Event Sourcing стал ключевым элементом нашей стратегии синхронизации между сторонами записи (Command Side) и чтения (Query Side) в рамках CQRS:

  • Command Side (Сторона Записи): Когда поступает команда (например, создать заказ, инициировать платёж), мы обрабатываем её, применяем бизнес-логику и, в случае успешного выполнения, генерируем одно или несколько доменных событий, отражающих произошедшее изменение состояния. Вместо того чтобы напрямую изменять текущее состояние объекта, мы сохраняем эти события в Event Store (журнал событий). Таким образом, история всех изменений состояния системы становится явной и неизменяемой.

Query Side (Сторона Чтения): Для обеспечения эффективного чтения данных, мы используем проекции. Сторона чтения подписывается на поток событий из Event Store и использует их для построения и обновления оптимизированных представлений (Read Models), которые идеально подходят для выполнения запросов. Эти проекции могут храниться в различных типах хранилищ данных, более подходящих для задач чтения.

Важные аспекты реализации

  • Eventual Consistency (Отложенная Согласованность): Поскольку обновление проекций происходит асинхронно после записи события, между моментом выполнения команды и обновлением представлений для чтения может существовать небольшая задержка. Мы учитываем это в проектировании пользовательского опыта.

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

  • Идемпотентность Обработки Событий: Для обеспечения надёжности, обработчики событий на стороне чтения являются идемпотентными, то есть повторная обработка одного и того же события не приводит к нежелательным побочным эффектам.

В качестве Event Store может быть:

  • Специализированная БД для событий (EventStoreDB, Axon Server, Chronicle)

  • Обычная реляционная БД

  • Очереди сообщений

  • Гибридное решение

Проблемы и их решения:

  • Гарантированная доставка событий (Transactional Outbox): Для обеспечения надёжной отправки событий после изменения данных, мы используем паттерн Transactional Outbox. Это означает, что при выполнении транзакции записи (например, сохранении агрегата), мы одновременно сохраняем исходящие доменные события в специальную таблицу "исходящих сообщений" (outbox). Отдельный процесс затем асинхронно извлекает эти сообщения из outbox и отправляет их в нашу очередь сообщений. Это гарантирует, что событие будет опубликовано только после успешного коммита основной транзакции.

  • Повторная обработка событий и предотвращение потери сообщений: Мы полагаемся на возможности нашей очереди сообщений (укажите, какую именно, если хотите) для обеспечения как минимум однократной доставки сообщений. Кроме того, мы можем использовать механизмы отслеживания прогресса обработки событий на стороне потребителя, чтобы избежать повторной обработки в случае сбоев (отдельная таблица для отслеживания прогресса). В специализированных Event Store также могут быть доступны механизмы Catch-up Subscriptions, позволяющие новым подписчикам или восстанавливающимся сервисам обработать всю историю событий.

Что у нас получилось

Вот такая структура папок

src/
  OrderContext
    Domain
        Event
 ….
  PaymentContext
     Domain
         Event
...

В результате такого подхода мы можем с уверенностью заявить, что наше решение активно использует Event-Driven Architecture и Event Sourcing. И вот почему:

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

  • Надёжность: Использование Transactional Outbox гарантирует, что события, отражающие изменения состояния, будут надёжно доставлены для дальнейшей обработки.

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

  • Аудит и история: Event Sourcing предоставляет полную и неизменяемую историю всех изменений состояния системы, что полезно для аудита, отладки и понимания эволюции бизнес-логики.

Применение EDA и Event Sourcing стало мощным инструментом в нашем арсенале, позволив создать более гибкую, надёжную и масштабируемую систему.


Haxegonal architecture

Наряду с принципами Clean Architecture, мы активно применили паттерн Hexagonal Architecture, также известный как "Порты и Адаптеры". Этот подход является развитием идеи о том, что основная бизнес-логика приложения должна быть полностью изолирована от внешних механизмов и деталей реализации. Цель — позволить приложению быть независимым от баз данных, UI, тестовых инструментов или сторонних API.

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

Порты в Домене:

  • Интерфейсы Репозиториев: На уровне DomainModel в каждом контексте (/DomainModel/Repository/OrderRepositoryInterface) мы определили интерфейсы для работы с хранилищем данных.

  • Аналогично, для таких общих служб, как отправка сообщений или уведомлений, мы определили интерфейсы на уровне Shared/DomainModel/Services:

    • /Shared/DomainModel/Services/MessageBusInterface.php

    • /Shared/DomainModel/Services/NotificationInterface.php

  • Использования PSR

    • Psr\Http\Client\ClientInterface

    • Psr\Cache\CacheItemInterface

    • Psr\Log\LoggerInterface

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

Адаптеры в Инфраструктуре:

  • Использования HTTP-клиент типа Guzzle или HttpPlug

  • Использования Mololog 

  • Использования патерна Repository + Doctrine ORM

  • Shared/Infrastructure/MessageBus/SymfonyMessageBus.php — этот адаптер реализует MessageBusInterface, используя конкретную библиотеку (Symfony Messenger) для отправки сообщений, но доменный слой об этом не знает.

  • Shared/Infrastructure/Notification/NotificationAdapter.php — реализует NotificationInterface, инкапсулируя детали отправки уведомлений через конкретный сервис (например, email-провайдер или SMS-шлюз).

Что у нас получилось

Вот такая структура папок

src/
  OrderContext
       DomainModel
          Repository
       Infrastructure
          Persistence
               Doctrine
                  Repository
…
  PaymentContext
       DomainModel
          Repository
       Infrastructure
          Persistence
               Doctrine
                  Repository
…
  Shared
       DomainModel
           Services
                MessageBusInterface.php
                NotificationInterface.php
       Infrastructure
            HttpClient
            MessageBus
            Notification
  ….

В результате такого подхода мы можем с уверенностью заявить, что наше решение активно использует Hexagonal Architecture. И вот почему:

  • Полная изоляция доменного ядра: Центральная бизнес-логика и доменные сущности (DomainModel, Application слои) абсолютно не знают о внешних технологиях (базы данных, веб-фреймворки, шины сообщений, сторонние API). Они зависят только от своих собственных интерфейсов (портов).

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

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

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


Выводы

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

В итоге, у нас получился проект (ссылка на него тут), который:

  • Следует принципам Domain-Driven Design (DDD): Мы сосредоточились на предметной области, выделив чёткие ограниченные контексты (OrderContext, PaymentContext). Это позволило нам создать модели, которые точно отражают бизнес-процессы и терминологию, обеспечивая глубокое понимание и единый язык между разработчиками и экспертами предметной области.

  • Использует Многослойную (Layered) Архитектуру: Каждый контекст был структурирован на логические слои – Presentation, Application, DomainModel и Infrastructure. Такое разделение ответственности обеспечило чистоту кода, изоляцию доменной логики и высокую тестируемость. Строгие правила зависимостей между слоями, поддерживаемые Deptrac, гарантируют архитектурную целостность.

  • Применяет Clean Architecture: Мы пошли дальше, внедрив принципы Clean Architecture, где бизнес-логика (Domain и Use Cases) находится в центре, будучи независимой от внешних деталей, таких как базы данных, UI или сторонние сервисы. Это обеспечивает максимальную гибкость, тестируемость и долговечность системы.

  • Использует CQRS (Command Query Responsibility Segregation): Разделяя операции, изменяющие состояние системы (команды), от операций чтения (запросы), мы смогли оптимизировать производительность, улучшить масштабируемость и значительно упростить обработку сложной бизнес-логики в каждом контексте.

  • Применяет Event-Driven Architecture (EDA): Для синхронизации между хранилищами чтения и записи в CQRS, a также генерация доменных событий после выполнения успешных операций. Эти события могут использоваться для асинхронной коммуникации между контекстами или для построения читаемых моделей, что является ключевым аспектом Event-Driven Architecture, повышая гибкость и расширяемость системы за счёт слабой связанности.

  • Следует принципам Hexagonal Architecture (Порты и Адаптеры): Этот паттерн является синонимом Clean Architecture, подчёркивая, что ядро приложения взаимодействует с внешним миром только через "порты" (интерфейсы), а конкретные реализации этих портов ("адаптеры") находятся на периферии. Это обеспечивает, что наше доменное ядро не зависит от внешних технологий, делая его по-настоящему "подключаемым".

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


Полезные ссылки

Комментарии (0)