Модульный монолит: как жестко изолировать данные и код без перехода на микросервисы
опубликовано: ⏰ 10Думаю, многие так или иначе сталкивались с задачей корректно определить границы функционала приложения или сервисов. И в последствии реализовать архитектуру, которая бы позволила гарантировать сохранение этих границ в коде и БД. Со стороны веб-приложений и серверной логики подходы давно известны, но про БД не редко забывают.
Введение
В чём суть проблемы. Усложнять не стану, для примера возьму простой и древний как мир сервис с трёхслойной архитектурой.
| Слой | Описание | |
|---|---|---|
| 1 | Слой представления | Веб-приложение на каком-либо фреймворке |
| 2 | Слой бизнес логики | Серверная часть с бизнес-логикой сервиса |
| 3 | Слой хранения данных | База данных, в которой хранится состояние сервиса |

Возможно, кто-то скажет – “О, ужас, это же монолит!”. Да, монолит (модульный) и чё ты мне сделаешь? :)
Как выделить границы функционала и сделать модули для первых двух слоев информации достаточно, способы и подходы известны (о части из них ниже). А вот над третьим слоем обычно не сильно задумываются и зачастую это свалка таблиц. Если конечно на проекте нет выделенного DBA в красных трусах поверх синего трико, который неизвестно откуда придёт и сделает “всё хорошо”.
Скажу сразу, что мог что-то упустить и есть другие варианты организации кода и БД. Приведу очевидные примеры того, что первым приходит в голову.
Слой представления
На слое представления у нас есть различные способы организации модульности:
| Подход | Описание | |
|---|---|---|
| 1 | Путь самообмана | Структуры папок и файлов в общем коде. |
| 2 | Путь реального разделения | Выносим в отдельные модули части приложения, которые далее собираются как зависимости в финальное приложение. |
| 3 | Микрофронтенды | Вариант позволяющий собирать приложение из разных частей, которые могут быть собраны отдельно и даже находиться на разных серверах. |
С первым вариантом всё понятно, хотя может быть у меня ошибочное мнение – для меня это всё та же свалка кода, но в красивой обёртке. Со временем начинает попахивать, даже красивая обёртка не спасает. Ограничения исключительно на соглашениях между разработчиками. Примеры подходов:
- feature-sliced – архитектурный подход, который позволяет разделять приложение по разным критериям: feature, entities, shared, widgets, pages. Вроде бы уже не фаворит, не сильно разбираюсь в модных веяниях фронтенд разработки.
- Clean Architecture – подход к разработке, который позволяет разделять приложение по слоям: entities, usecases, ui, api, etc. Вот это откопали у дяди Боба и носятся с этим, как со святым Граалем.
Второй вариант – это обычные модули, например workspace в monorepo. Уже заметно лучше, но всё равно в одном проекте и в одном репозитории. Но со стороны разработки начинают появляться определённые сложности. Например, с HMR, не всё и не всегда деплоится автоматом при изменениях кода.
Третий вариант – это наиболее явное разделение на независимые части, которые даже физически могут разворачиваться отдельно. Тут проблемы аналогичны и их ещё больше, чем в предыдущем варианте. Да и простых решений у них нет, насколько знаю. Что можем использовать:
- Module Federation aka micro frontends.
- <script type=“importmap”>.
- Возможно есть что-то ещё.
Слой бизнес логики
Если говорить о серверном слое, то тут ситуация по доступным подходам в каком-то роде совпадает со слоем представления. Так как я в основном отдал лучшие годы жизни Java разработке, то все мои последующие примеры будут приводиться с точки зрения разработки на Java, но для других языков разработки думаю есть что-то схожее.
Можем использовать следующие варианты организации кода:
| Подход | Описание | |
|---|---|---|
| 1 | Всё тот же путь самообмана | Всё в одном модуле в стиле Clean Architecture. Для этого даже придумали дополнительные костыли, которые позволяют бить по рукам “неправильных” разработчиков. |
| 2 | Модульность на уровне модулей (библиотек) | Выносим бизнес-логику в отдельные библиотеки, которые потом подключаются как зависимости. |
| 3 | Модульность на уровне микросервисов | Выносим доменную бизнес-логику в отдельные сервисы, которые общаются между собой по API (sync/async). |
По первому варианту всё те же проблемы, что и на слое представления. Примеры инструментов:
- Spring Modulith – навязывает определённые правила организации кода с проверками нарушений. Бонусом ещё можно документацию получить, насколько помню.
- ArchUnit – позволяет проверять архитектурные ограничения в коде (тесты), например, что модуль А не зависит от модуля Б.
Во втором варианте мы получаем модульность уже на уровне библиотек. Здесь уже само окружение сборки может “бить” по рукам указывая на циклические зависимости, скрытые классы или попытки связать несвязываемое. Хочется или нет, но придётся корректно разводить код по модулям. При этом есть целые фреймворки для более удобной работы с этим, вроде OSGi (хотя и со своими проблемами). Не знаю насколько здесь можно проводить аналогии с модулями Java, но наверное тоже можно рассматривать как альтернативу. Примеры используемых инструментов:
- Java Service Loader.
- OSGi.
- Стартеры у Spring Boot.
- Есть ещё EAR/WAR в Java EE при развёртывании на серверах приложений, но похоже этим мало кто пользуется.
Третий вариант – это уже микросервисы и физическое разделение. В этом случае мы получаем полную независимость между сервисами, но и все сопутствующие проблемы и ограничения. Очень рекомендуется для любителей собесов по System Design, фанатам финтехов/бигтехов. Для остальных проектов – it depends.
Слой хранения данных
И вот мы тут. База данных это всё-таки не код, точнее кода там может быть даже больше, чем в самом приложении в определённых случаях. Тем не менее, если рассматривать базу данных только как хранилище, то хочется как-то эти данные разделять.
Что есть в БД:
| Подход | Описание | |
|---|---|---|
| 1 | Как ни странно, снова путь самообмана | Сваливаем всё в одну базу пытаясь как-то различать таблицы по префиксам. |
| 2 | Схемы (schema) | Это способ изоляции данных внутри одной БД (не путать с СУБД). |
| 3 | Разделение на разные БД | Под каждый домен отдельная база, что далеко не всегда возможно. |
Первый вариант очень часто встречается в проектах, простой и понятный, но не масштабируемый. Про монолиты даже вспоминать не хочется, когда в одной базе могут находиться тысячи таблиц с закодированными их основателями названиями. Требуется осознанный и строгий подход к именованию и документированию модели.
Простой пример для двух модулей с префиксами, соответственно security и full-text-search:
- sec_user [table]
- sec_role [table]
- fts_document [table]
- fts_metadata [table]Что делает такую организацию неудобной:
- Бывают конфликты по именам таблиц, индексов, констрейнтов и так далее.
- Когда количество таблиц переходит определённый предел, становится сложно ориентироваться в них.
- У имён таблиц бывают и ограничения по длине (например, 63 символа в PostgreSQL).
- Непонятные префиксы, которые нужно запоминать.
Во втором варианте все таблицы размещаются в отдельных схемах, что решает все вышеперечисленные проблемы. Но есть и недостатки:
- Появляется дополнительный “параметр” в виде имени схемы во всех операциях DML/DDL.
- Необходима поддержка схем как со стороны СУБД, так и со стороны фреймворков приложения.
Пример аналогичен вышеприведённому, но теперь структура выглядит так:
- security [schema]
- user [table]
- role [table]
- fts [schema]
- document [table]
- metadata [table]Собираем всё вместе
Вариант с микросервисами рассматривать смысла нет. В общем случае, это один микросервис == одна база и данные не разделяются между ними. Взаимодействие между сервисами происходит через API – контракты взаимодействия регламентированы, данные формализованы.
Разделение на уровне кода, через различные соглашения и подпорки, мне лично не очень нравится. То что будет сказано далее для модулей в виде зависимостей (библиотек) можно применить и в этом случае, в каком-то виде.
Возвращаемся к началу статьи – необходимо организовать разделение доменов на уровне БД для обычной трёхзвенки. Если объединить все вышесказанное, получаем следующую структуру:
| Слой | Подход | Комментарий |
|---|---|---|
| Представление | Модули | Микрофронтенды для одного сервиса как будто бы излишни. Но подозреваю, что найдутся самураи со своим путём. |
| Бизнес логика | Модули | Сервис один, по сути это модульный монолит. Знаю, неприятно, но нужно потерпеть :) |
| Хранение данных | Схемы | Сервис один, на одном сервере, с одной базой данных и несколькими схемами. |

