Первый серьёзный архитектурный вопрос возник, когда в проекте понадобились комментарии. Они нужны и задачам, и страницам базы знаний. Это два разных модуля, и класс Comment не принадлежит ни одному из них целиком. Куда его положить, я решал два вечера — и это оказалось самым полезным решением во всём проекте.
Почему монолит, а не микросервисы
Продукт делает один человек. Микросервисы в такой конфигурации дают независимый деплой ценой распределённых транзакций, версионирования контрактов и отладки через три лога. Ни одна из этих проблем у меня не стоит, а вот цена решения ощущается на каждой задаче.
Модульный монолит даёт то, что мне реально нужно от микросервисов: явный запрет залезать в чужие данные напрямую и понятный ответ на вопрос «где живёт эта логика». Граница держится соглашением, code review и статическим анализом, а не сетью. Для меня это дешевле и честнее: общий PostgreSQL всё равно не стал бы физической границей.
Момент, когда монолит станет тесен, я узнаю по конкретному признаку: одна часть приложения потребует другого профиля нагрузки или другого цикла релизов. Пока этого нет, разделение — преждевременная плата.
Раскладка модуля
Каждый модуль внутри устроен одинаково:
src/Module/Tasks/
├── Domain/
│ ├── Entity/ # сущности Doctrine, маппинг атрибутами
│ ├── Enum/ # доменные перечисления и конечные состояния
│ └── Repository/ # интерфейсы репозиториев
├── Application/
│ └── Service/ # сценарии использования
└── Infrastructure/
├── Controller/ # контроллеры, роутинг атрибутами
├── Repository/ # реализации на Doctrine
└── Security/ # votersЕдинственное правило внутри модуля: Domain не знает про Infrastructure. Интерфейс репозитория лежит в домене, реализация — в инфраструктуре. Звучит как формальность, пока не приходит задача выгрузить те же данные не из PostgreSQL, а из кеша. Тогда выясняется, что подменять нужно один класс, а не искать EntityManager по всему сервисному слою.
// Domain/Repository/IssueRepositoryInterface.php
interface IssueRepositoryInterface
{
public function find(Uuid $id): ?Issue;
/** @return list<Issue> */
public function findForBoard(Uuid $projectId, ?Uuid $sprintId): array;
}Сервисы в Application работают только с этим интерфейсом. Doctrine остаётся этажом ниже, в инфраструктурном слое.
Слой Application у меня тонкий и намеренно скучный: он оркестрирует, а не вычисляет. Правило перехода статуса живёт в домене, в Workflow. Сервис только вызывает его и решает, что делать с результатом.
Core — единственный модуль, от которого можно зависеть
Активных модулей три: Core, Tasks, Docs. Ещё несколько в планах — чат, планирование, helpdesk.
Core держит то, без чего не работает ничего: организации, участников, проекты, команды, права, файлы, журнал активности, настройки, уведомления. Все остальные модули на него ссылаются. Обратно — никогда. Core не знает, что где-то существуют задачи.
Правило односторонней зависимости выглядит бюрократией ровно до первого раза, когда его нарушают. У меня Core в какой-то момент захотел показывать в профиле участника число открытых задач. Прямой путь — вызвать репозиторий Tasks из сервиса Core. Следующий же обратный запрос из Tasks в Core склеил бы модули намертво.
Правильный ответ оказался неудобным, но коротким: Core объявляет интерфейс того, что ему нужно, а Tasks его реализует.
// Module/Core/Domain/Contract/MemberWorkloadProvider.php
interface MemberWorkloadProvider
{
public function openCountFor(Uuid $memberId): int;
}Реализация лежит в Tasks, регистрируется как сервис, и Core про неё ничего не знает. Сам по себе интерфейс, конечно, не делает модуль отключаемым: без реализации контейнер не соберётся. Если понадобится выключать Tasks, для контракта придётся зарегистрировать отдельную нулевую реализацию.
Префикс таблицы как физическая граница
Логическая граница держится на дисциплине. Физическая — на схеме базы. Таблицы модуля задач имеют префикс wm_, таблицы базы знаний — kb_, таблицы ядра идут без префикса.
Это сокращённый фрагмент сущности: в маппинге здесь важна одна строка.
#[ORM\Entity]
#[ORM\Table(name: 'wm_issue')]
class Issue { /* … */ }Пользы больше, чем кажется. Во-первых, в psql сразу видно, чьё это хозяйство: \dt wm_* показывает весь модуль. Во-вторых, попытка написать джойн между wm_issue и kb_page в сыром SQL становится заметной — она бросается в глаза на ревью, потому что префиксы разные. В-третьих, если модуль когда-нибудь придётся вынести, границу выгрузки не надо восстанавливать по памяти.
Внешние ключи между модулями я разрешаю только в одну сторону: из модуля в Core. wm_issue.project_id ссылается на project — это нормально. wm_issue.page_id на kb_page — уже нет, для таких связей есть отдельный механизм.
Shared: место, куда утекает всё
Shared появился из-за реакций на комментарии. Эмодзи-реакция нужна и комментарию к задаче, и комментарию к странице. Она не принадлежит ни модулю задач, ни модулю документов, и класть её в Core тоже неправильно: ядро не должно знать про комментарии.
Так появился общий слой с той же трёхслойной раскладкой:
src/Shared/
├── Application/Service/CommentReactionService.php
├── Domain/Entity/CommentReaction.php
├── Domain/Enum/CommentReactionTarget.php
└── Infrastructure/
├── Persistence/Doctrine/HasUuidIdentity.php
├── Persistence/Doctrine/HasTimestamps.php
└── Rendering/EditorHtmlRenderer.phpОпасность очевидна: Shared — это ящик, в который удобно класть всё, что не хочется решать. Я держу его в узде одним вопросом: если этим пользуется ровно один модуль, это не общее, это его собственное. Механизм должен иметь минимум двух реальных потребителей, не «а вдруг понадобится».
Второй фильтр: в Shared не попадает бизнес-логика продукта. Трейты идентичности и меток времени — да. Рендерер HTML из редактора — да, им пользуются и задачи, и документы. Правило «задачу нельзя закрыть с открытыми подзадачами» — нет, это домен задач, ему там и место.
Три раза, когда граница мешала
Вот случаи, в которых красивая схема не выдержа.
Поиск. Глобальный поиск должен искать и по задачам, и по страницам, и по проектам. Это по определению сквозная функция. Строить его через интерфейсы каждого модуля я пробовал — получилось четыре абстракции ради одного экрана. В итоге команда переиндексации знает про все модули напрямую и живёт в src/Command/. Границу такого исключения я провожу по операциям: команда читает данные и обновляет поисковый индекс, но не вызывает доменные команды модулей. Само нарушение зависимости я принял осознанно и записал рядом с классом.
Лента активности. Тот же случай: события пишут все, читает один экран. Здесь я не стал изобретать и оставил писатель журнала в Core, а модули просто вызывают его с типом сущности строкой. Слабая типизация в обмен на отсутствие пяти интерфейсов.
Демо-данные. Сидер, который наполняет базу для показа, обязан знать всю схему целиком. Никакой границы он соблюдать не может по своей природе. Вынес в Shared/Infrastructure/Demo/ и не пытаюсь сделать вид, что это чистое решение.
Общее у трёх случаев одно: это не бизнес-логика, а инструменты вокруг неё. Логика продукта границу пока не нарушала ни разу, и вот за этим я слежу всерьёз.
Чек-лист перед новым модулем
Перед тем как завести очередной каталог в src/Module/, я отвечаю на четыре вопроса.
- Есть ли у него собственные сущности, или он только читает чужие? Если только читает — это сервис в существующем модуле, а не модуль.
- От чего он зависит? Если от двух модулей, кроме ядра, — граница проведена неправильно, надо перерезать иначе.
- Какой префикс таблиц? Нет ответа — значит, непонятно, где кончаются его данные.
- Что произойдёт, если его выключить? Ответ «половина приложения перестанет открываться» означает, что это не модуль, а часть ядра.
Модуль чата я по этим вопросам отложил дважды. Оба раза выяснялось, что мне нужны не чаты, а комментарии с уведомлениями, а это уже есть.
Что бы я сделал иначе
Начал бы с двух модулей, а не с трёх. Docs я выделил заранее, «потому что это очевидно отдельная сущность», и полгода поддерживал границу, за которой почти ничего не было. Модуль оправдал себя только когда у страниц появились ревизии, вложения и своё дерево.
Ещё раньше стоило завести правило односторонней зависимости в виде проверки, а не соглашения. Например, слой в конфигурации Deptrac может запретить импорт Module\Tasks внутри Module\Core. Такая проверка работает лучше любой договорённости с самим собой.