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 UPDATE transacional 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 + JOIN cross-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 JOIN conveniente, 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

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.