Projetando Fronteiras de Serviço e Contratos de API

A parte mais difícil de construir sistemas a partir de serviços não são os serviços — são as linhas entre eles. Desenhe as fronteiras errado e você ganha o pior dos dois mundos: o custo operacional de um sistema distribuído com o acoplamento de um monolito, de modo que todo serviço "independente" tem que fazer deploy em lockstep com outros três. Desenhe-as certo e times de fato se movem por conta própria. Depois de alguns sistemas que erraram isso antes de acertar, eis o que decidi.

Uma Fronteira É Sobre Posse de Dados, Não Tamanho de Código

O instinto é dividir por camada técnica ou por "este arquivo está ficando grande". A divisão durável é por posse de dados: um serviço é dono de uma fatia coerente de dados e é a única coisa que a escreve. Todo mundo que precisa daquele dado passa pela API do serviço, nunca dentro do banco dele.

O sinal de que uma fronteira está errada é a direção e o volume de conversa. Se dois serviços não conseguem fazer nada útil sem uma dúzia de chamadas síncronas de ida e volta, eles não são dois serviços — são um serviço com um cabo de rede grampeado no meio, e você adicionou latência e modos de falha à toa. Fronteiras deveriam cair onde o acoplamento é naturalmente baixo: gestão de pedidos é dona de pedidos, catálogo é dono de produtos, e eles interagem por meio de algumas chamadas bem definidas, não uma conversa constante.

O antipadrão para nomear em voz alta é o banco de dados compartilhado: dois serviços lendo e escrevendo as mesmas tabelas. Parece reuso; é na verdade o acoplamento mais apertado que existe, porque agora o schema do banco é a API de ambos os serviços, e nenhum pode mudar uma coluna sem coordenar com o outro. Se serviços compartilham um banco, você não tem uma fronteira — tem um monolito com passos extras de deploy.

O Contrato É a Fronteira de Verdade

Assim que um serviço é dono de seus dados, o contrato de API se torna a interface de fato — e o ponto todo é que a implementação atrás dele pode mudar livremente enquanto o contrato permanece estável. Isso só funciona se o contrato for explícito e imposto, não implícito. Seja um documento OpenAPI, um .proto, ou um schema GraphQL (comparei esses aqui), um bom contrato é:

  • Explícito — escrito numa forma contra a qual ambos os lados fazem build, não "o que quer que o endpoint retorne hoje".
  • Tipado — tipos de campo e obrigatoriedade fazem parte do contrato, então uma incompatibilidade é pega em build ou request time, não por um consumidor quebrando num null. Eu me apoio em tooling de schema como malli exatamente para isso na camada backend-for-frontend (post).
  • Do produtor, moldado para o consumidor — o serviço é dono de seu contrato, mas ele é projetado em torno do que os callers de fato precisam, não um dump cru do modelo interno.

Evoluindo Sem Quebrar Consumidores

Aqui é onde a maior parte da dor de contrato de fato vive: não projetar a primeira versão, mas mudá-la enquanto pessoas dependem dela. Você quase nunca controla todos os consumidores, e não pode atualizá-los atomicamente. Então a regra é mudança aditiva e retrocompatível por padrão:

  • Adicionar um campo opcional é seguro — consumidores antigos ignoram o que não conhecem.
  • Remover um campo, renomear um, ou tornar um campo opcional obrigatório é uma mudança quebradora — vai derrubar consumidores que não a esperam.
Change to a contract                 Safe?   Why
-----------------------------------  ------  -------------------------------------
Add an optional field                yes     old clients ignore unknown fields
Add a new endpoint / RPC             yes     nobody depends on it yet
Make an optional field required      NO      old clients omit it → they break
Remove or rename a field             NO      clients reading it → they break
Change a field's type                NO      deserialization breaks
Tighten validation on existing input NO      previously-valid requests now rejected

Quando você genuinamente precisa fazer uma mudança quebradora, você não muta o contrato existente — você versiona (um novo endpoint, uma mensagem v2, um novo schema) e roda ambos até os consumidores migrarem, depois aposenta o antigo. É mais trabalho, e é o preço de não conseguir fazer redeploy do mundo de uma vez.

Uma disciplina que vale adotar quando o risco é alto: testes de contrato orientados ao consumidor, onde cada consumidor contribui um teste afirmando o formato do qual depende, e o produtor roda todos eles no CI. Aí "acabei de quebrar alguém?" é respondido por um build vermelho antes do deploy, não pelo pager de um consumidor depois.

Trade-offs

O meta-ponto é que fronteiras e contratos são uma aposta sobre mudança: você gasta custo de coordenação antecipadamente (contratos explícitos, disciplina de versionamento, sem tabelas compartilhadas) para comprar a habilidade de mudar cada serviço independentemente depois.

  • Quando fronteiras fortes compensam: múltiplos times, serviços que evoluem em ritmos diferentes, qualquer coisa onde você não pode coordenar todo deploy. A disciplina antecipada é o que deixa os times pararem de se bloquear.
  • Quando são exagero: um time pequeno num produto jovem onde o domínio ainda muda toda semana. Fronteiras de serviço prematuras congelam seu modelo de dados antes de você entendê-lo, e mover uma fronteira depois é muito mais doloroso que mover uma função. Comece com um monolito bem estruturado e fronteiras internas claras; extraia serviços quando os dados de acoplamento (contenção de deploy, escala diferente, posse de time) de fato justificarem a rede.

A frase à qual sempre volto: uma fronteira de serviço é uma promessa que custa algo para manter e algo para quebrar. Coloque-a onde a promessa é fácil de manter — ao redor de dados possuídos, atrás de um contrato explícito que você pode evoluir de forma aditiva — e serviços te compram independência real. Coloque-a em qualquer outro lugar e você só distribuiu seu monolito.