Привет, Хабр! Меня зовут Иван Русин, я старший разработчик группы модернизации платформы Flowwow. Именно к нашей команде приходят, когда надо перенести часть функционала со старого бэкенд-проекта в новый. Сейчас у нас два проекта: один мы ведём на базе фреймворка Yii-1, а во втором реализуем современные архитектурные принципы на Yii-2.
Flowwow — это маркетплейс цветов и подарков: более 20 000 магазинов, свыше 5 миллионов пользователей и десятки категорий товаров. В обычные дни RPC составляет от 400 до 1 000 запросов, а в пиковые дни (8 марта, День матери и т. д.) этот показатель может увеличиваться до 7 000 RPC и больше.
При таком масштабе требования к стабильности и скорости разработки становятся критичными. Долгое время бэкенд платформы оставался монолитным, и в какой-то момент это начало серьезно тормозить развитие продукта. В этой статье я расскажу, как мы пришли к модернизации, как поменяли архитектуру и что получилось в итоге.
Когда монолит становится проблемой
Первой причиной модернизации стало то, что наш проект слишком разросся. У нас уже образовалось 95 000 файлов устаревшего кода. Но основная проблема была не в размере, а в структуре. Код представлял собой набор слабо структурированных компонентов, тесно переплетенных между собой.
Классические признаки деградации архитектуры проявились в полной мере:
высокий coupling: компоненты сильно зависели друг от друга,
границы размывались;низкий cohesion: не было фокуса на задачи контекста в компоненте;
скрытые зависимости, которые трудно отслеживались по коду;
отсутствие зон ответственности для разработчиков.
На практике это выглядело так: разработчик менял один контекст, а баг появлялся в другом. Причем зависимости могли находиться в любом месте проекта, и чтобы просто найти их, мы тратили много времени.
Отсутствие четко распределенных зон ответственности усиливало проблему. В любой момент разработчика могли переключить на другой участок системы. В результате человек не успевал глубоко понять текущий контекст, старые задачи забывались, а в новые он просто не мог полноценно вникнуть, что приводило к ошибкам.
Ещё одна проблема, которая нас донимала — сайд-эффекты. Это ситуации, когда задача формально решена, тесты проходят, код выкатывается в продакшн, но потом что-то внезапно отваливается в совершенно другом месте, которое вообще никто не трогал.
Всё это приводило к росту стоимости разработки. Некоторые фичи могли реализовываться месяцами, тестирование становилось сложным, а любой сбой на проде означал прямые финансовые потери.
Почему не микросервисы
Переход к микросервисной архитектуре в такой ситуации кажется логичным шагом. Но в реальности это не универсальное решение. В нашем случае, система уже имела сложную структуру зависимостей, и перенос в микросервисы без предварительной декомпозиции становился нерешаемой задачей. Плюс тогда бы увеличилась сложность инфраструктуры и сетевого взаимодействия, а вместе с ней — стоимость поддержки.
Кроме того, микросервисы создают дополнительные расходы: больше баз данных и точек отказа, сложнее синхронизация. Поэтому мы выбрали модульный монолит, который позволяет навести порядок внутри системы, а уже потом при необходимости выносить части в отдельные сервисы.
Чтобы этот подход действительно заработал на масштабах Flowwow, нам пришлось перестроить сразу несколько направлений: от организации слоев и контроля зависимостей до работы с базой данных и процессов внутри команд. Ниже мы детально рассмотрим каждый из этих пунктов.
Выбор архитектуры
За основу мы взяли onion-архитектуру, но адаптировали её под практические задачи команды.
Этот подход отлично ложится на крупные проекты. Когда разработчики начинают мешать друг другу, приходит время четко зафиксировать зоны ответственности. Кроме того, луковичная структура в первую очередь предполагает модульность. Она предлагает схему организации кода, при которой каждый модуль становится самостоятельным элементом с четкими границами, и задает правила взаимодействия между ними. Такая структура прекрасно разделяет работу команд, позволяя каждой сфокусироваться исключительно на своей предметной области.
В классическом виде она состоит из трех слоев: Domain, Application и Infrastructure. Мы упростили модель, убрав слой инфраструктуры, и фактически оставили два уровня: публичный слой Application и приватную область, в которую входят Domain и остальные внутренние слои.

