Skip to content
Arquitetura

Constituição vs Runtime: como um monorepo sustenta 16 SaaS sem fork

Como o repositório inite-ecosystem não envia uma linha de código executável, por desenho — e por que 16 SaaS verticais dividem infra sem precisar forkar.


Mikhail Savchenko·15 de junho de 2026·8 min de leitura
multitenantmonorepoengenharia de plataformaSaaS verticalspec-first

Dois repositórios, uma plataforma

Um monorepo que hospeda mais de um produto bate no mesmo fork mais cedo ou mais tarde. Uma vertical precisa de uma coluna extra em Company, outra precisa contornar o auth comum para SSO, uma terceira quer seu próprio handler de webhook de billing. Cada uma começa fazendo patch no código compartilhado. Cada uma acaba com a cópia vendorada. A plataforma agora são sete cópias da mesma coisa, divergindo.

A separação que evita isso é mais antiga que a ideia de monorepo. Separar contrato de implementação. O contrato é o repositório inite-ecosystem — JSON Schemas, YAMLs de capability, um manifesto, um validator. A implementação é o inite-shared — 19 pacotes TypeScript, Postgres real, HTTP real. As verticais (rent, estate, shop, events, health, education, club e as demais entre as 16 do registry) consomem ambos.

O manifesto do repositório-constituição é curto e estrutural:

