
Конституция и рантайм: как один монорепо держит 16 вертикальных SaaS без форков
Репозиторий inite-ecosystem не содержит ни одной строки исполняемого кода. Это и позволяет 16 SaaS-продуктам жить на общем рантайме без единого форка.
Два репозитория, одна платформа
Монорепо, на котором живёт больше одного продукта, рано или поздно ловит один и тот же форк. Одной вертикали нужна лишняя колонка в Company, второй — обойти общий auth-флоу под SSO, третьей — собственный обработчик billing-вебхуков. Каждая начинает с патча общего кода. Каждая заканчивает вендоренной копией. Платформа теперь — это семь копий одной и той же штуки, расходящихся в стороны.
Разделение, которое это останавливает, старше монорепо как идеи. Развести контракт и реализацию. Контракт — это репозиторий inite-ecosystem: JSON Schema, capability YAML, манифест, валидатор. Реализация — inite-shared: 19 TypeScript-пакетов, настоящий Postgres, настоящий HTTP. Вертикали (rent, estate, shop, events, health, education, club и остальные из 16 в реестре) потребляют оба слоя.
Манифест репо-конституции короткий и несущий:
| Запрещено в репо спеки | Разрешено в репо спеки |
|---|---|
| Доменная логика | JSON Schema |
Импорты @inite/* | Capability YAML |
prisma/schema.prisma | Примеры манифестов |
.tsx UI-компоненты | Валидаторы и генераторы |
| Адаптеры внешних API | Compatibility-фикстуры |
PR, добавляющий что-то из левой колонки, отклоняется на месте. Репо обслуживает спеку; спека обслуживает вертикали; вертикали ведут бизнес. Этот порядок — и есть вся архитектура.
Что именно контрактирует контракт
Вертикаль объявляет соответствие одним YAML-манифестом. Форма небольшая:
ecosystem: "^0.1.0"
id: inite.rent
name: INITE Rent
version: 1.0.0
domain: car_rental
maturity: production
capabilities:
- { id: inbox, version: "^1.0.0" }
- { id: billing, version: "^1.0.0" }
runtime_packages:
- "@inite/auth"
- "@inite/billing"
entities:
- { id: vehicle }
- { id: reservation }
Манифест связывает вертикаль тремя обязательствами. Ring 1 — пять обязательных сущностей (User, Company, UserCompanyRole, ApiKey, CompanyPermissionOverride) должны быть в её Prisma-схеме или объявлены как алиасы. Ring 2 — каждый capability в capabilities: обязывает реализовать полный набор сущностей, которых этот capability требует. Заявил inbox — поставляешь Conversation и Message в том виде, в котором их ждёт бандл. Не готов на обязательство — не имеешь права заявлять capability. Ring 3 — всё остальное (vehicle, reservation, listing, appointment) свободной формы. Спека не ограничивает ни форму, ни именование, ни семантику.
Инструмент conformance проходит по манифесту и Prisma-схеме и выдаёт отчёт present/partial/missing по каждой сущности. Drift всплывает как статус в реестре, а не как тихий форк.
Что это даёт операционно
Три свойства появляются только после того, как разделение действительно сделано.
Первое — обновление рантайма развязано с изменением контракта. Фикс в @inite/auth — это runtime-patch. Он уезжает в каждую вертикаль, которая тянет пакет, в её собственном темпе. Спека не двигается. Миграции по 16 продуктам нет. Записи в CHANGELOG конституции тоже нет.
Второе — ломающие изменения имеют ровно одну вынужденную поверхность. Удаление поля из Company — это MAJOR-бамп спеки. Миграция уезжает в репо спеки папкой fixtures/migrations/<from>-to-<to>/ с до-и-после манифестами по каждой задетой вертикали, письменным walkthrough и deprecation window не короче одного MINOR до момента, когда старая схема перестанет валидироваться. Вертикали сидят на предыдущем MINOR ровно столько, сколько нужно: правило caret для нулевого major трактует ^0.1.0 как ~0.1.0, и манифест не уплывает в ломающее автоматически. Нужно опт-иниться руками.
Третье — реестр становится источником правды о здоровье платформы. На версии 0.2.0-rc.4 реестр отслеживает 18 вертикалей в семи статусных корзинах:
| Статус | Кол-во | Что значит |
|---|---|---|
| production | 1 | заявляет версию ecosystem, отвечает контракту, в проде |
| pilot | 2 | активно калибрует спеку |
| migration_pending | 4 | репо есть, conform намерена, есть data-model gap |
| drift | 4 | тянет inite-shared, манифеста ещё нет |
| non_conforming | 2 | репо есть, ни shared, ни манифеста |
| candidate | 3 | пока не вертикаль, идёт scoping |
| legacy | 2 | вытеснена другой вертикалью |
Эта таблица обновляется на каждом релизе спеки. Вертикаль, ушедшая в drift за пределы согласованного окна, всплывает в том же ревью. Capability, не доехавший до прода в течение двух релизов, идёт на пересмотр. Реестр — это и есть дашборд.
Почему рантайм ничем не владеет в спеке
Пакеты рантайма заявляют contract_version в registry/capabilities.yaml. Восемь из девятнадцати — на 1.0.0: контракт стабилен, ломающие требуют runtime MAJOR. Остальные одиннадцать сидят на 0.1.0 — бандлы вроде @inite/accounting и @inite/inbox-ui, где контракт ещё не несущий. Спека не пришпиливает версии рантайма. Она ссылается только на контракты пакетов. Capability-бандл может сказать @inite/inbox >= 0.5.0, но никогда == 1.0.3.
Обратное направление асимметрично — намеренно. Рантайм не имеет права отгрузить изменение контракта; это может только спека. Рантайм может отгрузить реализацию контракта под текущую спеку, и эта реализация двигается по своей версионной оси. Когда спека хочет убрать поле из Company, едет MAJOR спеки, рантайм подтягивается, вертикали мигрируют в своём темпе внутри deprecation window.
Работает это потому, что в репо спеки нет ничего исполняемого, что можно было бы сломать. Нет сервиса, который надо обновить. Нет базы, которую надо мигрировать. Нет поведения, которое надо тестировать. В репо — JSON Schema, capability YAML, примеры, валидатор. PATCH спеки — это правка документации. MINOR — новое опциональное поле в сущности или новый capability-бандл. MAJOR — единственное место, где ломающие изменения вообще пишутся: явно, с примерами, с deprecation window.
Как это выглядит на живых вертикалях
inite.rent живёт на Ring 1 плюс inbox, billing, incidents и notifications, Ring 3 — около двадцати доменных сущностей (Vehicle, Reservation, RentalContract, InsurancePolicy, MaintenanceRecord и так далее). Заявляет maturity: production, и на сегодня это единственная вертикаль с этим статусом.
inite.estate живёт на той же Ring 1, том же inbox и другой Ring 3 — Property, Listing, Deal, Showing, Offer. inbox ведёт себя идентично в обеих, потому что они реализуют один контракт; витрина и поиск расходятся, потому что Ring 3 свободен.
inite.shop тащит сильнее всех на storefront и billing — здесь сидит интеграция с agentic commerce. Ring 3 — Product, Variant, Order, Cart, Customer (алиас от User через aliases: в манифесте). То, что Customer локально называется иначе, записано в манифесте — этого достаточно, чтобы inite-conform подтвердил наличие Ring 1 даже с алиасом.
Три разные вертикали. Одна спека. Те же пять сущностей Ring 1 под локальными именами. Тот же capability inbox, поставляющий те же conversation-примитивы. Три разные Ring 3, которых спека не видит и видеть не должна. Детальный кейс по rent — в материале «Как мы за 4 недели перенесли CRM проката авто».
Когда это неправильная форма
Разделение «конституция vs рантайм» — неправильная форма в двух случаях. Первый — один продукт, который никогда не станет ничем большим. Добавлять репо спеки, реестр и conformance-инструмент к однопродуктовой компании — это оверхед без выгоды. Общий монорепо в самый раз. Второй — портфель продуктов, у которых на уровне данных нет ничего общего. Если продукт A — однопользовательская iOS-игра, а продукт B — B2B-платформа для закупок, общая сущность Company не даёт того рычага, на который похожа. Разделение окупается там, где есть реальный, повторяющийся контракт — мультитенантный SaaS с общей identity, биллингом, разговорами и нотификациями — на продуктах, которые выглядят по-разному снаружи и одинаково внутри.
У Inite 16 продуктов делят Ring 1, восемь стабильных Ring 2 capability и общий набор горизонтальных сервисов. Разделение окупается многократно. Без него каждая вертикаль за квартал получила бы свой форк @inite/auth. С ним реестр отслеживает честный drift, спека двигается в своём темпе, рантайм отгружает фиксы во все продукты сразу. Шире про тезис «один движок, много обличий» — в материале «Один движок, много обличий», а про операционную модель — «Inite operating model».
Два репозитория. 16 продуктов. Один контракт, которому разрешено меняться сознательно, и рантайм, которому разрешено меняться непрерывно. Это и есть весь паттерн.
01Зачем выносить спеку в отдельный репо? Почему не хранить её папкой внутри рантайма?+
Две причины, обе несущие. Первая — как только спека живёт в одном репозитории с исполняемым кодом, она перестаёт быть контрактом и превращается в документацию. Папка с YAML рядом с TypeScript, который её все импортируют, неизбежно расходится: кому-то нужно новое поле, он правит рантайм, обновляет YAML постфактум — и теперь контракт описывает то, что только что выкатили, а не то, что должно выкатываться. Если же спека лежит в собственном репо без runtime-зависимостей, такой дрейф физически невозможен — нельзя одним коммитом править и спеку, и рантайм. Вторая — вертикалям нужно объявлять, под какую версию спеки они подогнаны, отдельно от того, какой рантайм они тащат прямо сейчас. С отдельным репо манифест вертикали говорит ecosystem: ^0.1.0, и эту строчку валидатор проверяет против опубликованных схем спеки, а не против того, что разработчик рантайма залил на этой неделе.
02Что мешает вертикали полезть во внутренности @inite/auth и фактически форкнуть платформу через monkey-patch?+
Технически — ничего, песочницы нет. Дисциплина держится на трёх вещах вместе. Первое: пакеты рантайма экспортируют узкую публичную поверхность (для auth это resolveSession, issueJwt, permissions, apiKey), а не внутренние модули. Залезание под капот — видимая вещь на код-ревью. Второе: инструмент conformance (inite-conform) читает манифест вертикали и проходится по Prisma-схеме, проверяя, что каждая объявленная Ring 1 сущность на месте и каждый объявленный Ring 2 capability имеет нужные сущности. Вертикаль, которая патчит внутренности, попадает в отчёт как drift. Третье: реестр отслеживает статус публично. Вертикаль, ушедшая с публичной поверхности, получает статус drift в registry/verticals.yaml — этот файл просматривается на каждом релизе спеки. Технически непробиваемой защиты тут нет, но социальная цена быть единственной строкой со статусом drift в публичном реестре — нетривиальная.
03Что делать, если одной вертикали нужно то, чего в рантайме ещё нет?+
Дерево решений из трёх веток, и каждая ветка соответствует слою, где новая штука должна жить. Если новая штука действительно вертикально-специфична — скажем, аренде машин нужна модель страхового покрытия, которой больше никто не будет переиспользовать, — она едет в Ring 3 собственной Prisma-схемы, без изменения спеки. Если она переиспользуется хотя бы двумя вертикалями — например, движок промокодов нужен и rent, и shop, — она становится кандидатом на capability-бандл уровня Ring 2. Это означает: сначала PR в inite-ecosystem (спека, схема, примеры), затем новый пакет @inite/<thing> в inite-shared (рантайм), затем вертикали опт-инят его в манифест. Порядок важен: спека до рантайма, рантайм до использования. Если штука структурная — новая обязательная сущность в Ring 1, — это MAJOR-бамп спеки с письменной миграцией для каждой вертикали и явным deprecation window не короче одного релизного цикла.
04Как делать ломающее изменение в рантайме, не сломав сразу 16 продуктов?+
Три дисциплинарных точки покрывают большинство случаев. Пакеты рантайма заявляют contract_version в реестре (1.0.0 для восьми стабильных, 0.1.0 для бандлов, которые ещё калибруются). Ломающее изменение внутри пакета 1.x.y — это MAJOR-бамп этого пакета, и каждая вертикаль в своём package.json держит предыдущий major, обновляясь сознательно, а не транзитивно. Кросс-пакетные ломающие изменения, затрагивающие спеку, едут через MAJOR-процесс спеки: запись в CHANGELOG, папка fixtures/migrations/<from>-to-<to>/ с до-и-после манифестами по каждой задетой вертикали и deprecation window не короче одного MINOR до момента, когда старая схема перестанет валидироваться. Вертикали сидят на предыдущем MINOR ровно столько, сколько нужно: их манифест пришпилен к ecosystem: ^0.1.0, а ^0.1.0 не дрейфует на 0.2.0 — по правилу caret для нулевого major, ^0.1.0 трактуется как ~0.1.0. Они обязаны опт-инуться вручную.
05Это не просто красивый способ назвать nx-монорепо плюс YAML сверху?+
Нет. Nx, pnpm workspaces, Turborepo, Bazel — они решают оркестрацию сборки: как гонять тесты, как шарить код, как кешировать. Они ничего не говорят про то, как должен выглядеть мультитенантный SaaS-продукт. Разделение «конституция vs рантайм» делает то, на что они не претендуют: отвечает на вопрос «что значит быть гражданином этой платформы?» — и ответ проверяется тулингом. Ring 1 контракт enforced'ится тем, что inite-conform читает Prisma-схему. Ring 2 контракты enforced'ятся проверкой, что для каждого объявленного capability присутствуют нужные сущности. Рантайм-импорты enforced'ятся реестром: runtime_packages в манифесте должен быть подмножеством registry, то есть нельзя тихо начать тянуть пакет, который спека не благословила. Этот слой кладётся поверх любого монорепо-инструмента, включая nx. Он не заменяет их — он живёт этажом выше.