К написанию этой статьи меня подтолкнуло изучение архитектурных подходов для Vue.js-проектов, а вдохновила - детально описанная методология Feature-Sliced Design.

К сожалению, PHP-сообществу не хватает подобных развернутых рекомендаций, да и вообще, каких-то общепризнанных стандартных подходов в структуре проекта.

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


В статье будет много ссылок на другие статьи, книги или видео. Я специально даю ссылки, а не пытаюсь заново всё объяснить, т.к., к сожалению, не возможно уместить всю информацию в одну статью. Да и по факту, это будет просто пересказ другими словами.
Но настоятельно рекомендую последовательно! ознакомиться со всеми материалами.
Только после этого, в вашей голове начнут формироваться правильные нейронные связи.
Всё по классике: отрицание, гнев, торг, принятие :)))

Побыстрее или подумать?

Любой из PHP-фреймворков имеет рекомендации по структуре папок, что и где надо создавать: Laravel, Symfony, Yii, CakePHP, CodeIgniter, Laminas, Spiral.

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

Так почему не устраивает разделение кода по типу файлов?

Всё дело в Low Coupling и High Cohesion и нескольких других критериях, подробно описанных в статье Как организовать структуру приложения.

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

  • с одной стороны - каждый проект уникален и не понятно как применять стандарты, особенно на начальном этапе,

  • с другой стороны - качество архитектуры кода будет сильно зависеть от уровня разработчика,

  • а с третьей стороны - за прошедший год написана "тонна" кода по базовым правилам, кто заплатит за переделку?

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

  • Где создать этот файл?

  • Где разместить этот метод?

  • Где граница ответственности?

  • и тп

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

В комментах к статье Как организовать структуру приложения прозвучало:

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

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

  • Какие Модули/Bounded Context есть в проекте?

  • Как тесно они между собой связаны?

  • Есть ли планы по-дальнейшему развитию?

И уже после ответа на эти вопросы надо начинать "рисовать карту местности".

А точно нужен весь этот overhead?

Однозначного ответа на этот вопрос нет, как и «серебряной пули»!

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

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

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

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

А тесты, это не только проверка функциональности и помощник при рефакторинге, но и лакмусовая бумажка, насколько правильно была выделена зона ответственности. Ведь хороший тест должен проверять результат только одного действия. Даже если это End-To-End тест.

Я не буду подробно останавливаться на том как правильно строить архитектуру проекта и что такое хороший тест. Об этом уже неоднократно писали:

В своей практике я пользуюсь парой "формальных" правил, когда не стоит "заморачиваться" и менять/усложнять базовые правила выбранного фреймворка:

  • Когда у вас простой проект (кол-во контроллеров и моделей, условно!, 8-10 штук),

  • Когда проект не собирается развиваться и усложняться (сайт-визитка, лендинг, блог и тп).

А следует задуматься над структурой кода, когда проект:

  • Имеет сложную логику (содержит десятки модулей),

  • Планируется долгосрочная поддержка и развитие функционала.


Может уже есть готовые подходы?

Да, попытки были и не раз:

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

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

Я же хочу подготовить набор подсказок и терминов или даже "формальных" правил, которые были бы понятны всем, а не только опытным ребятам. И этот набор был бы единообразен для разных команд и проектов, чтобы все могли говорить на "одном" языке.

Контроль соблюдения стандарта

Еще один важный момент это не только принять и описать какие-то правила в команде, но и осуществлять контроль по их соблюдению. Это ярко проявляется, когда в команду приходит новый сотрудник. Сначала ментор ему всё объясняет, показывает в WIKI где и что описано, а потом следит на Code Review, чтобы все эти правила соблюдались. Когда текучка небольшая, то команда быстро усваивает и запоминает все правила, но даже в таком варианте бывают случайные ошибки. Оптимальным вариантом для уменьшения "человеческого фактора" - является автоматизация таких проверок.

К счастью, у нас для контроля есть тесты. Но не обычные, а архитектурные. Инструментов много:

А вот хороших статей на эту тему мало:


Чистая структура

Новое — это хорошо забытое старое

Сейчас все говорят о DDD, Hexagonal Architecture, но давайте вернемся к истокам - модульности:

