Trilha 3 · Do conceito à produção · ~20 min
Todo monolito modular que já vi degradar seguiu o mesmo roteiro. No começo, as fronteiras são lindas: billing/, content/, identity/, cada um no seu quadrado. Aí chega uma sexta-feira, uma feature urgente, e um dev — bem-intencionado — faz import { SubscriptionEntity } from '../../billing/persistence/entity' lá no content. Funciona. Passa no review (ninguém reparou). Seis meses depois, existem 200 imports assim atravessando fronteiras, e o "monolito modular" virou um monolito espaguete com pastas bonitas. Módulos não têm parede; têm sugestão de parede.
O Public API Provider é a parede de verdade. É o padrão que responde: como um módulo fala com outro sem que os dois virem um só? E, criticamente, é o padrão que uma fitness function (aula Governança automatizada: fitness functions com ESLint + Husky) consegue defender automaticamente — porque ele torna explícito o que é permitido atravessar e o que não é.
O problema: interno vazando como público
Sem uma superfície pública explícita, tudo num módulo é potencialmente público. Qualquer arquivo pode ser importado de fora. Isso destrói a promessa da modularização: se Content pode importar a SubscriptionEntity de Billing, então Billing não pode mais mudar essa entity sem risco de quebrar Content. O interno de Billing virou contrato sem ninguém decidir isso.
A solução conceitual é separar duas coisas que costumam se confundir:
- A superfície pública: um contrato pequeno, estável, pensado — o que o módulo promete aos outros.
- O interno: tudo o mais (entities, services de domínio, repositórios) — livre para mudar, porque ninguém de fora depende dele.
O Public API Provider materializa essa separação em código.
Anatomia do padrão
São três peças:
Peça 1 — O contrato (a interface pública), que o consumidor conhece:
// billing/public/billing-subscription-status.api.ts
// Esta interface é a ÚNICA coisa de Billing que outros módulos podem conhecer.
export interface BillingSubscriptionStatusApi {
isUserSubscriptionActive(userId: string): Promise<boolean>;
}
export const BILLING_SUBSCRIPTION_STATUS_API = Symbol('BillingSubscriptionStatusApi');
Repare no que não está aqui: nada de Subscription, nada de status, nada de datas ou campos internos. Só a pergunta que Content precisa fazer e a resposta mínima. Isso é data coupling desenhado de propósito (aula As dimensões do acoplamento: força, distância e tempo).
Peça 2 — O provider (a implementação), que fica escondido dentro de Billing:
// billing/public/billing-subscription-status.provider.ts
@Injectable()
export class BillingSubscriptionStatusProvider implements BillingSubscriptionStatusApi {
// Ele orquestra o INTERNO de Billing, mas não o expõe.
constructor(private readonly subscriptionService: SubscriptionService) {}
async isUserSubscriptionActive(userId: string): Promise<boolean> {
return this.subscriptionService.isUserSubscriptionActive(userId);
}
}
O provider é a ponte controlada: por dentro ele usa o SubscriptionService (domínio interno, hexágono da aula DDD estratégico + Arquitetura Hexagonal no FakeFlix); por fora ele só honra o contrato. Se amanhã a regra de "assinatura ativa" ficar mais complexa (trial, tolerância, suspensão), tudo muda aqui dentro e no interno — e o contrato isUserSubscriptionActive(userId): boolean permanece idêntico. Content nunca fica sabendo.
Peça 3 — A exposição controlada (o módulo publica só a API):
// billing/billing.module.ts
@Module({
providers: [
SubscriptionService, // interno — NÃO exportado
TypeOrmSubscriptionRepository, // interno — NÃO exportado
{
provide: BILLING_SUBSCRIPTION_STATUS_API, // público
useClass: BillingSubscriptionStatusProvider,
},
],
exports: [
BILLING_SUBSCRIPTION_STATUS_API, // ⬅️ SÓ isto sai do módulo
],
})
export class BillingModule {}
O exports é a parede. Só o que está listado ali pode ser injetado em outros módulos. SubscriptionService, TypeOrmSubscriptionRepository, SubscriptionEntity — tudo interno — ficam inalcançáveis de fora por construção do container. A modularização deixou de ser convenção e virou mecanismo.
Do lado consumidor (Content), a dependência é do contrato — nunca do interno:
// content/core/service/playback.service.ts
@Injectable()
export class PlaybackService {
constructor(
@Inject(BILLING_SUBSCRIPTION_STATUS_API)
private readonly billing: BillingSubscriptionStatusApi, // ⬅️ conhece a INTERFACE, não a classe
) {}
async canPlay(userId: string): Promise<boolean> {
return this.billing.isUserSubscriptionActive(userId);
}
}
Content depende de BillingSubscriptionStatusApi (o contrato). Ele nem sabe que existe um BillingSubscriptionStatusProvider, muito menos um SubscriptionService. Distância grande (módulo→módulo) protegida com acoplamento fraco (data coupling). Exatamente a regra de ouro da aula As dimensões do acoplamento: força, distância e tempo.
Como isso fecha o vazamento de estado
Um princípio que atravessa o curso: cada módulo é dono do seu estado; ninguém lê a tabela do vizinho (State Isolation, aprofundado na aula Persistência e State Isolation). O Public API Provider é o que torna esse princípio praticável. Sem ele, a única forma de Content saber o status da assinatura seria dar um SELECT na tabela subscriptions (content coupling da aula As dimensões do acoplamento: força, distância e tempo) — furando o isolamento. Com o provider, Content pergunta, e Billing responde a partir do seu estado, do jeito que quiser. A tabela de Billing continua sendo assunto exclusivo de Billing.
Isso tem um efeito poderoso lá na frente: no dia em que você quiser extrair Billing para um serviço separado (aula Quando (e quando NÃO) migrar para microserviços), o contrato já existe. Você troca a implementação do provider de "chamada de função local" para "chamada HTTP/gRPC" — e Content não muda uma linha. O Public API Provider é, também, a preparação para a extração futura.
Variação: comunicação via eventos
O provider síncrono acima cria acoplamento temporal síncrono (Content e Billing precisam responder na hora — aula As dimensões do acoplamento: força, distância e tempo). Quando isso vira gargalo, a mesma ideia de "contrato explícito" migra para eventos:
// billing/public/events/subscription-activated.event.ts (o contrato agora é o EVENTO)
export class SubscriptionActivatedEvent {
constructor(public readonly userId: string, public readonly occurredAt: Date) {}
}
// Billing publica o fato (não sabe quem escuta):
this.eventBus.publish(new SubscriptionActivatedEvent(userId, new Date()));
// Content reage e mantém sua própria visão:
@OnEvent('billing.subscription.activated')
handle(e: SubscriptionActivatedEvent) {
this.entitlements.grant(e.userId);
}
Aqui o "contrato público" de Billing é o evento (um fato: "assinatura foi ativada"), não um método. Content passa a manter sua própria projeção de entitlement, ganhando independência de runtime ao custo de consistência eventual. É o mesmo padrão de "superfície pública explícita", só que assíncrono. A distinção mensagem vs. evento e as garantias disso são as aulas Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos e Event-Driven na prática: Transactional Outbox, idempotência e sagas.
Antes → Depois
| Antes | Depois |
|---|---|
import { SubscriptionEntity } from '../billing/persistence' |
@Inject(BILLING_..._API) billing: BillingSubscriptionStatusApi |
| Content depende do schema interno de Billing | Content depende de um contrato de 1 método |
| Mudar entity de Billing quebra Content | Interno de Billing muda livremente |
| Extrair Billing = reescrever Content | Extrair Billing = trocar impl do provider |
| Fronteira é convenção (review humano) | Fronteira é mecanismo (exports + fitness function) |
Tradeoffs
- Indireção tem custo. O provider é um arquivo a mais e uma camada de tradução. Em módulos com pouquíssima interação, pode parecer cerimônia. Mas repare: é uma indireção que paga (isola variação real entre fronteiras distantes) — diferente da indireção vazia da aula Por que não existe arquitetura perfeita.
- Risco de "provider anêmico". Se o provider só repassa 1:1 tudo do service interno, você pode estar vazando o modelo interno com uma casca. O contrato público deve ser desenhado do ponto de vista do consumidor (o que Content precisa), não como espelho do interno de Billing.
- Chattiness. Data coupling extremo (métodos minúsculos) pode gerar muitas chamadas. Às vezes um método público que responde à necessidade real do consumidor em uma tacada (ex.:
getPlaybackEntitlement(userId)retornando{active, maxResolution}) é melhor que três chamadas granulares. Desenhe a API pela necessidade, não pela pureza. - Não substitui isolamento de dados. O provider protege a fronteira de código, mas alguém disciplinado ainda pode dar um
SELECTcross-schema no banco. Por isso a aula Persistência e State Isolation (State Isolation) e a aula Governança automatizada: fitness functions com ESLint + Husky (fitness functions) são complementares — juntas fecham o cerco.
Cola mental
- Sem superfície pública explícita, todo o interno vira contrato sem ninguém decidir.
- Public API Provider = contrato (interface mínima) + provider (implementação escondida) + exposição controlada (
exports). - Desenhe o contrato pela necessidade do consumidor, não como espelho do interno.
- Produz data coupling (aula As dimensões do acoplamento: força, distância e tempo) e viabiliza State Isolation (aula Persistência e State Isolation).
- É a parede que a fitness function (aula Governança automatizada: fitness functions com ESLint + Husky) consegue defender, e a preparação para extrair um serviço (aula Quando (e quando NÃO) migrar para microserviços).
- Variação assíncrona: o contrato público vira o evento (aulas Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos e Event-Driven na prática: Transactional Outbox, idempotência e sagas).
Referências para Aprofundamento
O padrão e suas variações
- Modular Monolith: Integration Styles — Kamil Grzybek — Percorre exatamente as opções desta aula: chamada direta pela API pública, eventos internos e mensageria, com os custos de cada uma.
- Published Interface — Martin Fowler — A distinção entre interface pública (acessível) e publicada (com contrato e compromisso de compatibilidade). É a diferença que faz a Public API valer alguma coisa.
- Anticorruption Layer — DDD Community — Quando o outro lado não tem uma API decente, é essa camada que impede o modelo alheio de contaminar o seu.
Contratos entre partes
- Consumer-Driven Contracts — Ian Robinson — Como fixar o contrato pelo lado de quem consome, para que uma mudança do provedor quebre o build e não a produção.
- Pact — A ferramenta mais usada para testes de contrato, útil no dia em que o módulo virar serviço.
Exercícios
Exercício 1 — Desenhe o contrato
Identity precisa oferecer a outros módulos a validação de token JWT. Escreva a interface pública mínima (data coupling) que Identity deveria expor. O que não deve aparecer nela?
Ver resposta
✓ Resposta: typescript
export interface IdentityTokenApi {
validateToken(token: string): Promise<{ userId: string } | null>;
}
Passa só o token e devolve o mínimo que os outros módulos precisam: se é válido e de quem é (userId). Não deve aparecer: a UserEntity completa, hash de senha, roles internas, o algoritmo/segredo do JWT, nem detalhes de sessão. Tudo isso é interno de Identity; expor qualquer um deles criaria stamp/content coupling e amarraria Identity ao formato interno para sempre.
Exercício 2 — Ache o vazamento
Este é o "contrato público" de Billing. Critique-o à luz do padrão:
export interface BillingPublicApi {
getSubscription(userId: string): Promise<SubscriptionEntity>; // retorna a entity do ORM
}
Ver resposta
✓ Resposta: Grave vazamento: o contrato retorna SubscriptionEntity — a entidade do ORM, um objeto interno de persistência de Billing. Consequências: (a) quem consome passa a depender do schema do banco de Billing (stamp coupling forte a distância grande — aula As dimensões do acoplamento: força, distância e tempo); (b) qualquer mudança de coluna/estrutura na entity quebra os consumidores; (c) some com o State Isolation, porque o "interno" (a representação de persistência) virou público. Correção: retornar um objeto de contrato próprio da API pública (um DTO/valor de leitura desenhado para o consumidor), ou, melhor ainda, expor só o que o consumidor precisa (ex.: isUserSubscriptionActive(userId): boolean) em vez do objeto inteiro.
Exercício 3 — Contra a chattiness
Content precisa, para montar a tela de play: (i) saber se a assinatura está ativa e (ii) saber a resolução máxima do plano. A API pública atual tem isUserSubscriptionActive e getMaxResolution, exigindo duas chamadas por play. Proponha uma alteração no contrato e explique o tradeoff (data coupling puro vs. pragmatismo).
Ver resposta
✓ Resposta: Adicionar um método que responde à necessidade real em uma tacada:
getPlaybackEntitlement(userId: string): Promise<{ active: boolean; maxResolution: '480p'|'1080p'|'4k' }>;
Tradeoff: o purismo do data coupling diria "um método por dado". Mas aqui os dois dados são sempre consumidos juntos, no mesmo momento (montar a tela de play), então um contrato levemente mais gordo elimina uma chamada por play (menos chattiness, menos latência) sem vazar interno — o retorno ainda é um valor desenhado para o consumidor, não a entity. É pragmatismo consciente: você aceita um contrato um pouco mais rico porque a necessidade do consumidor é coesa. O que não se deve fazer é retornar a Subscription inteira "por via das dúvidas" — isso seria stamp coupling.
Exercício 4 — Prepare a extração
Explique, em código ou em palavras, por que um sistema que usa Public API Provider consegue extrair Billing para um serviço remoto sem tocar no código de Content. Onde exatamente entra a mudança?
Ver resposta
✓ Resposta: Porque Content depende do contrato (BillingSubscriptionStatusApi), não da implementação. Hoje o provider é uma classe local que chama o SubscriptionService na mesma memória. Para extrair Billing, você cria uma nova implementação do mesmo contrato — por exemplo HttpBillingSubscriptionStatusProvider implements BillingSubscriptionStatusApi, que por dentro faz uma chamada HTTP/gRPC ao serviço remoto de Billing — e troca o binding no módulo:
{ provide: BILLING_SUBSCRIPTION_STATUS_API, useClass: HttpBillingSubscriptionStatusProvider }
Content continua injetando BillingSubscriptionStatusApi e chamando isUserSubscriptionActive exatamente como antes: zero linhas alteradas em Content. A mudança fica confinada à fronteira (a implementação do provider). É por isso que "um bom monolito modular já é a preparação para microserviços" (aula Quando (e quando NÃO) migrar para microserviços): o contrato é o ponto de corte.
Exercício 5 — Desafio: a API pública que não protege nada
O time criou a Public API de Billing e ficou assim:
// billing/public/billing-public.api.ts
export interface BillingPublicApi {
getSubscription(userId: string): Promise<SubscriptionEntity>;
getRepository(): Repository<SubscriptionEntity>;
updateStatus(userId: string, status: string): Promise<void>;
}
Existe a pasta public/, existe a interface, e Content só importa dali. Ainda assim o padrão não está protegendo nada. Aponte os três problemas e reescreva a interface.
Ver resposta
✓ Resposta: (1) getSubscription devolve a entidade de persistência. O contrato passou a expor o formato interno da tabela: Content agora depende de cada coluna de SubscriptionEntity e vai ler status, plan, currentPeriodEnd por conta própria. A fronteira existe no diretório e não no tipo — é stamp coupling atravessando um public/ decorativo. (2) getRepository() é a fronteira aberta com chave na porta. Entregar o repositório permite a Content fazer qualquer query, inclusive update e delete, direto na tabela de Billing. Isso destrói o State Isolation por completo: não há mudança de schema que Billing possa fazer com segurança, porque não há como saber o que os outros módulos executam contra ela. (3) updateStatus deixa outro módulo escrever no estado de Billing. Escrita através da API pública transfere a regra de negócio para quem chama — quem garante que "cancelado" só vale se não houver fatura em aberto? A API pública é para perguntar, não para mandar o vizinho mudar de estado; se Content precisa que algo mude, isso é um comando ou um evento que Billing recebe e decide atender, aplicando as próprias invariantes. Reescrita:
// billing/public/billing-public.api.ts
export type Entitlement = 'none' | 'standard' | 'premium';
export interface BillingPublicApi {
isSubscriptionActive(userId: string): Promise<boolean>;
getEntitlement(userId: string): Promise<Entitlement>;
}
Só dado mínimo, já interpretado, em tipos que pertencem ao contrato e não à persistência. O Entitlement é vocabulário de fronteira: Billing pode trocar de ORM, renomear coluna, introduzir trial ou anuidade, e nada disso alcança Content. Sem escrita e sem repositório, Billing volta a ser o único dono do próprio estado — que é a coisa toda que o padrão existe para garantir. O teste rápido para qualquer API pública: se eu trocar o banco deste módulo amanhã, algum consumidor quebra? Se a resposta for sim, o contrato ainda está vazando implementação.