В 2017 году Matthias Noback (автор A year with Symfony) опубликовал цикл из трех статей, в котором описал свои взгляды на идеальную архитектру корпоративных приложений, сформировавшуюся за долгие годы практики.Первая часть является вводной и не представляет особого интереса(можно ознакомитсья в оригинале). Переводом второй является данная статья. Перевод третьей будет доступен в скором времени.
Для меня, одним из обязательных требований, к "чистой" архитектуре, является грамотное разделение кода приложения по слоям. Сам слой не делает ничего, вся соль в том, как он используется и какие ограничения накладываются на компоненты, ему принадлежащие. Давайте немного пофилософствуем, перед тем как рассмотреть конеретные практические приемы.
Зачем нужны слои
- Слои помогают спрятать/защитить, то, что находится под ними. Можно воспринимать слой как фильтрующий барьер: данные, передающиеся через него, должны быть провалидированны перед тем как перейти на следующий. Они должны быть приведены к формату, который позволит другим слоям корректно работать с ними. Слой также определяет, какие данные и функции из более глубокого слоя могут быть использованы во внешних.
- Слои четко разграничивают ответственности, а следовательно расположение классов в вашем коде. Если вы добьетесь строгих договоренностей внтури вашей команды, о том какие слои используются в вашем приложении и за что отвечате каждый из них, то вам всегда будет легко найти нужный класс или определится куда следует добавлять новый, просто зная его предназначение.
- Благодаря использованию слоев, можно свободно менять приоритет и порядок этапов разработки приложения. Вы можете разрабатывать проект последовательно, начиная от ядра бизнесс логики, накладывая слой за слоем на него. А можно инверсировать процесс и начать с разработки слоя взаимодействия с пользователем. Этот пункт довольно важен для нас, так как благодаря ему можно разработать большую часть приложения до принятия решения о используемой ORM, БД, фреймворке, и т.д
- Большое количество старого софта содержит код, неразделенный на слои, который можно назвать "спагети" кодом: вы можете вызывать и использовать всё что хотите, любые методы и структуры в любой части проекта. Используя систему слоев(правильным образом) можно добиться высокого уровня разделения ответсвенности( separation of concerns). Если вы задокументируете эти правила и будете следить за их соблюдением на код ревью, то вы здорово уменьшите скорость скатывания вашего проекта в ранг
гавнокода"технического долга" Вы, конечно же, пишите тесты. Грамотная спроектирвоанная система слоёв, невероятно упрощает тестирование. Различные типы тестов подходят для кода из разных слоев. Назначения каждого теста становится более очевидным. Набор тестов в целом становится более стабильным и более быстроработающим.
Однако, у нас есть паникёр из твиттера:
ООП версия спагетти кода — это код лазанья, с переизбытком слоев.
Лично я никогда не встречал код-лазанью, зато видел очень много лапшекода. Правда бывало, что я писал код, в котором допускал серьезные архитектурные ошибки, и неверно разделял приложение на слои, что приносило некоторые проблемы. В этой статье я описываю, как мне кажется, наилучший набор слоев, большая часть из которых описана в книге Vaughn Vernon "Implementing Domain-Driven Design"(ссылка ниже). Прошу заметить, что слои не имеют жесткой привязки к DDD, хотя они и дают возможность создавать чистые доменные модели, при соответсвующем желании у разработчика.
Структура директорий и неймспейсов
Внутри src/
у меня есть директории для каждого контекста(Bounded Context), например который я выделяю в своем приложении. Каждая из них также служит корневым неймспейсом для принадлежащих ей классов.
Внутри каждого контекста я создаю директории для каждого из слоёв:
- Domain
- Application
- Infrastructure
src/
{BoundedContext}/
Domain/
Model/
Application/
Infrastructure/
Кратко опишу каждый из них.
Слой 1 — Домен(модель/ядро)
Доменный слой содержит классы для известных DDD типов/паттернов:
- Entities
- Value objects
- Domain events
- Repositories
- Domain services
- Factories
- ...
Внутри папки Domain я создаю подпапку Model, внутри неё — директории для каждого из агрегата(Aggregate root). Папка с агрегатом содержит все связанные с ним штуки(объекты-значения, доменные события, интерфейсы репозиториев и т.д)
Обратите внимание, что код из доменного слоя никак не соприкасается с реальным миром. И если бы не тесты, то никто не мог бы обращаться к его объектам напрмяую(это делается через верхние слои). Тесты для доменной модели должны быть исключительно модульными. Т.к доменный слой не взаимодетсвует напрямую с файловой системой, сетью, бд и т.д, то мы получаем стабильные, независимые, чистые и быстрые тесты.
Слой 2 — (обёртка для домена): Прикладной слой
Прикладной слой(Application Layer) содержит классы команд и их обработчиков. Команда представляет собой указание на что-то, что должно быть выполненно.Это обычный DTO(Data Transfer Object), содержащий только примитивные значения. Всегда должен быть обработчик команды, который знает, как нужно выполнить конкретную команду. Обычно обработчик команды (также его называют application service) ответственен за все необходимые взаимодействия — использует данные из команды для создания(или извлечения из базы) агрегата, выполняет над ним какие то операции, может сохранить агрегат после этого.
Код этого слоя также можно покрыть юнит тестами, однако на этом этапе можно начинать писать и приёмочные. Вот хорошая статья на эту тему Modelling by Example от Константина Кудряшова.
Слой 3(обертка для прикладного) — Инфраструктура
Код, написанный в предыдущем слое, тоже не вызываается никем кроме тестов. И только после добавления инфраструктурного слоя, приложение становится рельно пригодным к использованию.
Инфраструктурный слой содержит код, необходимый для взаимодействия приложения с реальным миром — пользователями и внешними сервисами. Например, слой может содержать код для:
- Работы с HTTP
- Общением с БД
- Отправкой емэйлов
- Отправку пушей
- Получением времени
- Генерации случайных чисел
- И т.д и т.п
Код этого слоя надо покрывать интеграционными тестами(в терминологии Freeman and Pryce). Здесь вы тестируете всё по настоящему — настоящая база, настоящий вендорский код, настоящие внешние сервисы. Это возволяет убедиться в работоспособности тех вещей, которые не находятся под вашим контролем но используюся в вашем приложении.
Фреймворки и библиотеки
Все фреймворки и бибилотеки взаимодействующие с внешнем миром(файловой системой, сетью или базой) должны вызываться в инфраструктурном слое. Конечно, код домена и прикладного слоя часто нуждается в функциональности ORM, HTTP клиента и т.д. Но он должен использовать её только через более абстрактные зависимости. Как того и требует правило зависимостей.
Правило зависимостей
Правило зависимостей(сформулированное Robert C. Martin в The Clean Architecture) утвержадет, что на каждом слое приложения вы должны зависеть только от кода текущего или более глубокого слоя. Это значит, что код домена зависит только от себя, код слоя приложения от своего кода или домена, а код инфраструктурного слоя может зависеть от всего. Следуя этому правилу, нельзя сделать в доменном слое зависимость на код из инфрастуруктурного.
Но слепо следовать какому-либо правилу, непонимая в чем его истинный смысл — это довольно глупая затея. Так почему же вы должны использовать правило зависимостей? Следуя этому правилу вы гарантируете, что чистый код слоёв прикладного и доменного слоев не будет завязан на "грязный", нестабильный и непредсказуемый код инфраструктуры. Также, применяя правило зависимостей, вы можете заменить что угодно в инфраструктурном слое не прикасаясь и не изменяя код более губоких слоёв, что даёт нам богатые возможности для ротации и переносимости компонентов.
Этот способ уменьшения связанности модулей известен давно, как Dependency Inversion Principle — буква "D" в SOLID сформулированном Робертом Мартиным: "Код должен зависеть от абстракций, не от реализаций". Практическая реализация в большинстве ооп языков заключается в выделинии публичного интерфейса для всех вещей, от которых вы можете зависеть(интерфейс и будет абстракцией) и создании класса, реализующего этот интерфейс. Этот класс будет содержать детали, не имеющие значения для интерфейса, следовательно этот класс и будет реализацией, о которой говориться в inversion principle.
Архитектура: отсрочка технологических решений
Применяя предложенный набор слоёв вместе с правилом зависимостей, можно получить много плюшек при разработке:
- Можно много эксперементировать, прежде чем принимать такие важные решения, как, к примеру «используемая СУБД». Также можно спокойно использовать разные базы данных для разных случаев в рамках работы с одной и той же моделью.
- Можно отложить решение об используемом фреймворке. Это не позволит стать «приложением Symfony» или «Laravel проектом» в самом начале разработки.
- Фреймворки и библиотеки будут размещены на безопасном расстоянии от кода модели. Это здорово поможет при обновлении мажорных версий этих фреймворков и библиотек. Это также позволит минимизирвоать изменения в коде и трудозатраты, если вы когда-нибудь захотите использовать, к примеру, Symfony 3 вместо Zend Framework 1.
Все это выглядит крайне заманчиво: мне нравится возможность беспроблемной замены компонентов приложения + я люблю принимать важные архитектруные решения не перед стартом проекта(основываясь на своем прошлом опыте и догадках), а тогда, когда начинают проясняться реальные кейсы использования разных частей приложения, и я имею возможность выбирать подходящие решения исходя из существующих потребностей.
Заключение
Как упомяналось ранее, этот вариант расслоения приложения, хорошо уживается с любым фреймворком, т.к его место четко определено в инфраструктурном слое.
Некоторое считают, что в моём варианте "слишком много слоев". Я не понимаю, как можно считать 3 слоя, слишком большим количеством, но если вас это смущает то можете убрать прикладной. Вы потеряете возможность писать приемочные тесты(они станут чем то похожи на системные — более медленные и хрупкие) и не сможете тестировать один и тот же функционал вызываемый к примеру из веб-интерфейса и консольной команды без дублирования кода. В любом случае, вы сильно улучшите архитектуру вашего проекта благодаря раделению бизнесс логики и инфраструктурной части.
Осталось более подробно рассмотреть инфраструктурный слой. Так мы плавно перейдем к теме гексагональной архитектуры(порты и адаптеры). Но всё это, в следующей части.
Дальнейшее чтение
- Growing Object-Oriented Software Guided by Tests by Steve Freeman and Nat Pryce
- Screaming Architecture by Robert C. Martin
- The Clean Architecture by Robert C. Martin
- Implementing Domain-Driven Design, chapter 4: "Architecture" and chapter 9: "Modules", by Vaughn Vernon
Также можно ознакомиться с Deptrac — инструмент, помогающий соблюдать правила использования слоев и зависиомостей.
Комментарии (9)
Fantyk
26.03.2018 12:36Какая то каша в головах у людей со слоями. Каждый вроде бы и разделяет приложение на слои, но понимает что должно лежать в этих слоях по своему. И из текста статьи определенности не возникает.
Прикладной слой
… ответственен за все необходимые взаимодействия — использует данные из команды для создания(или извлечения из базы) агрегата, выполняет над ним какие то операции, может сохранить агрегат после этого.
Серьезно? Модели в контроллерах сохраняются?
Если не цитировать полностью, то посыл был сохранять в слое Домена.
Application Layer
This is a thin layer which coordinates the application
activity. It does not contain business logic. It does not
hold the state of the business objects, but it can hold
the state of an application task progress.
Сама последовательность слоев вводит в ступор
Слой 1 — Домен(модель/ядро)
Слой 2 — (обёртка для домена): Прикладной слой
Слой 3(обертка для прикладного) — Инфраструктура
Не припомню ни одного описания схемы Infrastructure->Application->Domain.
Классика из книжки Presentation->Application->Domain->Infrastructure:
Надеюсь в Описании инфраструктурного слоя
Работы с HTTP
Имеются ввиду все же библиотеки curl/Guzzle, и речь не о контроллерах.
Пишу это чтобы акцентировать внимание, т.к. по тексту это не ясно, но это очень важно для понимания.Fesor
26.03.2018 12:54Серьезно? Модели в контроллерах сохраняются?
это не контроллеры, это сервисы уровня приложения. Можете еще раз перечитать в чем отличие. Это имплементация юзкейса если хотите. И это именно та штука которая знает границу бизнес транзакции и, как следствие, знает когда надо коммитить транзакцию.
то посыл был сохранять в слое Домена.
нет, слой домена ничего не знает о хранении данных, максимум там есть интерфейсы сервисов которые уже занимаются этим делом и лежат в слое инфраструктуры. Но опять же domain layer не обязан знать границы бизнес транзакций. Но может (не зря ж мы агрегаты делаем), но как я говорил в таком случае штуки типа "отправить email-ы" надо переводить на какие-то более высокие материи вроде доменных ивентов. К этому не все готовы.
Имеются ввиду все же библиотеки curl/Guzzle, и речь не о контроллерах.
в статье не предлагается выделять отдельный ui layer поскольку он является частью инфраструктуры. "порты и адаптеры" в заголовке как бы намекают, хотя статья этот вопрос вообще не раскрывает. Вы можете разделять слой инфраструктуры и выделять там и UI layer, и DAL и т.д. но это уже вам решать. Опять же если сразу идти по пути полного разделения то будем есть лазанью.
В целом достаточно только двух слоев, намного важнее направление зависимостей между ними соблюдать.
arturpanteleev Автор
26.03.2018 13:30«порты и адаптеры» в заголовке как бы намекают, хотя статья этот вопрос вообще не раскрывает
еще есть 3 часть, она целиком про порты и адаптеры
Fantyk
26.03.2018 13:55это не контроллеры, это сервисы уровня приложения. И это именно та штука которая знает границу бизнес транзакции и, как следствие, знает когда надо коммитить транзакцию.
Спасибо за перевод с русского на русский. Теперь понял о чем шла речь. Со сложной бизнес логикой приходилось разделять «доменные сервисы» и «сервисы уровня приложения», но как то иерархически их не выделял. С одной стороны логично, с другой есть риск запутаться окончательно и запутать других.
в статье не предлагается выделять отдельный ui layer поскольку он является частью инфраструктуры.
Вот это не очень понятно. На картинке UI и Infrastructure в разных концах. Я разделяю это так: UI — то, что сделало запрос приложению (это интерфейс/вызов апи, консольные команды, какие то хуки), и Infrastructure — все внешние системы, взаимодействие с которыми инициировало уже приложение.Fesor
26.03.2018 14:25есть риск запутаться окончательно и запутать других.
когда я применял подобное для команды существовали простое ограничение: в сервисах уровня приложения должна отсутствовать логика (никаких
if-ов
или циклов с условиями, никаких исключений). Вся логика делигируется на уровень ниже.
У такого подхода есть масса плюсов и минусов. Основной плюс — можно почитать в одном месте что происходит при какой-то операции. Кому email-ы отправлюятся, ну и все смежные вещи. Но так или иначе минусы тоже не маленькие. Скажем, если вам надо поменять тригер для отправки нотификаций — придется убирать в одном месте и добавлять в другом.
Последний год я больше использую event driven подходы. Я как бы теряю возможность быстро глянуть что происходит в одной конкретной операции (хотя логирование позволяет всю картинку восстановить + есть мысли на счет того что бы генерить полную картину из кода, но пока в зачаточном виде), но зато очень легко можно менять местами какие-то элементы операций, делать цепочки операций (почти саги) и в целом с таким подходом код упрощается. Но я подозреваю что такой подход будет комфортен не всем.
Вот это не очень понятно. На картинке UI и Infrastructure в разных концах.
вы можете воспринимать инфраструктуру как оболочку приложения, леса своеобразные. То есть да, UI и скажем доступ в базу — это разные цонцы, но если представить это все как луковицу, то это окажется один и тот же слой.
arturpanteleev Автор
26.03.2018 13:29Серьезно? Модели в контроллерах сохраняются?
Нет, это не контроллер. Это, к примеру, сервисы, которые вызываются из контроллера или обработчики команд, сформированных в контроллере из внешнего запроса.Например, метод репозитория `save` я вызываю именно в этом слое. Да и в статье говорится как раз об этом: It then often persists the aggregate
Если не цитировать полностью, то посыл был сохранять в слое Домена.
Не припомню ни одного описания схемы Infrastructure->Application->Domain.
Картинка хорошая и правильная, но она не противоречит тому что написано в статье. Просто в ней слой Presentation(обозначен как UI, но по факту это может быть вызов из консоли, запуск воркера, вызов API и т.д) выделен отдельно от Infrastructure, это более популярный подход я тоже так делаю, однако в статье он почему то не выделяется как отдельный. Но по факту — это тоже взаимодействие с внешним миром, так что можно отнести это к инфраструктуре, принципиальной ошибки нет.
Классика из книжки Presentation->Application->Domain->InfrastructureИмеются ввиду все же библиотеки curl/Guzzle, и речь не о контроллерах..
Если не выделять Pressentation от Infrastructure, то получится что в Infrastructure будет и то и другое. А если делать такое разделение, то получится так как вы и сказали, всё верно.Fantyk
26.03.2018 13:57Отписался выше. Не совсем понимаю как можно объединять Pressentation и Infrastructure, если они с разных концов.
VolCh
26.03.2018 22:51+1Это слои не пирога, а луковицы или матрёшки, адаптеры внешнего мира к ядру или обёртки вокруг ядра для внешнего мира. Нет концов, есть центр и края.
Fesor
Я бы сказал что разделение на контексты намного важнее, поскольку каждый контекст может иметь свой набор слоев, свои подходы и свои ограничения, которые дают в итоге оптимальное соотношение сложности реализации и гибкости.
Упрощает тестирование снижение связанности системы. Если мы берем распространенные подходы, которые можно увидеть в 4/5 проектов, то слои там мало чем помогают.
А я встречал, и довольно часто. Особенно когда разработчики начинают загоняться по слоям, именно начинают (я и сам так делал). Это когда для внесения простых изменений приходится "прорезать" все слои. Пример — вывести новое поле на UI когда затрагиваются буквально все слои. Это простой способ проверить уровень связанности слоев между собой. В целом если разделение на слои не позволяет вам получить ту самую гибкость — стоит пересмотреть подход.
У Матиаса есть статья на тему лазанья кода, но она ничего на самом деле не объясняет (единственная полезная фраза — это на тему постоянного пересмотра правил и ограничений).
Следует конкретизировать что речь идет исключительно о persistence ignorance и устранении влияния инфраструктуры, хранилища данных, на вашу доменную модель. И как бы мы не обмазывались в слоеном, ограничения реляционных СУБД на то как происходит проектирование модели данных это никак не снимает. То есть опять же слои тут второстепенная штука. Конечно же выделение каких-то слоев подразумевается но вся соль в "каких-то", то есть важна конкретика.
Было бы неплохо приводить ссылки на конкретное определение "модульных тестов" ибо их множество. Лично я предпочитаю называть их "изолированными" поскольку так проще объяснять почему тот или иной тест не является изолированным (с модульными путаница выходит, ибо модули есть, а тот факт что мы зависимости не подменили как-то мелочь)
У меня вопрос на кого рассчитана эта статья… Те кто знают что такое агрегаты и могут спокойно их выделять, находить корни агрегатов и делать грамотную декомпозицию врядли нуждаются в подобных статьях где рассказываются какие-то уж совсем примитивные вещи. Мне наверное этим и не нравятся статьи Матиаса. Он из сложного вопроса часто берет самое простое.
существуют варианты при которых этот слой можно даже не вводить. Правда альтернатива — больше про event-driven подходы и многих это пугает.
Но это не точно. В целом весь этот слой уже должен затрагиваться приемочными e2e сценариями. Хотя на некоторые вещи конечно написать интеграционные тесты стоит (интеграция с вещами которыми владеете не вы, сторонние сервисы и т.д.).
стоит так же помнить о таком чудестном правиле как "принцип стабильных зависимостей". Например если мы подключили библиотеку
Money
и считаем ее стабильнее нашего кода (то есть с бухты барахты она не поменяется) — нет никаких проблем с тем что бы использовать эту библиотеку в нашем слое с бизнес логикой. Как никак, стандартная библиотека это тоже зависимость, но мы почему-то не паримся когда на нее завязываемся.То есть основная мысль — направление зависимости должно быть направлено от менее стабильных к более стабильным компонентам (стабильность тут выражается в том как часто меняется интерфейс в силу изменений требований).
опущен очень важный момент — кому принадлежит интерфейс. Интерфейс не должен принадлежать модулю, который имплементит этот интерфейс. Либо ложим интерфейс там где его потребляем, либо в какой-то промежуточный модуль. Именно так появляется инверсия зависимостей.
Большинство же воспринимают DIP как "лепим интерфейсе для всего подряд". Даже если не подразумевается более одной имплементации. Да и если разработчики не особо заморачиваются с такими вещами как контракты (а в случае с php можно лишь очень скупо описать контракт за счет языковых средств), то это просто может превратиться в бездумный культ карго.
Намного интереснее форсить мысль о том что в целом зависимости это не очень хорошо и нужно прилагать немало усилий что бы от них избавляться. Это и с тестированием намного больше помогает и вообще позволяет чуть по другому на проблемы смотреть.
для этого надо не бояться не юзать ORM когда придется. Большинство по какой-то причине к этому не готовы. Да и популярные решения этому не способствуют… Хотя такой опыт, как по мне, должен быть у каждого разработчика, просто что бы знать какие плюсы и минусы какой подход в себе несет.
возвращаясь к тому на кого нацелена эта статья… ума не приложу просто.
Deptrac отличный интерфейс, но с ним можно мухлевать. Он все же не очень догадливый в плане используемых типов.
У меня есть опыт написания плагинов для phan (можно и под phpstan) который анализирует что где юзается. За счет того что эти решения намного более качественно определяют типы, то и уровень контроля можно вводить неплохо.