almdev Технический блог

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

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

Подкатегория: Архитектура

Чтение
6 мин
Технологии / версии
Symfony · DDD · Архитектура · Монолит
8 янв 2026 · 6 мин · 4 просмотра · Backend
Модули единого механизма разделены границами и контролируемыми связями
Архитектура 01/2026

Первый серьёзный архитектурный вопрос возник, когда в проекте понадобились комментарии. Они нужны и задачам, и страницам базы знаний. Это два разных модуля, и класс 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. Такая проверка работает лучше любой договорённости с самим собой.

#symfony #ddd #arhitektura #monolit