# F1.2 — Modelo de isolamento para o SaaS

Detalhamento da decisão que abre a Fase 1.

Escrito em 17/08/2026. **Revisado no mesmo dia**, depois que a meta de escala foi
informada: a primeira versão dimensionava para menos de dez inquilinos e
recomendava schema por inquilino. Com 4.000, a resposta muda.

---

## Premissas

| | |
|---|---|
| Meta | **~4.000 inquilinos até o fim de 2028** |
| Perfil | Maioria **simples**: uma unidade só |
| Exceção | A própria Barbearia VIP: 32 unidades, 16,2 milhões de registros |
| Código | **Uma linha de desenvolvimento só**, infraestrutura variável |

### Uma linha de desenvolvimento é a decisão certa

O pedido original de 14/08 falava em "nova linha de desenvolvimento". Fork de
código significa dois produtos divergindo, manutenção dobrada e um deles
apodrecendo. O que varia entre clientes é **configuração e infraestrutura**, não
código.

### Cliente simples é um inquilino com uma unidade

O modelo já tem o nível de unidade, e ele funciona com N=1 sem caso especial. A
hierarquia fica:

```
Inquilino (rede)     ← nível novo. Barbearia VIP é o nº 1
   └── Unidade       ← já existe. Cliente simples tem uma
          └── Colaborador, caixa, venda, agenda...
```

Nada da máquina de multiunidade se perde com cliente simples: ela só opera com
um elemento.

---

## O que 4.000 inquilinos significa em volume

Projeção a partir do que a Barbearia VIP acumulou em 32 unidades ativas:

| Tabela | Por unidade | × 4.000 inquilinos |
|---|---:|---:|
| `agendas` | 125.002 | **500.008.000** |
| `vendas_produtos` | 102.018 | **408.072.000** |
| `vendas` | 70.912 | **283.648.000** |
| `clientes` | 7.580 | **30.320.000** |
| | | **~1,22 bilhão** |

Hoje o banco inteiro tem 16,2 milhões de registros. É um fator de **~75×**, e só
nessas quatro tabelas.

Duas consequências que independem do modelo de isolamento escolhido:

1. **Particionamento deixa de ser opcional** nas tabelas grandes.
2. **A produção atual não chega perto.** 4 vCPU e 16 GB sustentam 16 milhões de
   registros, não 1,2 bilhão. Isso é conversa de dimensionamento, não de schema.

---

## Por que a recomendação anterior não sobrevive

| Opção | Em ~10 inquilinos | Em 4.000 |
|---|---|---|
| **D.** Instância dedicada | Viável, e a mais rápida | **Impossível.** 4.000 pilhas para dois operadores |
| **C.** Banco por inquilino | Viável | **Inviável.** Pool de conexão é por banco; migração e backup em 4.000 bancos |
| **B.** Schema por inquilino | **Era a recomendação** | **Quebra.** 4.000 × 83 = **332 mil tabelas**, sem contar índices |
| **A.** Coluna de inquilino | Cara de adaptar | **A única que chega lá** |

O ponto que derruba o schema por inquilino é concreto: o catálogo do PostgreSQL
passa a carregar centenas de milhares de relações. Autovacuum, `pg_dump` e o
planejador degradam; cada migração vira 4.000 execuções, e uma falha parcial
deixa inquilinos em versões diferentes sem ninguém perceber. O padrão funciona
bem até algumas centenas de inquilinos — não milhares.

---

## Correção importante sobre a opção A

A primeira versão deste documento dizia que a coluna de inquilino exigiria
**revisar todas as consultas**. Com PostgreSQL, isso está errado, e o erro
mudava a conclusão.

**RLS — segurança em nível de linha — filtra sem que a consulta saiba.** A
conexão declara qual inquilino está ativo, e o banco aplica a política a toda
consulta que tocar a tabela:

```sql
ALTER TABLE vendas ENABLE ROW LEVEL SECURITY;
ALTER TABLE vendas FORCE  ROW LEVEL SECURITY;

CREATE POLICY inquilino_isolado ON vendas
    USING      (inquilino_id = current_setting('app.inquilino_id')::int)
    WITH CHECK (inquilino_id = current_setting('app.inquilino_id')::int);
```

E o `INSERT` também dispensa mudança, com valor padrão vindo do contexto:

```sql
ALTER TABLE vendas
    ALTER COLUMN inquilino_id SET DEFAULT current_setting('app.inquilino_id')::int;
```

O efeito é que **o código legado continua funcionando sem alteração de consulta**.
O que muda é um ponto só: onde a conexão é aberta, passa a executar
`SET app.inquilino_id`.

Isso é o oposto do que eu havia descrito. A opção A deixa de ser "reescrever o
acesso a dados" e passa a ser "trabalho de schema mais um ponto de conexão".

Quatro ressalvas honestas:

- O usuário da aplicação **não pode ser superusuário nem dono da tabela** — por
  isso o `FORCE`, senão o dono escapa da política.
- Com PgBouncer em modo transação, o contexto precisa ser `SET LOCAL`, dentro da
  transação, senão vaza entre inquilinos na mesma conexão reaproveitada. **É o
  ponto de falha mais perigoso do desenho** e merece teste automatizado próprio.
- A política custa avaliação por linha. Pequeno, mas não zero.
- Consultas administrativas que precisam cruzar inquilinos usam um papel separado
  com `BYPASSRLS`.

---

## Recomendação

**Pool para a massa, silo para os grandes.**