Чтобы было не только на словах, примеры буду показывать на:
- tekBlueprint.kbase – финальное приложение с подключенными модулями.
- tekBlueprint.frame – набор подключаемых модулей.
На слое представления модули tekBlueprint.frame организованы в виде отдельных пакетов (workspaces)
tb-frame-js > npx lerna list
@tb-frame/core
@tb-frame/security-ui-prime
@tb-frame/ui-prime
@tb-frame/ui-reactК сервису подключаются необходимые. Например, для функционала управления пользователями и правами нужно добавить
@tb-frame/security-ui-prime. По итогу, в приложении появится отдельный модуль с UI для управления пользователями,
который принесёт с собой:
- UI компоненты.
- Маршруты для роутера.
- Локализацию.
- Дополнительные сервисы, хуки и прочий SPI необходимый для подключения.
Ниже на скриншоте видно, что слева на панели есть раздел SECURITY и форма редактирования пользователя, который
относится к соответствующему модулю. Сущности и код модуля бесшовно встраиваются в основное приложение.

На слое бизнес логики модули tekBlueprint.frame организованы в виде подмодулей Maven (modules).
tb-frame-java > ./mvnw compile
tekBlueprint: frame [pom]
security [pom]
security :: spi [jar]
security :: svc [jar]
security :: protocol :: Local [jar]
security :: protocol :: LDAP [jar]
security :: protocol :: OIDC [jar]
security :: preset :: basic [jar]
security :: starter [jar]
storage [pom]
...
search [pom]
...Их также можно подключать по необходимости к финальному приложению. Всё взаимодействие с ними через SPI (классы-интерфейсы), без доступа к внутренней логике модулей. Например:
| Модуль | Функционал |
|---|---|
| security :: svc | Добавляет общий функционал управления ролями и правами с доменной моделью данных (плюс Liquibase). |
| security :: protocol :: Local | Добавляет функционал с аутентификации локальных пользователей по паре логин и пароль. |
| security :: protocol :: OIDC | Добавляет функционал с аутентификации внешних пользователей по JWT. |
| security :: preset :: basic | Добавляет базовый функционал для управления профилем пользователя и самим пользователем (плюс Liquibase). |
Если посмотреть на структуру базы tb_kbase, то можно будет увидеть, что она состоит из нескольких схем.
public
└──Tables
├──databasechangelog
├──databasechangeloglock
├──tb_component_index
├──tb_component_meta
├──tb_component_repository
├──tb_key_value
└──tb_profile
security
└──Tables
├──base_user
├──access_group
├──access_perm
├──access_role
├──access_subject
├──group_to_subject
├──perm_to_role
├──perm_to_subject
├──role_inherit_role
├──role_to_group
└──role_to_subjectСхема public содержит в себе таблицы, которые используются для самого приложения (кстати там как раз видно таблицы с
префиксами). Там же находится и общий лог миграции для Liquibase.
Схема security содержит таблицы, связанные только с модулем security :: *. То есть, есть явное разделение таблиц
относительно используемых модулей в сервисе. Думаю, дополнительно можно более гранулярно настраивать и права доступа на
объекты БД. Например, в схеме security сделать более жёсткие запреты на определённые DML/DDL операции, а в public
оставить всё как есть.
В целом, всё. Естественно, потребуется некоторая обсуживающая “магия” с подключением модулей к основному приложению. Но это неизбежное зло, если не стремиться к полной автоматизации и универсализации, при этом теряя в гибкости.
Поддержка со стороны разработки
Если в проекте используется JPA, то нужную схему достаточно указать в параметрах таблицы сущности. ORM учитывает указание схемы и генерирует SQL запросы с учётом этого.
@Entity
@Table(schema = SecurityConstants.DB_SCHEMA)
public class AccessGroup {
}Со стороны скриптов миграции Liquibase, всё тоже просто. Указываем в changeSet необходимую схему.
<changeSet>
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="access_group" schemaName="SECURITY"/>
</not>
</preConditions>
<createTable tableName="access_group" schemaName="SECURITY">
...
</createTable>
</changeSet>Заключение
Использовать или нет такой подход – решать вам. Всё сильно зависит от типа проекта и его масштаба. В моём случае он
оказался удобным и пока особых проблем не заметил. Остальные проекты tekBlueprint так же разделены в виде библиотек на
отдельные модули.