Слой Infrastructure в привычном виде мы убрали. Но в новой схеме он остался с другой трактовкой. Так, infrastructure в нашем понимании — это внешние сервисы, например карты, почтовые отправления, email и СМС-рассылки. Инфраструктура нужна не для каждого модуля, поэтому зачастую этого слоя нет вовсе.
На практике это означает, что любой внешний вызов к модулю проходит через Application, вместо классического взаимодействия посредством Infrastructure. Application слой становится точкой контроля и границей модуля.
Разделение на контексты
Выбрать архитектурный паттерн и обозначить границы модулей — это лишь половина дела. Нужно было решить, по какому принципу логически делить сам монолит на эти независимые блоки. Здесь мы обратились к Domain Driven Design (DDD). Согласно его принципам, в основе всего лежит контекст, он же Domain. Это те области бизнеса, с которыми он работает. Для нас было важно прийти к единой терминологии. Нам было критично, чтобы не было двух разных сфер деятельности компании с одинаковым названием.
Для начала мы определили и выделили контексты, которые сформулировали на основе задач бизнеса:
бизнес-процессы и термины, которыми он оперирует;
таблицы базы данных;
существующая функциональность.
В результате получили 54 контекста и создали под каждый из них отдельный модуль. Это всё ещё монолит, но внутри он поделен на области с чёткими границами.
Мы разбили разработчиков на команды и закрепили за каждой свой контекст. Обозначив, какая команда за какой модуль отвечает, мы приступили к переносу кода.

Как взаимодействуют модули
Ключевым правилом для работы команд стало отсутствие прямого доступа к внутренним слоям чужих модулей. Взаимодействие возможно только через Application-слой. В нем находятся менеджеры (по сути, сервисы), которые предоставляют публичный API модуля. Именно через них происходит вся коммуникация.
Типичный сценарий выглядит следующим образом:
Один модуль вызывает менеджер другого.
Передает необходимые параметры (например, идентификатор магазина).
Менеджер уходит во внутренние слои — в Domain.
Собирает данные и возвращает результат.
На выходе данные всегда преобразуются в DTO. Это принципиальный момент: доменные модели не выходят наружу. Они используются только внутри модуля, и это защищает систему от неявных зависимостей и побочных эффектов.
Направление подключаемых зависимостей внутрь модуля
Изоляция данных через DTO — это лишь половина дела. Чтобы избежать повреждений модуля от внешних воздействий, важно соблюдать ещё одно правило: очередность подключения (Use) классов-зависимостей должна быть строго последовательной и направленной внутрь модуля. Он должен зависеть только от самого себя, и при этом внешние слои — только от внутренних.
Механика работы следующая: когда внешний компонент или модуль обращается к вашему модулю, он может делать это, только используя в своём коде ваш менеджер из Application. Этот слой первым встречается на пути его запроса. Дальше работает уже код из Application, который подключает у себя компоненты со слоя Domain. Мы строго придерживаемся этой последовательности: в коде Domain не используются классы Application, равно как и Application не обращается к классам внешних модулей в обход Anti-Corruption.
Anti-Corruption Layer
Даже DTO не передаются напрямую. На границе модуля используется Anti-Corruption Layer. Когда модуль получает данные извне, происходит двойное преобразование. Сначала данные формируются в DTO в исходном модуле, затем в принимающем модуле, внешнее DTO преобразуется во внутреннее, из доменного слоя, что даёт возможность работать с таким «своим» объектом внутри модуля на любом слое.
В результате у одного и того же объекта существуют разные представления в разных модулях — это осознанная изоляция.
Такой подход решает сразу несколько задач:
обеспечивает взаимную защиту модулей от изменений друг друга (изменения в наших модулях не влияют на соседние и наоборот)
разработчики команды, ответственной за модуль, сфокусированы только на своей предметной области;
помогает сделать зависимости видимыми, не скрывать их, локализовать в одном месте, на слое Application.
Структура модуля
Каждый модуль делится на две большие зоны: публичную и приватную. Публичная часть — это Application, через который происходит всё взаимодействие с внешним миром. Приватная часть включает Domain и остальные внутренние слои.