Почему именно модульность? Потому что всё остальное - это частные случаи модуля, попытки формализовать правила для определения зоны ответственности кода.

В каждом фреймворке уже существует возможность разделять по модулям: Package в Laravel, Bundle в Symfony, Module в Yii 2.0, Module в CodeIgniter, Plugin в CakePHP, Module в Laminas.

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

За годы практики у меня сформировался боле-менее универсальный подход и я назвал его: "Чистая структура" (The Clean Structure). Фактически это та же группировка по типу файлов, только обернутая в модульность и разделенная по архитектурным слоям с комментариями: что и где.

Основные термины и понятия

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

  • Модуль - максимально самодостаточная часть продукта отвечающая за определенный функционал. Каждый Модуль может содержать "публичные" (доменные) интерфейсы, сущности или события, определяющие границы его использования - API. Теоретически, модуль можно вынести в самостоятельную composer-библиотеку.

  • UseCase - "простая" реализация варианта поведения пользователя (что именно хочет сделать пользователь).

  • Interactor - более глобальный уровень для реализации поведения пользователя.

  • DTO - упрощенно, это "типизированный ассоциативный массив".

  • Event - специализированный DTO, сообщающий другому модулю о возникшем событии и делящийся на:

    • Уведомления - Прошедшее событие (синхронное или асинхронное, много получателей).

    • Запросы - Получение данных (только синхронное, только один получатель).

    • Команды - Создание, Изменение и Удаление данных (только синхронное, много получателей).

Материалы для самостоятельного изучения:

Универсальная структура папок и набор "формальных" правил: что и за что отвечает, и в какой папке создается файл

  • /src

    • Core - Ядро приложения, то от чего зависит работа большинства модулей, и код можно вынести в самостоятельную composer-библиотеку. Какие-то общепроектные события, исключения или обертки над фреймворком и vendor. Структура аналогична модулю, см ниже.

    • /%Название Группы модулей% - Группировка схожих по смыслу модулей, если необходимо.

      • /%Название Модуля%

        • /Application - Реализация любой бизнес-логики.

          • /Command - Изменение данных во внешних системах. Например, БД.

          • /Dto - Объекты для передачи из UseCase в Command, Query, Query и обратно.

          • /Factory - Фабричные классы для создания Dto, Response, ValueObject.

          • /Responder - Всё что требует специальной генерации контента вместо json для ответа пользователю.

            • /Template - email-шаблоны, Excel/Word-шаблоны, сообщения в Telegram.

            • %Название.php% - Какой-то генератор ответа либо выбор стратегии для генерации ответа. Например, CreateTaxExcelReport.php.

          • /Service - Самодостаточный функционал.

            • %Название.php% - Какая-то уникальная реализация логики. Например, калькулятор ABC-анализа.

          • /Query - Получение данных из внешних систем. Например, БД.

          • /UseCase - Варианты использования приложения, те обработка действий, которые выполняет пользователь.

        • /Domain - Предметная область. Обычно все эти объекты доступны во вне модуля, как публичное API модуля (Контракт).

          • /Dto - Объекты для передачи из Infrastructure и Presentation в Application и обратно.

          • /Event - Дополнительные действия/уведомления, возникающие в ходе выполнения UseCase.

          • /Exception - Исключительные сообщения во вне об ошибке в Application.

          • /Entity - Dto для описания структуры таблицы, используется в Command для создания записи в БД или требуется для ORM.

          • /Request - "Входящие" Dto в Controller и Console, часто со встроенной валидацией данных..

          • /Response - "Исходящие" Dto в Controller.

          • /Validation - Бизнес требования по валидации Dto.

          • /ValueObject - Узкоспециализированный DTO с валидацией, часто со встроенной валидацией данных.

          • %Интерфейс.php% - API (контракт) для взаимодействия между модулями или для инверсии зависимости между слоями модуля.

        • /Infrastructure - Здесь собран функционал для реализации подхода Anti-corruption layer и Framework Agnostic.

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

  • /tests

    • /Architecture - Архитектурные тесты.

    • /Stub - Все что требуется для выполнения тестов. Например, универсальный fake-jwt.

    • /Suite - Набор тестов для нашего приложения.

      • /%Название Группы Модулей%

        • /%Название Модуля%

          • ...

    • TestCase.php - Абстрактный родитель-обертка над фреймворком.

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