Proibido no repositório de specPermitido no repositório de spec
Lógica de domínioJSON Schemas
Imports @inite/*YAMLs de capability
prisma/schema.prismaManifestos de exemplo
Componentes .tsxValidators e geradores
Adapters para APIs externasFixtures de compatibilidade

Um pull request que adiciona qualquer item da coluna esquerda é rejeitado de cara. O repo serve à spec; a spec serve às verticais; as verticais tocam o negócio. Essa ordem é a arquitetura toda.

O que o contrato contrata, na prática

Uma vertical declara conformance num único manifesto YAML. O formato é pequeno:

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 }

Esse manifesto compromete a vertical com três coisas. Ring 1 — cinco entidades obrigatórias (User, Company, UserCompanyRole, ApiKey, CompanyPermissionOverride) precisam existir no schema Prisma ou ser declaradas como aliases. Ring 2 — cada capability listada em capabilities: obriga a vertical a entregar o conjunto completo de entidades que aquele capability exige. Declarou inbox, você entrega Conversation e Message no formato que o bundle espera. Sem isso, não pode listar o capability. Ring 3 — todo o resto (vehicle, reservation, listing, appointment) é livre. A spec não restringe formato, nomeação nem semântica.

A ferramenta de conformance percorre manifesto mais schema Prisma e produz um relatório present/partial/missing por entidade. Drift aparece como status no registry, não como fork silencioso.

O que isso traz operacionalmente

Três propriedades só aparecem depois que a separação está de fato feita.

Primeiro — upgrade de runtime fica desacoplado de mudança de contrato. Um fix em @inite/auth é um patch de runtime. Chega a cada vertical que consome o pacote, no ritmo de pull da própria vertical. A spec não se move. Não há migração nos 16 produtos. Não há entrada no CHANGELOG da constituição.

Segundo — mudanças quebrantes têm uma única superfície forçada. Remover um campo de Company é um bump MAJOR de spec. A migração entra no repositório de spec como pasta fixtures/migrations/<from>-to-<to>/ com manifestos antes-e-depois para cada vertical afetada, walkthrough escrito de migração e janela de deprecation de pelo menos um MINOR antes do schema antigo parar de validar. As verticais ficam no MINOR anterior pelo tempo que precisarem — porque a regra de caret semver zero-major trata ^0.1.0 como ~0.1.0, e o manifesto não flutua automaticamente para a quebra. Precisam fazer opt-in.

Terceiro — o registry vira fonte de verdade sobre saúde da plataforma. Na versão 0.2.0-rc.4 o registry rastreia 18 verticais em sete buckets de status:

StatusQtdSignificado
production1declara versão ecosystem, atende contrato, em prod
pilot2calibrando ativamente a spec
migration_pending4repo existe, pretende conformar, há gap no modelo
drift4consome inite-shared, ainda sem manifesto
non_conforming2repo existe, sem shared, sem manifesto
candidate3ainda não é vertical, em scoping
legacy2substituída por outra vertical

Essa tabela é atualizada a cada release de spec. Uma vertical que entra em drift além da janela acordada cai no mesmo review. Um capability que não chegou a produção em dois releases vai para reavaliação. O registry é o dashboard.

Por que o runtime não é dono de nada na spec

Os pacotes de runtime declaram um contract_version em registry/capabilities.yaml. Oito dos dezenove estão em 1.0.0 — contrato estável, breaking exige bump MAJOR do runtime. Os onze restantes ficam em 0.1.0 — bundles ainda em calibração, como @inite/accounting e @inite/inbox-ui, onde o contrato ainda não é estrutural. A spec não pina versões de runtime. Só referencia contratos de pacotes. Um capability bundle pode dizer @inite/inbox >= 0.5.0, mas nunca == 1.0.3.

A direção inversa é assimétrica de propósito. O runtime não pode enviar uma mudança de contrato — só a spec pode. O runtime pode enviar uma implementação do contrato sob a spec atual, e essa implementação se move num eixo de versão próprio. Quando a spec quer remover um campo de Company, sai o MAJOR da spec, o runtime acompanha, as verticais migram no próprio ritmo dentro da janela de deprecation.

Isso funciona porque no repositório da spec não existe nada executável para quebrar. Não há serviço para subir. Não há base para migrar. Não há comportamento para testar. O repo é JSON Schemas, YAMLs de capability, exemplos e validator. Um PATCH de spec é edição de documentação. Um MINOR é um campo opcional novo numa entidade ou um capability novo. Um MAJOR é o único lugar onde mudanças quebrantes são escritas — explicitamente, com exemplos, com janela de deprecation.

Como isso aparece nas verticais reais

inite.rent roda em Ring 1 mais inbox, billing, incidents e notifications, com um Ring 3 de cerca de vinte entidades de domínio (Vehicle, Reservation, RentalContract, InsurancePolicy, MaintenanceRecord e assim por diante). Declara maturity: production e é a única vertical nesse status hoje.

inite.estate roda no mesmo Ring 1, no mesmo inbox e num Ring 3 diferente — Property, Listing, Deal, Showing, Offer. O inbox se comporta de forma idêntica nas duas porque elas implementam o mesmo contrato; storefront e busca divergem porque Ring 3 é livre.

inite.shop puxa mais forte em storefront e billing — é onde mora a integração com agentic commerce. Ring 3 é Product, Variant, Order, Cart, Customer (aliased a partir de User pelo mapa aliases: do manifesto). O fato de Customer ter nome local diferente está registrado no manifesto — basta isso para inite-conform confirmar presença de Ring 1 mesmo com alias.

Três verticais diferentes. Uma spec. As mesmas cinco entidades Ring 1 sob nomes locais distintos. O mesmo capability inbox entregando os mesmos primitivos de conversação. Três Ring 3 distintos que a spec não vê e não precisa ver. O caso completo de migração está em como reconstruímos o CRM de aluguel sobre nossa plataforma.

Quando isso é o formato errado

A separação constituição-vs-runtime é o formato errado em dois casos. O primeiro — um produto único que nunca será mais do que ele mesmo. Acrescentar repo de spec, registry e ferramenta de conformance numa empresa de um produto é overhead sem retorno. Monorepo comum dá conta. O segundo — um portfólio de produtos que não compartilham nada na camada de dados. Se o produto A é um jogo iOS single-player e o produto B é uma plataforma B2B de compras, compartilhar uma entidade Company não é a alavanca que parece. A separação paga quando há um contrato real e recorrente — SaaS multitenant compartilhando identidade, billing, conversas e notificações — entre produtos que parecem diferentes por fora e iguais por dentro.

Para Inite, 16 produtos dividem um Ring 1, os oito Ring 2 capabilities estáveis e o mesmo conjunto de serviços horizontais. A separação se paga várias vezes. Sem ela, cada vertical teria seu próprio fork de @inite/auth dentro de um trimestre. Com ela, o registry rastreia drift honesto, a spec se move no próprio ritmo e o runtime envia fixes a todos os produtos de uma vez.

Dois repositórios. 16 produtos. Um contrato que pode mudar de forma deliberada, e um runtime que pode mudar continuamente. Esse é o padrão inteiro. A tese de produto que sustenta a escolha está em um motor, várias peles, e a aplicação do método em INITE Protocol: as 6 etapas.

Perguntas frequentes
  • 01Por que um repositório separado para a spec? Por que não manter a spec dentro do runtime como uma pasta?+

    Dois motivos, ambos estruturais. Primeiro — no momento em que a spec vive no mesmo repositório que código executável, ela deixa de ser um contrato e vira documentação. Uma pasta de YAML ao lado de TypeScript que todo mundo importa vai derivar: alguém precisa de um campo novo, edita o runtime, atualiza o YAML depois — e agora o contrato descreve o que acabou de ser entregue em vez do que deveria ser entregue. Separar a spec num repositório próprio sem dependências de runtime torna essa deriva fisicamente impossível: você não consegue editar a spec e o runtime no mesmo commit. Segundo — as verticais precisam declarar a qual versão de spec elas conformam, separado de qual versão de runtime estão consumindo hoje. Com um repo de spec separado, o manifesto da vertical pode dizer ecosystem: ^0.1.0 e essa linha é checada pelo validator contra os schemas publicados da spec, não contra o que o autor do runtime resolveu enviar nesta semana.

  • 02O que impede uma vertical de importar internas do @inite/auth e forkar a plataforma na prática via monkey-patch?+

    Tecnicamente nada — não existe sandbox. A disciplina é sustentada por três coisas atuando juntas. Primeiro: os pacotes de runtime exportam uma superfície pública estreita (para auth, resolveSession, issueJwt, permissions, apiKey) e não os módulos internos. Passar por trás da superfície fica visível num code review. Segundo: a ferramenta de conformance (inite-conform) lê o manifesto da vertical e percorre o schema Prisma para confirmar que cada entidade Ring 1 declarada existe e que cada capability Ring 2 declarada tem as entidades exigidas — uma vertical que faz monkey-patch aparece no relatório como drift. Terceiro: o registry rastreia status publicamente. Uma vertical que saiu da superfície pública ganha o status drift em registry/verticals.yaml — esse arquivo é revisado em cada release de spec. Nada disso é tecnicamente blindado, mas o custo social de ser a entrada drift no registry público é não-trivial.

  • 03O que acontece quando uma vertical precisa de algo que o runtime ainda não tem?+

    Árvore de decisão de três ramos, e cada ramo corresponde à camada em que a coisa nova vai morar. Se a coisa nova é genuinamente específica da vertical — digamos, locação de carros precisa modelar cobertura de seguro de um jeito que nenhuma outra vertical vai reaproveitar — vai para Ring 3 do schema Prisma daquela vertical, sem mudança na spec. Se ela é reaproveitável em pelo menos duas verticais — um motor de cupons de desconto que rent e shop vão usar — vira candidata a capability bundle Ring 2. Significa: primeiro um PR em inite-ecosystem (spec, schema, exemplos), depois um novo pacote @inite/<coisa> em inite-shared (runtime), depois as verticais fazem opt-in adicionando ao manifesto. A ordem importa: spec antes do runtime, runtime antes da adoção. Se a coisa nova é estrutural — uma nova entidade obrigatória em Ring 1 — dispara um bump MAJOR de spec, com migração escrita para cada vertical e janela de deprecation de no mínimo um ciclo de release.

  • 04Como fazer uma breaking change no runtime sem quebrar os 16 produtos de uma vez?+

    Três pontos de disciplina cobrem a maioria dos casos. Os pacotes de runtime declaram um contract_version no registry (1.0.0 para os oito estáveis, 0.1.0 para os bundles ainda em calibração). Uma breaking dentro de um pacote 1.x.y é bump MAJOR daquele pacote, o que significa que cada vertical pina o major anterior no package.json e atualiza deliberadamente, não transitivamente. Mudanças quebrantes entre pacotes — as que tocam a spec — passam pelo processo MAJOR da spec: entrada no CHANGELOG, pasta fixtures/migrations/<from>-to-<to>/ com manifestos antes-e-depois para cada vertical afetada, e janela de deprecation de pelo menos um MINOR antes do schema antigo parar de validar. As verticais ficam no MINOR anterior pelo tempo que precisarem, porque o manifesto está pinado em ecosystem: ^0.1.0, e ^0.1.0 não flutua para 0.2.0 — pela regra de caret zero-major, ^0.1.0 é tratado como ~0.1.0. Têm que optar pela mudança.

  • 05Isso não é só uma forma sofisticada de dizer monorepo nx mais um pouco de YAML?+

    Não. Nx, pnpm workspaces, Turborepo, Bazel resolvem orquestração de build — como rodar testes, como compartilhar código, como cachear. Não dizem nada sobre como um produto SaaS multitenant precisa parecer. A separação constituição-vs-runtime faz o que essas ferramentas não pretendem fazer: responde à pergunta 'o que significa um produto ser cidadão dessa plataforma?' de um jeito que é checado por ferramenta. O contrato Ring 1 é enforced por inite-conform lendo seu schema Prisma. Os contratos Ring 2 são enforced pela checagem de que você implementa as entidades que cada capability declarada exige. Os imports de runtime são enforced pelo registry: runtime_packages do manifesto precisa ser subconjunto do registry, ou seja, você não consegue silenciosamente começar a consumir um pacote que a spec não abençoou. Isso fica em cima de qualquer ferramenta de monorepo, inclusive nx. Não substitui — é a camada acima.