Важный момент: никакие внутренние слои не могут напрямую обращаться к другим модулям. И наоборот, внешние компоненты не имеют доступа к приватной части. Вся коммуникация проходит строго через Application.
Это даёт важный эффект: каждый блок становится устойчивым к изменениям в системе. Если команда меняет внутреннюю реализацию, это не влияет на соседей. Дополнительно все внешние связи локализованы. Чтобы понять, от чего зависит конкретный участок кода, достаточно посмотреть на слои Application и Anti-Corruption. Зависимости больше не размазаны по проекту.
Контроль зависимостей через Deptrac
Чтобы архитектура не деградировала, мы автоматизировали контроль зависимостей с помощью Deptrac. Он запускается при деплое и проверяет взаимодействия между слоями, допустимость обращений, связи между модулями.
Конфигурация описывается в YAML-файлах. В одном файле мы задаем слои: например, где находится Domain, где Application. Это делается через пути и регулярные выражения, которые описывают структуру каталогов.
Во втором файле мы описываем сами модули и их взаимосвязи. По сути, это явный список: какой модуль от какого зависит. Если в коде одного модуля появляется несанкционированное подключение другого модуля, которое не описано в конфигурации, деплой упадёт. Например, если модуль SEO внезапно начинает использовать модуль Shop без явного разрешения, такой вызов блокируется.
Таким образом, мы получаем полный и актуальный список зависимостей системы и гарантируем, что архитектура не нарушается.
Важно понимать, что Deptrac решает только задачу контроля архитектуры. Мы не рассматривали альтернативы, потому что она полностью покрывает эту потребность, хотя существуют похожие инструменты: PHPat, dePHPend, Arkitect. Некоторые из них позволяют даже визуализировать структуру проекта и зависимости модулей, но нам это не требовалось: для визуализации мы используем модель C4 и ведём документацию по этому стандарту.
Работа с базой данных
Если на уровне PHP-кода мы жёстко разграничили модули и автоматизировали проверки через deptrac, то с данными всё оказалось сложнее. База данных осталась общей. Мы не стали делить её физически, а сосредоточились на уровне кода. Модели распределены по модулям: каждый из них работает только со своими моделями, а использовать чужие напрямую — запрещено. Мы пока не можем обеспечить полный автоматический запрет технически, поэтому часть контроля лежит на договоренностях и ревью.
Конечно, в реальности остаётся возможность обойти ограничения и написать прямой SQL-запрос. Мы не запрещаем этого полностью, но считаем исключением из правил. В основном, взаимодействие с базой идёт через модели. В редких случаях, когда требуется оптимизация, мы допускаем ручные запросы. Такие решения принимаются осознанно и согласуются с архитектором или DBA.
Производительность
Обилие слоёв, DTO и отказ от прямых SQL-запросов могут вызвать вопрос: как всё это сказывается на скорости? Несмотря на усложнение архитектуры, система остаётся производительной. Это достигается за счёт комплекса решений:
кэширование, включая статические страницы через Varnish;
использование Redis для ресурсоёмких операций;
очереди для асинхронной обработки;
репликация базы данных (master/slave);
оптимизация индексов и запросов.
Дополнительно используется распределение нагрузки по кластерам. Чтение и запись разделены: изменения идут в master, а SELECT-запросы направляются в slave. Это реализовано на уровне движка через фильтрацию запросов.
DBA-команда постоянно мониторит долгие запросы, анализирует сложные JOIN и приходит к разработчикам с конкретными рекомендациями по оптимизации. В результате система стабильно выдерживает нагрузку более 7 000 RPS.
Организация разработки
Технические ограничения и автоматические проверки — это лишь половина успеха. Чтобы новая архитектура жила и не деградировала, нам потребовалось изменить процессы внутри самих команд. У нас за каждым модулем закреплены команда и ответственные разработчики. Любые изменения проходят обязательное ревью. Если нужно изменить чужой модуль, это делается только через согласование с его владельцем. Без этого код не попадет в релиз. У каждого файла есть владелец, прописанный в CODEOWNERS. При этом сам файл мы не обновляем руками: он генерируется автоматически. Мы организовали отдельный архитектурный репозиторий. В нём храним информацию о взаимосвязях файлов и контекстов, из него генерируем актуальный список владельцев контекстов — CODEOWNERS.
Луковичная архитектура позволяет удерживать архитектурные границы не только технически, но и организационно. Разработчики лучше понимают свой контекст, глубже в него погружаются, не отвлекаются, сохраняют фокус на нём и реже допускают ошибки. Мы также упростили онбординг. Новичку стало проще погружаться в работу: он изучает один модуль, а не весь проект.
Где архитектура не решает все проблемы
Несмотря на преимущества, модульный монолит не универсален. Для нас он стал идеальным решением, но не обошлось и без минусов.
Во-первых, остаются общие зависимости. Например, сам фреймворк и часть библиотек подключены для всех модулей. При необходимости вынести модуль в отдельный сервис он поедет вместе с этими зависимостями.
Во-вторых, возникают трудности с полным запретом доступа к данным на уровне базы, raw query, прямых обращений. Так как запрет влечёт за собой дублирование моделей данных в модулях. Поэтому часть правил держится на дисциплине команды.
В-третьих, появляются организационные накладные расходы. Если задача затрагивает несколько модулей, приходится договариваться между командами.
И наконец, часть общих компонентов (например, common, Shared Kernel) остается общими и не всегда удобно изолируется.
Миграция с легаси
Переход на новую архитектуру — длительный процесс. Мы переносим функциональность постепенно, не останавливая развитие продукта.
Процесс выглядит так:
Функциональность реализуется в новом модуле.
В аннотациях классам либо методам добавляем теги переноса, сообщающие, куда и откуда переносили.
Пишем проверки — консольные команды или тесты.
Результаты сравниваем со старой реализацией.
После совпадения фронт переключается на новый код.
Старый функционал удаляем.
Иногда для проверки мы запускаем один и тот же метод в старом и новом проекте на разных наборах данных и сравниваем результаты. Это позволяет убедиться, что поведение полностью совпадает. Только после этого можно безопасно отключать старую реализацию.
Результаты
После внедрения модульной архитектуры система стала более предсказуемой. Связанность между модулями снизилась, зависимости стали явными и контролируемыми.
Разработчики действуют в рамках своих контекстов, лучше понимают систему и быстрее решают задачи. Если проблема возникает, благодаря CODEOWNERS и явной структуре можно быстро найти ответственного и источник ошибки. Кроме того, код не меняется, пока владельцы контекста не подтвердят эти изменения от коллег из других контекстных команд, или же сами их не реализуют. Такой подход в нашем случае существенно снизил количество инцидентов.
Дополнительно система стала готова к дальнейшему развитию. Любой модуль при необходимости можно вынести в отдельный сервис без радикальной переработки. Также чёткое разграничение позволило нам настроить автоматическое распределение возникающих ошибок на ответственных хозяев контекста.
Ещё одним неочевидным плюсом стало то, что модульная система оказалась отлично приспособлена для написания ИИ-документации: за счёт разбиения кодовой базы на более мелкие модули. Мы можем добавлять в каждый модуль .md файлы-подсказки для LLM, с описанием особенностей работы именно этого контекста.
Если подводить итоги, то основная проблема заключалась не в монолите, а в отсутствии структуры и контроля. Когда появляются четкие границы, изоляция контекстов и автоматическая проверка архитектуры, даже большой монолит становится управляемым.
Модульный монолит — это практический инструмент. Он позволяет навести порядок в легаси, снизить стоимость изменений и создать основу для дальнейшего развития системы.
Если вы проходили через похожую трансформацию или решали проблему границ в монолите другим способом, делитесь своими историями в комментариях — сравним подходы.

























