ESPN DeportesHaaland recupera la ventaja para el Manchester CityPunchSafe school: Bauchi federal varsity seeks govt intervention on perimeter fencingRTP Desporto`Bis` de Pedro Neto garante triunfo do Chelsea na visita ao EvertonInquirerBukidnon ube set to enter US marketESPNThe 10 teams that fixed roster needs and five that totally missedThe Jerusalem PostIn Pittsburgh, campus antisemitism report’s authors neglected to speak with Jewish studentsZDF heuteEntdecken Sie das ZDF-NachrichtenstudioObservador DesportoMais de três mil pessoas em protestos na TunísiaGlobal NewsAlberta referendum expected to bring finality, but also more questions on separationtazKatherina Reiches Wirtschaftspolitik: Merz verspricht Aufschwung, Reiche macht das GegenteilRMF24Starcia z policją, pożary, wtargnięcie do merostwa. Zamieszki we FrancjiVanguardJAMB moves to rescue graduates stuck for years over NIN data mismatch
The Daily Newsstand · Free, Always
Saturday, October 17, 2026

Асимметрия модульного монолита

Translate

Всем привет. Меня зовут Александр Кубит, я работаю архитектором, системным аналитиком и разработчиком в компании Promet System. Мы уже много лет занимаемся развитием и сопровождением довольно крупной ERP-системы по управлению заводами металлоконструкций. На данный момент наша кодовая база насчитывает порядка четырех миллионов строк кода.

Несколько лет наша команда состояла из двух-трех разработчиков, которым по наследству досталось огромное легаси. Предметная область сама по себе достаточно сложная: сотни сущностей, тысячи, если не десятки тысяч бизнес-правил, которые связывают различные части системы в единые процессы — проекты, справочники, производство, финансы, планирование, техническую документацию, заказы, поставки, складской учет и многое другое. Долгое время мы занимались в основном написанием нового функционала и доработкой существующего. Времени на архитектуру особенно то и не было. Но в какой-то момент пришло понимание, что дальше работать в таком ключе практически невозможно: система начинала яростно сопротивляться любым изменениям. Классическая анемичная модель, где половина логики в контроллерах, а половина в сервисах.

Мы стали изучать материалы, которые обещали структурировать приложение, и конечно начали с попыток внедрить DDD. Само по себе внедрение — обширная и интересная тема, которая требует отдельной статьи. В то же время набирал популярность микросервисный подход. Мы коснулись его исключительно теоретически, потому что понимали: развивать его такой небольшой командой практически невозможно. Инфраструктурные издержки настолько высоки, что мы занимались бы только ими, а не бизнес-логикой. Да и реальных предпосылок для микросервисов не было: не было сотен тысяч одновременных пользователей, не было нескольких команд, не было особых требований к раздельному хранению данных, отказоустойчивости или деплою.

Спустя время мы попробовали несколько подходов к «классическому DDD». Сначала был вариант без разделения на команды и запросы (CQRS), благодаря которому мы увидели, что сложные формы и отчеты то и дело пытаются нагружать домен нехарактерными для него требованиями. Затем, после внедрения CQRS, пробовали варианты без доменных сервисов, где весь код, который не укладывается в агрегаты, лежал в операционном уровне, порождая проблему переиспользования. Пробовали вариант с доменными сервисами, которые могли обращаться к репозиториям, но быстро столкнулись с проблемой инкапсуляции. Затем попробовали физически изолировать агрегаты и их сервисы через отдельные сборки — кстати, вышло довольно неплохо, но ряд фундаментальных вопросов оставался.

