ERP · учётная платформа

Мультиарендная ERP на модульном монолите

Было: учётные процессы держались на разрозненных решениях, и каждая новая доработка добавляла ещё одну связь «все со всеми». Сделали: собрали мультиарендную ERP как модульный монолит — доменные модули с явными границами, один gRPC-контракт внутри и GraphQL-шлюз наружу. Стало: десять с лишним доменов живут в общей кодовой базе, архитектура зафиксирована в модели C4 и корпусе ADR, а изменения схемы приезжают обратимыми миграциями.

  • Модульный монолит
  • gRPC внутри, GraphQL наружу
  • Мультиарендность с шардированием
  • ДоменУчётный контур целиком: НСИ, продажи, склад, закупки, финансы, ценообразование, договоры, доставка, документы и IAM.
  • СистемаТри монолита и GraphQL-шлюз. Внутри — gRPC-контракт coreerp.v1 почти на два десятка сервисов.
  • ДанныеPostgreSQL 16 как источник истины, Kafka для событий, Elasticsearch для read-моделей.
  • РольАрхитектура, доменные модули, контракты, схема базы и миграции.
10+доменных модулей в одной кодовой базе
3 + шлюзмонолита за единым GraphQL-входом
100+обратимых миграций на Goose
C4 L1–L4модель архитектуры и корпус ADR

Контекст и задача

Учётная система ломается не от нагрузки, а от связей

Это мультиарендная ERP: одна кодовая база обслуживает независимых арендаторов, у каждого свои справочники, документы, цены и права доступа.

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

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

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

Отсюда требования, которые мы поставили до первой строки кода:

  • держать десять и более доменов в одной кодовой базе, не превращая её в клубок взаимных вызовов;
  • дать внешним клиентам один вход, а внутренним сервисам — один контракт, который нельзя нарушить молча;
  • развести запись и чтение так, чтобы поиск не влиял на учётные операции;
  • изолировать арендаторов на уровне маршрутизации данных, а не на уровне внимательности разработчика;
  • сохранять обратимость: любая миграция должна откатываться, иначе выкладка становится необратимым событием.

Решение

Пять решений, на которых стоит платформа

Каждое из них зафиксировано в ADR — с контекстом, альтернативами и последствиями, а не как «так исторически сложилось».

  1. 01Границы вместо сервисов. Домены — НСИ, продажи, склад, закупки, финансы, ценообразование, договоры, доставка, документы, IAM — живут в одной кодовой базе с явными границами пакетов. Отдельными процессами вынесены только три монолита: ядро учёта, интеграции и служба чтения, то есть те части, у которых действительно разный профиль нагрузки и жизненный цикл.
  2. 02Один контракт на всех. gRPC-контракт coreerp.v1 описывает почти два десятка сервисов; схемы собираются через buf, поэтому клиент и сервер не расходятся незаметно — несовместимое изменение падает на этапе сборки. Наружу платформу закрывает GraphQL-шлюз на gqlgen, а DataLoader снимает N+1, который иначе появляется на первом же вложенном списке позиций документа.
  3. 03Ядро документооборота. Единая шапка документа и per-type расширения вместо десяти похожих таблиц. Поверх — конечный автомат статусов, граф связей «на основании» и помесячная сквозная нумерация. Правила перехода описаны один раз и применяются всеми типами документов, поэтому новый тип не приносит с собой новую версию тех же правил.
  4. 04Транзакционный Outbox и CQRS-чтение. Событие пишется в ту же транзакцию, что и сам документ, и только потом уезжает в Kafka — иначе оно теряется между коммитом и публикацией, и это самый дорогой класс расхождений: в базе документ есть, в смежной системе его нет. На этих же событиях строятся read-модели в Elasticsearch: поиск и списки читаются оттуда, запись остаётся в PostgreSQL.
  5. 05Мультиарендность маршрутизацией. TenantRegistry определяет арендатора на входе, ShardRouter отправляет запрос в его шард. Изоляция обеспечивается инфраструктурой, а не дисциплиной: забыть условие по арендатору в запросе просто негде, потому что запрос физически идёт в другое соединение.

ADR · почему модульный монолит

Контекст. Десять доменов, общий учётный контур, транзакции поверх нескольких таблиц сразу. Решение. Модульный монолит с жёсткими границами пакетов; отдельный процесс выделяем только там, где отличается профиль нагрузки — интеграции и чтение. Следствие. Транзакции остаются локальными, а эксплуатация не превращается в распределённую отладку. Цена решения — дисциплина границ, которую держат структура репозитория и ревью.

Архитектура

Как это устроено

Клиенты знают только GraphQL-шлюз. За ним — три монолита, которые общаются по одному gRPC-контракту, и три хранилища с разными ролями: запись, события, чтение.

  • Go 1.26
  • PostgreSQL 16
  • gRPC
  • coreerp.v1
  • buf
  • GraphQL
  • gqlgen
  • DataLoader
  • Goose
  • Kafka
  • Outbox
  • Elasticsearch
  • CQRS
  • C4 · ADR
  • GitLab CI
erp-platform / system-topology
GraphQL-шлюз → gRPC-монолиты → PostgreSQL / Kafka / Elasticsearch

Результат

Что получилось на выходе

Не «система стала лучше», а конкретные свойства, которые можно проверить в репозитории.

Десять доменов без клубка

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

НСИ · продажи · склад · закупки · финансы

Обратимые изменения схемы

Больше сотни миграций на Goose, каждая с обратной стороной. Выкладка перестаёт быть событием без пути назад, а окружение разработчика поднимается из нуля до актуальной схемы одной командой.

100+ миграций · up/down

Один контракт вместо десяти

Почти два десятка сервисов описаны в coreerp.v1. Несовместимое изменение ловится на сборке, а не в проде; клиентский код генерируется, а не пишется руками.

gRPC · buf · gqlgen

Поиск не мешает учёту

Тяжёлые выборки и полнотекстовый поиск ушли в Elasticsearch, наполняемый событиями из Outbox. Учётные транзакции остались короткими и не ждут отчётов.

Outbox → Kafka → read-модели

Роль maincode

Что делали мы

Проект длинный, и честно показать в нём стоит именно границу собственной работы.

  • АрхитектураМодель C4 уровней L1–L4, границы доменных модулей, разбиение на три монолита и шлюз, корпус ADR. Решения фиксировались письменно до кода.
  • КонтрактыПротофайлы coreerp.v1 и сборка через buf, схема GraphQL и резолверы на gqlgen, DataLoader на связанных сущностях.
  • ДоменыРеализация модулей учётного контура: документооборот с конечным автоматом статусов, склад, закупки, финансы, ценообразование, доставка.
  • ДанныеСхема PostgreSQL 16, индексы под операционные сценарии, более ста обратимых миграций на Goose, шардирование по арендаторам.
  • СобытияТранзакционный Outbox, публикация в Kafka, построение read-моделей в Elasticsearch и служба чтения поверх них.
  • ГраницыО бизнес-показателях заказчика мы не рассказываем — в кейсе только инженерная часть, которую сделали сами.

Что применили

Услуги, из которых собран этот проект

Если ваша задача другая — посмотрите разбор логистической платформы про микросервисы и обработку операций или движок распределения товара. Полный список — на странице проектов.

Похожая задача?

Расскажите, что у вас за учётный контур — предложим архитектуру и план.

Разберём домены и связи, покажем, где система упрётся при росте, и предложим границы модулей. Если задача не наша — скажем прямо. maincode — инженерная студия ИП Диденко М. С.