- **A (coluna + RLS)** para os milhares de clientes simples. É o único modelo que
  chega a 4.000, e com RLS ele não exige reescrever consulta.
- **C (banco próprio)** para os poucos clientes grandes — a Barbearia VIP entre
  eles, com 32 unidades e volume desproporcional. Cliente grande costuma exigir
  isolamento em contrato, e tirar um inquilino do pool não muda o código: muda a
  string de conexão.

É o padrão maduro de SaaS, e encaixa exatamente na descrição de "a maior parte
dos clientes é simples".

### Como isso se distribui pelas fases

O que decide o custo é **quando** cada parte entra:

| Quando | O quê | Por que aí |
|---|---|---|
| **Fase 1** | Coluna `inquilino_id` nas tabelas com escopo, preenchida com o inquilino 1; políticas RLS criadas; particionamento das tabelas grandes | O schema está sendo reescrito e cada linha, movida. Acrescentar coluna aqui custa quase nada. Depois seria `ALTER TABLE` em centenas de milhões de linhas |
| **Fase 1** | `SET app.inquilino_id` na abertura de conexão | Um ponto no código |
| **Fase 2–3** | Cadastro de inquilino, provisionamento, cobrança, domínio por cliente | É produto novo, não migração |

O ponto central: **a Fase 1 entrega o banco pronto para multi-inquilino operando
com um inquilino só.** Nada no comportamento muda, os 67 testes de caracterização
continuam sendo o portão, e o trabalho caro de schema fica feito enquanto é
barato.

### Regra de índice que precisa valer desde o início

Em tabela com escopo de inquilino, **todo índice começa por `inquilino_id`**. Um
índice que não lidera por ele faz o banco varrer dados de 4.000 clientes para
responder sobre um. É a diferença entre o sistema funcionar e não funcionar
nessa escala, e é caro de corrigir depois.

---

## Começo lento, e por que isso não muda o modelo

Registrado em 17/08: a adoção deve ser lenta no início, e a meta de 4.000 pode
ser otimista.

**Isso não altera a escolha, e essa é justamente a virtude dela.** Pool com
coluna e RLS não cobra nada por rodar pequeno: com dez inquilinos é uma coluna e
uma política, sem sobrecarga que se note. É o único modelo que atende os dois
extremos, então é o que não exige acertar a previsão.

A comparação vale ao contrário também: se a meta fosse cinco clientes e viessem
4.000, schema por inquilino teria que ser desmontado com o sistema em produção.
O caminho inverso — pool dimensionado para pouco, crescendo — é só
infraestrutura.

O que a previsão muda é **quando** gastar, não **o que** construir.

### Fazer na Fase 1, mesmo com começo lento

Porque são caros de acrescentar depois:

- **Coluna `inquilino_id`** nas tabelas com escopo. Depois vira `ALTER TABLE` em
  tabela grande.
- **Políticas RLS**, com `FORCE` e `WITH CHECK`. É a garantia de isolamento; tem
  que estar certa **antes** do primeiro inquilino externo, não depois.
- **Regra de índice**: todo índice em tabela com escopo começa por
  `inquilino_id`. Reconstruir índice em produção depois é janela de manutenção.

### Adiar, com gatilho escrito

**Particionamento.** Recuei aqui em relação ao que escrevi antes, e o motivo é
concreto: num PostgreSQL particionado, **todo índice único precisa conter a
chave de partição**. Particionar por `inquilino_id` transforma as chaves
primárias em compostas, o que se propaga pelos mapeamentos Doctrine e pelos 67
testes de caracterização — justamente o que a Fase 1 usa como portão.

Pagar essa complexidade para 16 milhões de linhas não se justifica. E há um
ganho em esperar: com tráfego real de vários inquilinos, dá para particionar pelo
critério que os acessos mostrarem, em vez de adivinhar agora.

O gatilho fica escrito, para não virar esquecimento: **quando `agendas` ou
`vendas` passar de ~100 milhões de linhas**, ou quando o tempo de consulta com
filtro de inquilino sair do previsível, particionar entra na fila. No ritmo de
hoje isso são anos; no ritmo da meta, meses.

### Não construir na Fase 1

Com começo lento, a máquina de produto SaaS — autoatendimento, cobrança,
provisionamento automático, domínio por cliente — é adiantamento de trabalho.
Cadastrar os primeiros inquilinos **na mão** é barato e ensina o que automatizar.
Isso é Fase 2–3, e só quando o volume de cadastros justificar.

### O que mais importa no começo lento

O primeiro inquilino externo é o momento em que o isolamento deixa de ser
teórico. Antes dele, uma coisa precisa estar testada de verdade: **o contexto do
inquilino sob pool de conexão**. Com PgBouncer em modo transação, um
`SET` que não seja `LOCAL` vaza para a próxima transação numa conexão
reaproveitada — um cliente lendo dados de outro, de forma intermitente e
dificílima de reproduzir.

Isso merece teste automatizado dedicado, escrito antes de existir o inquilino
nº 2.

---

## O que continua sendo decisão de negócio

Nenhuma bloqueia a Fase 1, agora que o caminho está definido:

1. **A partir de que tamanho um cliente sai do pool para banco próprio?** Pode
   ser decidido quando o primeiro cliente grande aparecer.
2. **Vai existir relatório consolidado entre inquilinos?** Com o pool, é
   trivial — mais um argumento a favor de A.
3. **Onde o pool vai morar?** 1,2 bilhão de linhas não cabe na produção atual. É
   dimensionamento, e o ensaio no laboratório do BVIP dá o número.