Еще через какое-то время мы попробовали другой подход, который не является ни классическим DDD, ни анемичной моделью с сервисами. Он даже не является классическим ООП, но в то же время не является и функциональным. Он возвращает поведение в сущности, сохраняя инкапсуляцию, а также позволяет строить полную и богатую модель, заметно опережая классический DDD по производительности. Я подробно описал этот подход в своей книге (https://www.litres.ru/74351379/), которую вы можете прочитать абсолютно бесплатно.

Но сегодня я хотел бы поговорить не об этом.

С тех пор, как мы начали этот путь, прошло более 10 лет. Система сильно изменилась, хотя многое еще предстоит. Компания тоже изменилась — одно без другого не бывает. Наша команда выросла: теперь нас 12 разработчиков, и мы подходим к пределу эффективной разработки в рамках единого репозитория. Настало время задуматься о разделении команд, а система, как известно, повторяет организационную структуру компании. За это время модность микросервисов заметно поубавилась, а высокая цена операционных издержек стала общеизвестной проблемой. И лично я очень рад, что мы, в свое время, не пошли по этому пути.

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

Домен: возможность локализовать инварианты

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

Инвариант так или иначе имеет локальную природу. Даже если правило связывает несколько сущностей, оно все равно локализуемо — либо в одной сущности, либо в наборе сущностей, которые в рамках конкретного бизнес-действия образуют временную «коалицию», защищающую инвариант. Даже если инвариант межмодульный — такое тоже бывает, каждая из частей инварианта локализуется в одном из модулей. Тот модуль, чье состояние ломается при нарушении правила, становится владельцем инварианта. Он подписывается на события других модулей или запрашивает недостающие данные и защищает себя сам.

Взаимодействия между модулями при записи, на мой взгляд, сводятся к двум основным видам. Первый — чтение проекций: модуль запрашивает у другого модуля read-only DTO, чтобы проверить что-то в рамках своего инварианта. Например, модуль отгрузки проверяет, что ресурс не в архиве, запрашивая у модуля справочников легкий DTO с идентификатором и статусом. Второй — события: модуль публикует факт, другие модули на него реагируют. Например, при удалении ресурса из справочника каждый модуль, который его использует, сам решает, допустима ли операция.

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

Есть любопытный вопрос про направление зависимостей. Можно сделать так, чтобы контракт смотрел на домен — тогда доменные сущности доступны контракту, и он может строить проекции напрямую из них. А можно наоборот: домен смотрит на контракт, а для доступа к внутренним членам используются дружественные сборки. Оба варианта рабочие. Мне больше нравится первый, потому что он не заставляет домен обрастать методами, единственная цель которых — отдать данные для DTO. Но это скорее вопрос вкуса и конкретной ситуации, а не железного принципа.

Отдельный вопрос — где проводить эту пресловутую границу. Да, наверное, по ограниченным контекстам. Но всегда ли ровно по одному? Думаю, все зависит от того, как понимать термины модель и модуль.

Если с термином модель все довольно неоднозначно, так как зависит от выбранного подхода, от способов организации кода и приложения, от степени анемичности, то вот с понятием модуля дело обстоит немного иначе. Модуль — это единица владения, а не единица модели. Если модуль не соответствует команде, которая им владеет, то это не модуль, а папка. У него нет владельца, нет ответственного за эволюцию, нет того, кто принимает решения о его API. Причина простая: закон Конвея работает в обе стороны. Система отражает структуру команд. Если сделать десять модулей на две команды, каждая команда будет владеть пятью модулями. Это значит, что внутри команды появится координация между модулями, которая не нужна — потому что команда и так может договориться. А межкомандная координация останется только на границе двух модулей, где она действительно необходима.

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

Сторона чтения: что на самом деле является контрактом

А как быть со стороной чтения?

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

Если да, то как ее организовать? Запрашивать данные у других модулей через отдельные запросы? Это просто неэффективно. Сложно, хрупко, нагружает систему и разработчиков. Мы получаем N+1, кучу промежуточных DTO, маппинг, сборку в памяти и теряем главное преимущество монолита — единую базу данных и нормальные SQL-запросы. Вместо одного запроса с джойнами по пяти таблицам — десятки обращений к разным модулям и склейка результата в памяти. Кажется, что это какое-то самоистязание ради принципа, цена которого в монолите явно перевешивает пользу. Да и не попытка ли это просто перенести в модульный монолит микросервисные практики — туда, где они не нужны и только мешают?

Может быть, через представления? Якобы view — это контракт, и модуль может менять свою схему, не ломая потребителей. Но будет ли это представление отражать действительность через месяц, год или несколько лет? Этих столбцов уже давно не будет, возможно даже таблиц. Может, вместо них вообще будут иные формы представления данных. Как долго придется поддерживать иллюзию, что представление все еще отражает действительность? Сколько сил потребуется для поддержания этой иллюзии? Не дороже ли это, чем признать, что схема БД — это и есть контракт на чтение между всеми командами?

Изменение схемы — это на самом деле реальное отражение изменившихся бизнес-процессов. Стоит ли пытаться спрятать эти изменения за якобы стабильными контрактами? Если вдуматься, это скорее нечестно по отношению к системе и командам, которые будут поддерживать эту иллюзию.

Признать, что схема — это контракт, на самом деле проще, чем кажется. Нужно лишь соглашение, которое достаточно просто поддерживается на организационном уровне.

Правило простое: ты не можешь в одном релизе переименовать, удалить или изменить тип столбца. Ты всегда должен сначала создать новый, затем предупредить всех, кто его использует, затем дождаться, пока все мигрируют, и только потом удалить старый. Эта практика известна как Parallel Change (или Expand/Contract): расширение, миграция, сжатие. Это проще и прозрачнее. В конечном счете — особенно на длинной дистанции — это меньший технический долг.

Организационно это тоже решается. В каждой команде можно назначить ответственного человека, который занимается координацией изменений схемы. Можно настроить CI/CD и правила код-ревью таким образом, что миграции, изменяющие схему, обязательно попадают на ревью к таким людям. Они вместе с такими же ответственными из других команд ставят встречи и определяют сроки и планы, чтобы все команды успели отредактировать отчеты, формы и страницы, которые затронуло изменение.

Есть спорный вопрос: кто должен исправлять отчеты и формы, которые затронуло изменение схемы? На мой взгляд, в общем случае та команда, которая меняет схему. Она создала прецедент, она понимает, как и почему схема изменилась, и она знает, как правильно преобразовать старые данные в новые. Разработчик, писавший или поддерживающий отчет, этого не знает — он знает, что отчет показывает и зачем он нужен бизнесу, но не знает, что total теперь включает НДС, а раньше не включал. Перекладывать эту работу на него — значит заставлять его разбираться в чужом изменении, которое он не инициировал. Если же изменение затрагивает множество форм или сложные отчеты, в которых члены команды совершенно не разбираются, ответственный за изменение схемы выносит вопрос на общую встречу — и вместе с такими же ответственными из других команд они договариваются о распределении работ.

Заключение

Подведем итог. Модульность, о которой я говорил — это модульность изменений. Она нужна там, где есть инварианты, где состояние системы меняется, где кто-то должен защищать правила от нарушения. Именно здесь границы модулей имеют смысл: они совпадают с границами команд, они выражаются в коде через контракты, сборки и явные запреты. Именно здесь модуль — это единица владения.

На стороне чтения все иначе. Здесь нет инвариантов, нет состояния, которое нужно защищать. Есть только данные, которые нужно показать пользователю — в форме, в отчете или графике. И здесь модульность в привычном смысле не нужна. Не потому, что чтение «проще», а потому что у него другая природа: информационные потребности бизнеса глобальны, а бизнес-правила локальны. Форма заказа требует данных из пяти модулей, и это нормально. Пытаться провести между ними границы — значит бороться с природой задачи.

Но это не значит, что на стороне чтения царит хаос. Чаще всего команда, которая пишет заказы, строит формы для их просмотра и редактирования, а также основные отчеты, связанные с ними. Это не модульность в том смысле, о котором я говорил выше — это не выражается явными запретами в коде, не оформляется контрактами и сборками. Это зона ответственности между командами. Она существует на организационном уровне, а не на уровне архитектуры. И этого достаточно.

Так что модульность — это не универсальный принцип, который нужно применить ко всей системе. Это инструмент для той части системы, где он работает. На стороне записи — модули, контракты, инварианты. На стороне чтения — схема БД как общий контракт, SQL напрямую и зоны ответственности между командами. Это не два разных мира, а два уровня одной архитектуры, у каждого из которых своя природа и свои правила.

О том, как можно строить архитектуру внутри модуля — как сохранить инкапсуляцию, как тестировать домен без моков, как организовать загрузку данных, не теряя производительности — я подробно написал в книге «Архитектура свободных сущностей». Если вам близка проблематика, о которой я говорил в этой статье, возможно, книга даст ответы на вопросы, которые вы еще не сформулировали.

Остается последний вопрос: «А что, если завтра я захочу вынести модуль в микросервис? Ведь тогда все эти джойны через границы модулей сломаются.» Отвечу честно: сломаются. Но давайте посмотрим на цену. Запись уже готова к выносу: у нее есть контракты, события, проекции. Чтение — нет, оно джойнит таблицы напрямую. Значит, при выносе придется переписать те запросы, которые касаются этого модуля. Сколько их? Если у вас есть отдельный query-слой, вы можете это посчитать. Если нет — это первое, что стоит сделать, независимо от планов на микросервисы. И вот что важно: Parallel Change, о котором я упоминал выше, уже дает ту инвентаризацию потребителей, которая нужна для такого плана. Вы знаете, какие запросы читают какие колонки — значит, понимаете, что переписывать.

При этом вы не обязаны выбирать между «монолит навсегда» и «полный микросервис». Есть промежуточный путь: вынести write side модуля в отдельный сервис, но оставить общую БД для чтения. Это не самый чистый паттерн с точки зрения микросервисной догмы, но он работает и сохраняет джойны. Многие реальные системы так и живут годами.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.