Примеры реализации

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

1. Простая HTML-страница

Яркий пример, как с помощью фреймворка создать быстро WelcomePage

2. Например, есть задача: проверять, что ресурс доступен (аля ping, только через HTTP)

Т.е. каждую секунду будет HTTP запрос. Для реализации этого функционала будет достаточно создать один файл: Presentation/Http/Controller/PingController

3. А теперь давайте подумаем, какая будет структура для задачи "Проверки доступности и работоспособности DB в проекте"

К примеру, такая проверка нужна 1 раз в минуту. Тут уже потребуется более сложная логика и для Laravel будет вот такая структура:

  • Application/Command/CheckDbWriter.php

  • Application/Query/CheckDbReader.php

  • Application/UseCase/DbHealthUseCase.php

  • Domain/Entity/HealthCheck.php

  • Presentation/Config/HealthCheckServiceProvider

  • Presentation/Listener/HealthChecker.php

На первый взгляд, кажется, что вся это вложенность - прям очень сильный оверхед, почему не создать 5 файлов в одной папке? А через два-три месяца, может потребоваться проверять доступность для Redis, RabbitMQ или ClickHouse.

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

  • /DbChecker

    • DbCheckerProvider.php

    • DbCheckerListener.php

    • DbHealthUseCase.php

    • CheckDbReader.php

    • CheckDbWriter.php

    • HealthCheckEntity.php

  • /RedisChecker

  • /ClickHouseChecker

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

  1. Если будет не опытный разраб, реализация бизнес логики может оказаться в контроллере, вплоть до запросов к БД. И это придется отслеживать на уровне Code Review.

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

Я бы сделал так:

  • Application/Command/CheckDbWriter.php

  • Application/Command/RedisWriter.php

  • Application/Command/ClickHouseWriter.php

  • Application/Query/CheckDbReader.php

  • Application/Query/RedisReader.php

  • Application/Query/ClickHouseReader.php

  • Application/UseCase/DbHealthUseCase.php

  • Application/UseCase/RedisHealthUseCase.php

  • Application/UseCase/ClickHouseHealthUseCase.php

  • Domain/Entity/HealthCheck.php

  • Presentation/Config/HealthCheckServiceProvider

  • Presentation/Listener/HealthChecker.php


Зависимости и контроль

Напомню общий принцип инверсии зависимостей "Чистой архитектуры"

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

Для себя я сформулировал следующие упрощенные правила.

  1. Не усложняй и так всё сложно!

  2. Между слоями существуют следующие зависимости:

    • Presentation, тк это часть фреймворка, может использовать Infrastructure, Application и Domain (и свой и чужой),

    • Infrastructure может использовать только Application и Domain (и свой и чужой),

    • Application может использовать Domain (желательно, только свой),

    • Domain может использовать только Domain (и свой и чужой),

  3. Для внедрения реализации из слоя Infrastructure в Application потребуется создать интерфейс в Domain.

  4. UseCase - не может использовать другой UseCase, тк теряется "простота". Но может агрегировать в себя Query и Command,

  5. Interactor - не должен использовать другой Interactor, тк, скорее всего, не правильно декомпозированы варианты использования. Но может агрегировать в себя Query, Command и UseCase,

  6. Модуль может использовать другие модули используя подход "Anticorruption layer": взаимодействие происходит через Domain интерфейсы другого модуля.
    Еще один вариант организовать взаимодействие между модулями - доменные события.

  7. DTO применяется для типизации входных и выходных данных в public и protected методах, private методы могу принимать и возвращать ассоциативный массив.

Обязательность интерфейсов

  • В рамках одного модуля не обязательно создавать Интерфейс, если существует только одна реализация. Но всегда помним о зависимостях между слоями.

  • Интерфейс создается всегда, если надо что-то вызвать из другого модуля.

Тесты

Тесты пишутся всегда! Даже когда их некогда писать.

Один из способов проверить всё и сразу - это End-To-End тесты. Такой вид теста охватывает почти весь код, но очень сложен в разработке и поддержке.

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

  • Unit и Integration тесты лучше использовать для более детальной проверки, особенно негативных случаев.


PS

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

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

Ещё немного полезных ссылок