Trilha 3 · Do conceito à produção · ~30 min · pleno/sênior
O pedido parecia inocente: "precisamos extrair o módulo de pagamentos para um serviço separado, o time cresceu". Estimei duas semanas. Levou cinco meses. O motivo não estava no código de pagamentos — estava no banco. Ao abrir o schema, encontrei o horror: 34 queries em outros módulos faziam JOIN na tabela payments. O módulo de relatórios lia direto. O de antifraude tinha uma view materializada em cima dela. O de faturamento fazia UPDATE em uma coluna de payments "para economizar uma chamada". A tabela era, na prática, propriedade compartilhada de meio sistema.
Extrair pagamentos significava caçar e reescrever 34 pontos espalhados por seis módulos, cada um com sua própria suposição sobre o formato da tabela. O código estava "modularizado" — pastas bonitas, services separados. Mas o banco não sabia disso. E aqui está a lição mais dura da Trilha 3: modularidade que não chega ao banco de dados é modularidade de fachada. Suas fronteiras de código podem ser impecáveis, defendidas por fitness functions (aula Governança automatizada: fitness functions com ESLint + Husky), e mesmo assim o sistema ser um monolito indivisível — porque o acoplamento real mora nas tabelas, e nenhuma regra de ESLint vê um JOIN.
State Isolation: o princípio que fecha o cerco
Recapitulando a Trilha 3: a Public API (aula Comunicação entre módulos: o padrão Public API Provider) protege a fronteira de código — impede que Content importe o interno de Billing. Mas há uma segunda porta dos fundos: o banco. Se Content pode dar um SELECT na tabela subscriptions, ele fura a fronteira por baixo, sem import nenhum. State Isolation fecha essa porta.
State Isolation: cada módulo é o dono exclusivo do seu estado. Nenhum outro módulo lê ou escreve suas tabelas diretamente. Para acessar o dado, você pergunta ao dono (via Public API ou evento) — nunca vai ao banco dele.
O teste mental é simples e brutal: se eu extrair este módulo para um servidor com seu próprio banco, o que quebra? Se a resposta é "nada, porque ninguém tocava minhas tabelas", você tem isolamento. Se a resposta é "34 queries", você tem fachada. O isolamento de estado é o que faz a promessa de evolutibilidade (Princípio 10, aula Os 10 princípios da arquitetura modular) ser verdadeira: um módulo cujo estado é privado pode ser extraído trocando a implementação do provider (aula Quando (e quando NÃO) migrar para microserviços); um módulo cujo estado é compartilhado exige a arqueologia dos cinco meses.
O ponto que confunde todo mundo: um banco só, ainda assim isolado
A objeção imediata é: "mas nós temos um PostgreSQL, não um banco por módulo. Como isolar?". Aqui está a sacada que separa quem entende de quem decora: isolamento de estado é uma disciplina lógica, não uma exigência de infraestrutura física. Você não precisa de N bancos. Você precisa de N donos, e de regras que os respeitem. Há três níveis de isolamento lógico dentro de um único Postgres:
Nível 1 — Schema por módulo (o recomendado para monolito modular). Cada módulo tem seu schema no mesmo banco. As tabelas de billing vivem em billing.*, as de content em content.*.
-- ✅ um banco, schemas separados por dono
CREATE SCHEMA billing;
CREATE SCHEMA content;
CREATE SCHEMA identity;
CREATE TABLE billing.subscriptions ( ... ); -- dono: billing
CREATE TABLE content.videos ( ... ); -- dono: content
A regra de ouro que torna isso isolamento de verdade: nenhuma query de um módulo referencia o schema de outro. Zero JOIN billing.x ON content.y. Se content precisa de algo de billing, ele pergunta (Public API), e a resposta vem do próprio billing, que é quem tem o direito de ler billing.subscriptions.
Nível 2 — Migração por módulo. Cada módulo é dono das suas migrações. billing tem sua pasta de migrações, executadas independentemente; content não sabe (nem pode alterar) o schema de billing.
src/module/
├── billing/persistence/migrations/ ← só billing evolui billing.*
├── content/persistence/migrations/ ← só content evolui content.*
└── identity/persistence/migrations/
Isso garante que a evolução do schema é local: adicionar uma coluna em billing.subscriptions é um assunto interno de billing, com sua própria migração, sem coordenar com ninguém — exatamente como seria se fosse um serviço separado. É a preparação para a extração (aula Quando (e quando NÃO) migrar para microserviços) chegando até o banco.
Nível 3 — Permissões de banco (a defesa forte, opcional). Você pode ir além e usar usuários/roles do Postgres: o usuário de conexão de content não tem GRANT para ler billing.*. Aí o isolamento deixa de depender de disciplina e vira imposição do banco — um SELECT cruzando fronteira falha com erro de permissão. É a fitness function (aula Governança automatizada: fitness functions com ESLint + Husky) descendo ao nível do banco.
-- ✅ defesa forte: content fisicamente não enxerga billing
GRANT USAGE ON SCHEMA content TO content_user;
REVOKE ALL ON SCHEMA billing FROM content_user; -- content_user não lê billing
Na prática, a maioria dos times fica no Nível 1 + 2 (disciplina + fitness function de código) e reserva o Nível 3 para dados sensíveis/regulatórios. O ponto é: você escolhe a dose de isolamento (aula Por que não existe arquitetura perfeita), mas a regra lógica — "não toque na tabela do outro" — vale em todas.
Código: o módulo de persistência reutilizável (sem acoplar domínios)
Um erro comum ao buscar isolamento é jogar fora o reúso: cada módulo reescreve conexão, configuração de pool, tratamento de transação, logging de query. Isso é duplicação de infraestrutura (não de domínio — cuidado com a distinção da aula Coesão: o outro lado da moeda). A solução é um módulo de persistência reutilizável que provê o mecanismo sem conhecer nenhum domínio.
// ✅ shared/persistence/persistence.module.ts — MECANISMO, zero domínio
@Module({})
export class PersistenceModule {
// cada módulo registra SUAS entities no SEU schema, reusando a conexão
static forFeature(entities: EntityClassOrSchema[], schema: string): DynamicModule {
return {
module: PersistenceModule,
imports: [TypeOrmModule.forFeature(entities, schema)],
exports: [TypeOrmModule],
};
}
}
// ✅ billing/billing.module.ts — billing registra SÓ o que é seu, no schema 'billing'
@Module({
imports: [
PersistenceModule.forFeature([SubscriptionEntity, InvoiceEntity], 'billing'),
],
providers: [SubscriptionService, { provide: SUB_REPO, useClass: TypeOrmSubscriptionRepository }],
exports: [BILLING_SUBSCRIPTION_STATUS_API],
})
export class BillingModule {}
Repare: PersistenceModule fornece a conexão, o pool, a config comum — mas não conhece SubscriptionEntity. Billing traz suas próprias entities e as ancora no schema billing. O reúso é de mecanismo (alta coesão: o módulo de persistência só faz "persistência genérica"); o domínio permanece isolado. Isso satisfaz simultaneamente High Cohesion (Princípio 7) e State Isolation (Princípio 6).
E a fronteira de código continua valendo por dentro do hexágono (aula DDD estratégico + Arquitetura Hexagonal no FakeFlix): o core de billing não conhece TypeORM. Ele fala com a porta SubscriptionRepository; a implementação TypeOrmSubscriptionRepository (em persistence/) usa o PersistenceModule. Duas camadas de proteção: o core não sabe do ORM, e nenhum outro módulo sabe do schema de billing.
O que NÃO fazer: a entity compartilhada
O anti-padrão que gera os "34 joins" da abertura começa inocente assim:
// ❌ shared/entities/subscription.entity.ts — a entity mora no "shared"
@Entity({ schema: 'public', name: 'subscriptions' })
export class SubscriptionEntity { /* ... */ }
// aí QUALQUER módulo importa e faz query:
// content:
const subs = await this.orm.getRepository(SubscriptionEntity).find({ where: { userId } }); // 😱
No momento em que a entity de um domínio vira shared e outro módulo a consulta, State Isolation morreu — e você nem precisou de um JOIN cru para isso. O ORM disfarçou o acoplamento de banco com uma cara orientada a objetos. A entity de um domínio é interna daquele domínio. Ela nunca vai para shared. O que vai para shared é o mecanismo de persistência, nunca o modelo de um domínio.
TypeORM vs. Prisma vs. Knex: escolha por tradeoff
O curso usa TypeORM, mas a escolha do ORM é uma decisão de tradeoff (aula Por que não existe arquitetura perfeita), não de moda. Aqui está a comparação honesta, do ponto de vista de uma arquitetura modular:
| Critério | TypeORM | Prisma | Knex |
|---|---|---|---|
| Paradigma | ORM (Data Mapper/Active Record) | ORM + client gerado | Query builder (baixo nível) |
| Schema por módulo | Suportado (por entity/conexão) | Um schema.prisma central (fricção p/ modularizar) |
Livre — você controla o SQL |
| Migrações independentes | Boas, por módulo | Centralizadas por padrão (atrito p/ dono por módulo) | Manuais, você organiza por módulo |
| Type-safety | Boa (decorators) | Excelente (tipos gerados do schema) | Fraca (você tipa na mão) |
| Controle do SQL | Médio (abstrai) | Médio (abstrai) | Total (você escreve) |
| Curva/produtividade | Média | Alta produtividade, DX ótima | Baixa abstração, mais verboso |
| Risco de acoplamento oculto | Médio (relations facilitam joins cross-entity) | Médio (client central conhece tudo) | Baixo (nada te "convida" a cruzar fronteira) |
O ponto de tensão para arquitetura modular é sutil e importante: ferramentas que centralizam o schema (como o schema.prisma único do Prisma) criam fricção com "migração/schema por módulo". Você pode modularizar com Prisma (múltiplos schemas, folders), mas está remando contra a corrente do design da ferramenta. TypeORM, com entities por módulo e schemas nomeados, casa mais naturalmente com a estrutura do FakeFlix. Knex dá controle total (nada te empurra para cruzar fronteira) ao custo de você escrever mais SQL e perder type-safety.
A decisão por contexto: produtividade e type-safety máximas, um domínio menos fragmentado → Prisma. Modularidade com schema/migração por módulo, ecossistema NestJS → TypeORM. Controle total do SQL, performance crítica, aversão a "mágica" → Knex. Nenhum é "o melhor"; cada um compra algo pagando outro. O que não muda com a escolha é a regra: a entity/model de um domínio é interna, e ninguém cruza schema.
Os custos reais do isolamento (e quando aceitá-los)
Isolamento não é grátis, e fingir que é seria desonesto. Dois custos concretos:
Custo 1 — Você perde o JOIN conveniente. Antes, um relatório "vídeos assistidos por assinantes premium" era um JOIN entre content.videos e billing.subscriptions. Com isolamento, esse join é proibido. Você tem três saídas: (a) content pergunta a billing os userIds premium via Public API e filtra localmente; (b) billing publica eventos e content mantém uma projeção local (uma cópia read-only do que precisa — aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos); (c) para relatórios analíticos pesados, um pipeline separado (ETL/read model) consolida os dados fora do transacional. A opção certa depende do volume e da tolerância a consistência eventual.
Custo 2 — Duplicação de dado (projeções). Se content mantém uma projeção de "quais usuários são premium", esse dado existe em dois lugares (a verdade em billing, a cópia em content). Isso é duplicação deliberada e saudável — cada módulo tem o que precisa para ser independente — mas exige sincronização (via eventos) e convive com uma janela de consistência eventual. É o oposto da normalização que aprendemos em banco; no mundo modular, um pouco de duplicação controlada compra independência, e independência vale mais que a pureza relacional.
Quando não pagar esses custos? No MVP (aula Por que não existe arquitetura perfeita): se você tem um time e um produto não validado, o JOIN cross-tabela é mais barato e você aceita o acoplamento conscientemente. O isolamento entra quando a evidência aparece: o time cresceu, você precisa escalar/extrair um módulo, ou as mudanças de schema de um domínio começaram a quebrar outro. Aí o custo do isolamento passa a valer menos que o custo de não tê-lo.
Antes → Depois
| Antes (fachada) | Depois (isolamento real) |
|---|---|
Entity em shared/, todos consultam |
Entity interna do domínio dono |
JOIN billing.x ON content.y |
Content pergunta via Public API |
| Migrações centralizadas | Migração por módulo (dono evolui seu schema) |
| Extrair módulo = caçar 34 queries | Extrair = trocar impl do provider (aula Quando (e quando NÃO) migrar para microserviços) |
| ESLint verde, banco acoplado | Fronteira chega até o banco |
Tradeoffs e armadilhas
- Schema por módulo vs. banco por módulo. Bancos físicos separados dão isolamento máximo (e são obrigatórios se os módulos escalam/versionam separado), mas custam operação (backups, conexões, transações distribuídas). Schema por módulo num banco só é o meio-termo pragmático para monolito modular: isolamento lógico com simplicidade operacional. Escolha pela maturidade.
- Transações cross-módulo somem. Se billing e content estivessem na mesma tabela, um
UPDATEtransacional cobria os dois. Isolados, uma operação que toca ambos vira coordenação (saga/eventual — aulas Transações e consistência e Event-Driven na prática: Transactional Outbox, idempotência e sagas). Isso é custo real do isolamento. - Cuidado com "projeção" virando fonte da verdade. A cópia local que content mantém é read-only e derivada; se alguém começar a editar a projeção como se fosse a verdade, você recriou o acoplamento ao contrário. A projeção obedece ao dono, sempre.
- Isolamento excessivo cedo demais é over-engineering (aula Por que não existe arquitetura perfeita). Três bancos separados, permissões de role e projeções para um MVP de 4 devs é o Vilão B. Comece com schema por módulo + disciplina; suba a dose com evidência.
Cola mental
- State Isolation: cada módulo é dono exclusivo do seu estado; ninguém toca a tabela do outro — pergunta ao dono.
- Modularidade que não chega ao banco é fachada: fronteira de código impecável +
JOINcross-tabela = monolito indivisível. - Isolamento é lógico, não físico: um Postgres, schema por módulo + migração por módulo (+ permissões de role para dados sensíveis).
- Reutilize o mecanismo de persistência (alta coesão), nunca o modelo de um domínio. Entity de um domínio jamais vai para
shared. - ORM por tradeoff: TypeORM (modularidade natural), Prisma (DX/type-safety, schema central), Knex (controle total do SQL).
- Custos: perde
JOINconveniente, ganha projeções duplicadas (consistência eventual). Aceite-os com evidência; no MVP, o acoplamento consciente pode ser mais barato. - Teste mental: "se eu extrair este módulo com seu banco, o que quebra?". Nada = isolado. Muito = fachada.
Referências para Aprofundamento
Isolamento de estado
- Modular Monolith: Database Isolation — Kamil Grzybek — Trata exatamente o ponto que confunde: um banco só, schemas separados, e nenhuma consulta cruzando a fronteira.
- Database per Service — Chris Richardson — A versão distribuída do mesmo princípio, útil para entender o que se ganha e o que se perde ao radicalizar.
- Shared Database — Chris Richardson — O anti-padrão descrito com honestidade, inclusive sobre quando ele ainda é a escolha certa.
Persistência sem casar com a ferramenta
- Repository — Martin Fowler — A definição original no catálogo de PoEAA, antes de o termo virar sinônimo de "classe que embrulha o ORM".
- TypeORM · Prisma · Knex — A documentação das três opções comparadas na aula; ler o modelo de migração de cada uma ajuda mais do que qualquer benchmark.
Exercícios
Exercício 1 — Diagnostique a fachada
Um sistema tem billing/, content/, identity/ com services separados e ESLint barrando imports cruzados (aula Governança automatizada: fitness functions com ESLint + Husky), mas o relatório de churn faz SELECT ... FROM billing.subscriptions JOIN identity.users .... O sistema está modularizado? Que princípio (aula Os 10 princípios da arquitetura modular) está violado, e por que o ESLint não pegou?
Ver resposta
✓ Resposta: Não está modularizado — é fachada. O código respeita fronteiras (ESLint verde), mas o JOIN billing.subscriptions ↔ identity.users fura State Isolation (Princípio 6) e, por tabela, Well-Defined Boundaries (1) e Loose Coupling (8): o relatório acopla-se ao schema de dois domínios pela via do banco. Por que o ESLint não pegou: fitness functions de import (aula Governança automatizada: fitness functions com ESLint + Husky) analisam o código TypeScript — quais módulos importam quais. Um JOIN é uma string SQL / uma query que não gera import cruzado no grafo de módulos; está fora do alcance dessas regras. Por isso o isolamento precisa descer ao banco (schema separado + eventualmente permissões de role), e não confiar só na fitness function de código. É o cerco de duas frentes: aulas Comunicação entre módulos: o padrão Public API Provider e Governança automatizada: fitness functions com ESLint + Husky protegem o código, aula Persistência e State Isolation protege o banco.
Exercício 2 — Isole com um banco só
Você tem um único PostgreSQL e quer isolar content de billing. Descreva as três medidas (schema, migração, permissão) e diga qual delas transforma o isolamento de "disciplina" em "imposição".
Ver resposta
✓ Resposta: (1) Schema por módulo: criar content.* e billing.* no mesmo banco; nenhuma query de content referencia billing.* e vice-versa; se content precisa de billing, pergunta via Public API. (2) Migração por módulo: cada módulo tem sua pasta de migrações; só billing evolui billing.*, só content evolui content.* — a evolução do schema é local. (3) Permissão de role: o usuário de conexão de content recebe GRANT só em content.* e tem REVOKE em billing.*. A terceira medida (permissão) é a que transforma disciplina em imposição: com schema e migração separados, nada impede fisicamente um dev de escrever SELECT FROM billing.x no código de content — é convenção. Com a permissão revogada, essa query falha no banco com erro de permissão. É o isolamento deixando de depender de boa vontade e virando lei do Postgres.
Exercício 3 — Reúso certo vs. errado
Classifique cada item como "reúso saudável (mecanismo)" ou "acoplamento (modelo)":
(a) um PersistenceModule que provê conexão e pool para todos;
(b) uma SubscriptionEntity em shared/entities importada por content;
(c) um decorator @Transactional genérico em shared/;
(d) um BaseRepository<T> com findById/save genéricos;
(e) um UserEntity em shared/ que billing e content consultam.
Ver resposta
✓ Resposta: (a) Mecanismo (saudável) — conexão/pool é infraestrutura genérica, sem domínio. (b) Acoplamento (modelo) — SubscriptionEntity é o modelo de billing; em shared e consultada por content, mata o isolamento. (c) Mecanismo (saudável) — transação genérica é infraestrutura. (d) Mecanismo (saudável) — BaseRepository<T> genérico é mecanismo; cada módulo o especializa com sua própria entity, que fica interna. (e) Acoplamento (modelo) — UserEntity é o modelo de identity; compartilhá-la para consulta direta fura o isolamento (o certo é identity expor uma Public API, ex.: getUserSummary(id)). Regra: mecanismo genérico → shared; modelo de domínio → interno do dono, sempre.
Exercício 4 — Resolva o join perdido
Antes do isolamento, "listar vídeos assistidos por assinantes premium" era um JOIN entre content.views e billing.subscriptions. Proponha duas formas de obter esse resultado respeitando State Isolation, e diga o tradeoff de cada uma (síncrono via Public API vs. projeção via evento).
Ver resposta
✓ Resposta: Forma 1 — síncrona via Public API: content chama billingApi.getPremiumUserIds() (ou pergunta, por lote, quais dos usuários com views são premium) e filtra suas próprias content.views localmente. Tradeoff: consistência forte no momento da consulta e simplicidade, mas cria acoplamento temporal síncrono (billing precisa responder na hora) e pode ser pesado/chatty se a lista de premium for grande. Forma 2 — projeção via evento: billing publica SubscriptionActivated/SubscriptionCancelled; content mantém uma projeção local content.premium_users (read-only, derivada) e faz o "join" dentro do próprio schema de content. Tradeoff: independência total em runtime (content não precisa de billing no ar) e consultas rápidas/locais, ao custo de consistência eventual (a projeção pode estar momentaneamente desatualizada) e da complexidade de manter/reconstruir a projeção. Escolha: consulta pontual e volume baixo → Forma 1; relatório frequente/pesado e tolerância a eventual → Forma 2.
Exercício 5 — Escolha o ORM
Para cada contexto, recomende TypeORM, Prisma ou Knex e justifique: (a) um monolito modular NestJS com forte necessidade de schema/migração por módulo; (b) uma equipe pequena priorizando velocidade de entrega e type-safety num domínio pouco fragmentado; (c) um serviço de alta performance onde o time quer controlar cada query SQL.
Ver resposta
✓ Resposta: (a) TypeORM — casa naturalmente com schema nomeado e migração por módulo no ecossistema NestJS; a modularidade rema a favor da ferramenta, não contra. (b) Prisma — a produtividade e a type-safety geradas do schema aceleram a entrega, e o schema central único não é problema num domínio pouco fragmentado (a fricção do Prisma com modularidade aparece justamente quando você quer schema/migração por módulo, o que não é o caso aqui). (c) Knex — dá controle total do SQL e nenhuma "mágica" de ORM, ideal quando o time quer otimizar cada query; o custo (menos type-safety, mais verbosidade) é aceito de propósito em troca de performance e controle. Em todos, a regra de isolamento independe da escolha.