Согласование Laravel и DDD
- ·
-
Эта статья фокусируется на поиске общей основы для использования проектирования, ориентированного на предметную область (Domain-Driven Design или DDD) в фреймворке Laravel, способного использовать максимум возможностей Laravel.
Введение
Если вы следите за некоторыми ключевыми участниками сообщества Laravel, то вы, возможно, заметили некоторые напряженности между любителями Laravel — выступающими за чистый и простой код — и экспертами PHP — выступающими за разделение обязанностей любой ценой. Хотя я, очевидно, являюсь приверженцем Laravel, я часто оказываюсь погруженным в увеличивающуюся сложность моих приложений в долгосрочной перспективе. Поэтому я решил, что для меня настало время погрузиться в мир проектирования, ориентированного на предметную область (DDD) (Evans 2004).
Университет научил меня многому, начиная от технических шаблонов проектирования до ориентированных на предприятия архитектур, но чтение синей книги позволило мне сосредоточиться на том, почему мой софт должен работать так, а не на том, как это сделать, и применять эту бизнес-логику прямо в своих приложениях. Это было отличным чтением, но когда ты с энтузиазмом возвращаешься к своим Laravel-проектам, пытаясь применить то, что ты узнал… замираешь ⛄️.
Следовательно, цель этой статьи — более глубоко изучить концепции DDD, применяемые к фреймворку Laravel. На самом деле они не совсем взаимоисключающие. Нам просто нужно больше дисциплины как разработчикам, чтобы это работало. Как и любые религиозные книги, синяя книга подвержена интерпретациям и контекстам. Здесь я поделюсь своей интерпретацией в контексте Laravel.
Правила конечной цели
Прежде чем перейти к всему этому, давайте на минутку проанализируем, что мы хотим достичь и чего хотим избежать в этом путешествии. Мы, используя стиль консультаций, создадим 3 правила для этого:
- Правило A: Сосредоточьтесь на домене приложения. Без этого мы теряем саму суть DDD. Наши объекты модели должны соответствовать нашему домену и следовать общепринятому языку проекта.
- Правило B: Оставайтесь верны фреймворку. Борьба с фреймворком утомительна, не масштабируется и бесполезна. Мы хотим, чтобы было легко обновляться до следующих версий. Мы не только хотим иметь доступ к всем преимуществам фреймворка, но и дискретно использовать его мощь внутри нашей области.
- Правило C: Держите все простым. Оно в какой-то степени связано с предыдущим правилом, но мы не хотим создавать и внедрять десятки классов, когда достаточно User::find(1) или config(‘app.name’). Кроме того, мы не хотим поддерживать две версии наших модельных объектов: одну, которую понимает наш домен, и другую, которую понимает Laravel.
Несоответствие в структуре слоя
Один из ключевых концептов DDD — это изоляция логики вашей области от остальной части вашего приложения. Это помогает разработчикам лучше понимать свое программное обеспечение, когда сложность увеличивается. Чтобы достичь этого состояния, Эванс советует разделить программу на слоистую архитектуру. Это можно сделать несколькими способами — главное, чтобы область была изолирована, но в книге используется следующая архитектура.
Первое, что я сделал, когда прочитал эту часть книги, это попытался соотнести фактические папки Laravel с этими слоями.
Как видно, папки разбросаны повсюду, и иногда сложно определить, в каком слое находится та или иная папка. Если мы просто сосредоточимся на Eloquent, то увидим, что он является частью инфраструктуры Laravel (в папке vendors), но большая часть нашей логики предметной области находится в моделях, которые расширяют его и используются в слое приложения.
Мы можем соглашаться или не соглашаться с любой из этих структур и их компонентов, но факт в том, что они не сочетаются между собой «из коробки».
К компромиссной структуре
Это ядро статьи. Мы начнем с пустой слоистой структуры и постепенно достигнем состояния, когда Laravel и DDD будут сосуществовать вместе.
Сфокусироваться на домене
Давайте начнем с добавления всех строительных блоков реализации Domain-Driven в наш слой домена. Наша область семантически разделена на различные модули, которые содержат свои собственные объекты модели. Эти объекты могут иметь идентификатор, то есть быть сущностями, или могут быть идентифицированы только значением своих атрибутов, то есть объектами-значениями. Группы сущностей и объектов-значений формируют агрегаты.
Некоторые понятия домена не предназначены для сохранения или имеют состояние, которое не развивается, их называют сервисами. Их роль заключается просто в вычислении некоторых выходных данных и/или выполнении некоторых действий на основе некоторых входных данных.
Фабрики и репозитории являются частью домена, но не являются частью модели, представляющей бизнес-логику. Репозитории — это интерфейсы, которые инкапсулируют способ, которым мы сохраняем наши модели, а фабрики — это инкапсуляция того, как создавать конкретный объект, чтобы освободить последний от этой ответственности.
Ничего нового здесь. Мы просто устанавливаем основу для реализации нашей доменной модели.
Квадраты — это классы, шестиугольники — интерфейсы.
Использование фреймворка в вашем домене.
Это момент, когда возникает замираешь. Как мы можем использовать всё, что может предложить Laravel, когда вся наша логика находится за пределами слоя приложения?
Мы могли бы рассматривать все объекты домена как обычные PHP-объекты (POPO), но это потребовало бы наличия отдельных объектов для маппинга данных и отказа от активной записи Eloquent, что привело бы к потере значительной части мощности Laravel и нарушению правила B.
Ещё один вариант — сделать, чтобы эти сущности и агрегаты расширяли Eloquent, чтобы воспользоваться всей его мощью. Однако это легче сказать, чем сделать, и этот выбор сопряжен с рядом компромиссов и искушений, которые могут заставить нас потерять фокус нашей предметной области, нарушая тем самым правило A.
Однако, это интересный вызов, который нужно принять, чтобы полностью использовать возможности Laravel с использованием DDD, и именно этот вызов мы и рассмотрим. Во второй части этой статьи, посвященной изучению способов приручения Eloquent beast внутри нашего слоя домена, мы обсудим последствия этого выбора.
Это также возможно сделать, чтобы использовать возможности Laravel Jobs в наших доменных сервисах. Все, что нужно сделать, это добавить несколько трейтов к нашим сервисам. Это значительно облегчит возможность диспетчеризации или постановки на очередь наших сервисов в приложении с использованием Horizon. Аналогично, если отправка электронной почты является ключевым элементом нашей домены, то ничто не мешает нам сделать наши объекты Mail наследниками класса Mailable.
Ключевой момент здесь — баланс между использованием преимуществ Laravel и не перегружением нашей предметной области техническими деталями. В тот момент, когда мы принимаем тот факт, что технический парадигма, которую мы используем, — это The Laravel Framework™, а не общий объектно-ориентированный язык, имеет смысл использовать и расширять классы, трейты и интерфейсы Laravel в нашей предметной области. Это наша новая база. Это наш новый пустой класс.
Если новый разработчик взглянет на объект Service в нашей области и увидит строку…
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
… он/она не будет травмирован(а).
Да, это требует большей дисциплины у разработчиков, чтобы они не переходили границу и не превращали модели объектов в конфигурационные файлы Laravel, но это далеко не невозможно.
На последнее замечание следует отметить, что поскольку слой инфраструктуры находится под слоем домена, то его использование допускается правилами DDD. Когда мы добавляем папку /vendors в слой инфраструктуры, это даёт нам право использовать некоторые преимущества Laravel внутри нашего домена. В соответствии с этой логикой мы также можем использовать наши папки /configs или /storage внутри объектов нашего домена.
Исключением здесь будет все, что связано с нашей /database, поскольку она уже инкапсулирована интерфейсами репозиториев. Кроме того, доступ к базе данных за пределами узкого места, предоставляемого интерфейсами репозиториев, может обойти некоторые проверки инвариантности и сделать наш код гораздо сложнее для понимания.
Зеленый — наш домен, серый — все, с чем мы знакомы в Laravel.
Используйте доменную модель в вашем приложении на Laravel.
Эта часть очень простая. Мы просто добавляем в нашу слоистую архитектуру все концепции Laravel, которые мы все знаем.
- Маршруты могут быть семантически восприняты как точка входа в наше приложение на основе желаемого интерфейса. По умолчанию Laravel даже предоставляет один файл на интерфейс — web.php, api.php, console.php и т.д.
- Затем наш запрос проходит через цепочку промежуточных слоев (middleware), относящихся к слою приложения. Прежде чем запрос достигнет логической части нашего кода, он может быть проверен на валидность через Requests или проверен наличие у пользователя определенных прав через Policies.
- Наконец, наши запросы достигают основной части нашего приложения через веб-контроллеры, API-контроллеры или команды. Все они находятся в интерфейсном слое, потому что они различаются в зависимости от интерфейса. Если наше приложение использует только веб-контроллеры — и это, вероятно, достаточно для большинства приложений, — тогда нам не нужно слишком беспокоиться о том, в каком слое мы находимся. Но когда мы начинаем разрешать доступ к нашему приложению через несколько протоколов, может быть хорошей идеей создать задания (jobs), которые будут вызываться через каждый из этих интерфейсов.
- Независимо от того, где вызывается основная логика нашего приложения, она всегда должна делегировать любую бизнес-логику на уровень домена, чтобы гарантировать, что не нарушаются никакие инварианты.
Заметьте, что до сих пор, за пределами слоя домена, ничего не изменилось в том, как мы используем Laravel. Мы по-прежнему имеем наши провайдеры, исключения, правила, почту и многое другое внутри нашей папки /app. Эти обсуждения помогают нам понять, что, в некотором смысле, Laravel уже разбит на полезную структуру, которая может соответствовать доменно-центричной архитектуре.
Прерывистые стрелки описывают логический рабочий процесс входящих запросов. Любые контроллеры, команды, задания или другие классы, выполняющие логический код, должны в конечном итоге делегировать работу на уровень домена.
Модули приложения
Одна последняя вещь, которой не хватает в нашей архитектуре, — это конкретная реализация интерфейсов внутри нашей доменной модели, например, интерфейсов репозиториев.
Поэтому для каждого модуля на уровне домена нам нужен соответствующий модуль на уровне приложения, который берет на себя ответственность за то, о чем уровень домена не может заботиться. Это подразумевает реализации репозиториев, а также некоторые локальные провайдеры, которые связывают интерфейсы с их реализацией.
Это также дает нам идеальное место для любой реализации границ внешней системы, которые могут использоваться внутри домена. Например, если наш домен использует интерфейс для инкапсуляции всей логики платежей и подписок, мы можем поместить его реализацию Stripe в соответствующий модуль. Если наш домен использует данные из внешней CRM, модуль приложения — идеальное место для некоторых мапперов данных и т.д.
Как и в случае с доменным слоем, этот подмножество приложения заставит нас добавить некоторую дополнительную логику в наши приложения Laravel.
Окончательная архитектура:
- Зеленый цвет относится к домену
- Синий цвет относится к любой технической реализации интерфейсов домена
- Серый цвет относится ко всему, что мы знаем в Laravel
- Гексагоны — это интерфейсы
- Пунктирные стрелки образуют логический рабочий процесс входящих запросов
Примечание по реализациям репозиториев.
Хотя наши агрегаты и сущности используют Eloquent, я считаю важным использовать репозитории для создания узкого места доступа к нашим объектным моделям и их сохранения — по крайней мере, чтобы освободить их от этой задачи.
Это не означает, что мы не можем использовать мощь Eloquent в реализации этих репозиториев. Все, что нам нужно сделать, это обернуть вызовы Eloquent\Builder или Query\Builder в методы, которые имеют смысл для нашей предметной области и нашего единого языка.
К сожалению, это приводит к использованию другого статического вызова, чем мы привыкли …
// Из этого:
Customer::find('000-11-1111');
// К этому:
CustomerRepository::findBySocialSecurityNumber('000-11-1111');
// Обратите внимание, что здесь мы используем режим реального времени
// Фасады, чтобы сохранить схожий синтаксис.
… и добавление двух файлов — не выглядит хорошо для правила C!
namespace Domain\Accounting\Repositories;
interface CustomerRepository
{
public function findBySocialSecurityNumber($ssn);
}
namespace App\Accounting\Repositories;
use Domaine\Accounting\Customer;
class CustomerRepository
{
public function findBySocialSecurityNumber($ssn)
{
return Customer::find($ssn);
}
}
Плюс поставщик услуг, который связывает их воедино…
Однако, у нас есть несколько значительных преимуществ с таким подходом:
- Мы больше не зависим от названий методов Laravel, и мы можем использовать некоторые названия, которые отражают язык нашей предметной области. Например, здесь метод findBySocialSecurityNumber() намного более явный, чем find().
- Когда у нас есть очень сложные запросы для вычисления, они уже абстрагированы в домене.
- Мы не обязаны сгруппировывать наши объектные модели с помощью локальных областей или других методов, похожих на репозитории. Это особенно важно сейчас, когда наши модели Eloquent являются частью домена.
Возможная структура папок:
Правило B заставило нас оставаться верными Laravel, поэтому на самом деле мы не изменили много аспектов его структуры. Здесь я предложу простую структуру папок для конкретной реализации того, о чем мы обсуждали до сих пор.
- Мы начинаем с чистой установки Laravel.
- Мы добавляем папку /domain в корневую директорию нашего проекта и загружаем ее содержимое в пространство имен Domain. Не забудьте выполнить composer dump-autoload.
// composer.json
"autoload": {
"psr-4": {
"App\\": "app/",
"Domain\\": "domain/"
}
},
- Мы добавляем папку для каждого модуля в папке /domain.
/domain
|___ /ModuleA
|___ /ModuleB
|___ /ModuleC
|___ /Factories
|___ /Repositories (interfaces)
|___ AggregateA1.php
|___ EntityA2.php
|___ EntityB.php
|___ CRMInterface.php
|___ PaymentInterface.php
|___ ValueObjectA3.php
|___ ValueObjectC.php
|___ ...
- Мы добавляем папку для каждого модуля в папке /app.
/app
|___ ...
|___ /ModuleA
|___ /ModuleB
|___ /ModuleC
|___ /CloseIO
|___ /Repositories (implementation)
|___ /Stripe
|___ AggregateA1Resource.php (if using API interface)
|___ ModuleCServiceProvider.php
|___ ...
Что мне нравится больше всего в наличии отдельной папки для каждого модуля в слое приложения, так это то, что каждый раз, когда нам нужна поддержка домена в какой-то логике приложения, мы имеем идеальное место для этого с собственным поставщиком служб.
Также обратите внимание, что я не обернул все модули в слое приложения внутри папки /Modules или что-то подобное. Это делает наши пространства имен более понятными и создает соответствующую конвенцию между нашим слоем домена Domain\Shipping\Cargo и нашим слоем приложения App\Shipping\Cargo.
Заключение
Реализация Domain-Driven Design всегда будет вызывать вызов, независимо от того, какой фреймворк мы используем. Синяя книга и написание этой статьи помогли мне в процессе понимания того, как использовать DDD без того, чтобы это вызывало разочарование в нашем использовании Laravel. Моя цель не заключается в том, чтобы предоставить учебник структуры, которую каждый должен следовать, но чтобы поделиться моим текущим видением этого вопроса и установить основу очень интересного процесса открытия.
Как обещано, вторая часть этой статьи будет посвящена тому, как использовать Eloquent внутри слоя домена и как справляться с возникающими трудностями.