Resumo teórico
Pense em uma casa. Antes de os pedreiros assentarem o primeiro tijolo, alguém desenha a planta: onde ficam as paredes, por onde passam os canos, quais cômodos ficam lado a lado. Essa planta é difícil de mudar depois. Mudar uma parede é fácil no papel e muito caro com a casa pronta. A arquitetura de software é a planta de um programa. É o conjunto das grandes decisões que dão forma ao programa e que custam caro para mudar depois.
A maioria dos programas começa pequena e cresce. Se ninguém pensa na forma, o código vira um novelo embaraçado: você puxa um fio e dez outros se mexem. Uma boa arquitetura mantém as peças separadas, e uma mudança em um lugar não quebra os outros. Isso importa todo dia na vida real. É por isso que uma loja pode trocar a empresa de pagamento sem reescrever o site inteiro, e que um aplicativo de celular pode usar um banco de dados hoje e outro amanhã.
Neste resumo você vai aprender o que é arquitetura e como a julgamos, e depois uma família de estilos que respondem a uma pergunta: como proteger as regras de negócio (as regras que tornam o programa útil, como "o total do carrinho é a soma dos itens") dos detalhes (o banco de dados, o framework web, a tela)? Você vai conhecer camadas, o hexágono, a arquitetura limpa e sua regra de dependência, um programa único contra vários serviços pequenos, eventos e o vocabulário do domain-driven design. No fim você verá por que toda escolha é um trade-off e como as equipes registram suas decisões.
O que é arquitetura e atributos de qualidade
Imagine que você monta uma cidade de brinquedo com blocos. Algumas escolhas são baratas de desfazer: a cor de um telhado. Outras são caras: por onde passa a rua principal, porque todas as casas são colocadas em volta dela. No software, a arquitetura é feita das escolhas caras. Autores como Martin Fowler e Ralph Johnson a descrevem como o conjunto das decisões de projeto que importam, as difíceis de mudar. Ela também vive no entendimento compartilhado da equipe, não só em um documento.
Robert Martin acrescenta que não existe uma linha clara entre arquitetura e projeto detalhado. As decisões pequenas sustentam as grandes, então tudo é um contínuo. Ele também diz qual é o objetivo: minimizar as pessoas e o esforço necessários para construir e manter o sistema. Uma boa arquitetura mantém baixo o custo de cada mudança durante toda a vida do programa. Um jeito de conseguir isso é manter as opções abertas, ou seja, não escolher um detalhe cedo demais.
Requisitos funcionais e não funcionais
Um requisito funcional diz o que o programa faz, como "um cliente pode fazer um pedido". Um requisito não funcional diz com que qualidade ele faz: quão rápido, quanto tempo fica no ar, quão seguro, quão fácil de mudar. As partes individuais implementam as funções, mas as propriedades não funcionais dependem da arquitetura como um todo. Por isso duas lojas virtuais com os mesmos botões podem precisar de arquiteturas muito diferentes: uma para 50 usuários em um escritório, outra para 5 milhões de usuários no mundo todo que precisa estar no ar 99,99% do tempo.
Atributos de qualidade precisam de um cenário mensurável
Um atributo de qualidade é uma palavra como desempenho, disponibilidade, segurança ou manutenibilidade. "O sistema deve ser rápido" não serve, porque nenhum teste consegue reprová-lo. Um bom requisito é um cenário: sob qual condição, qual resposta é medida e qual é o limite. Por exemplo: "com 1.000 usuários ao mesmo tempo, 95 de cada 100 buscas respondem em menos de 300 milissegundos". Ele descreve o resultado que queremos, não a solução técnica.
| Atributo | O que ele pede da estrutura |
|---|---|
| Desempenho | Trabalho crítico concentrado em poucas partes grandes que conversam o mínimo possível |
| Segurança | Camadas, com os dados mais valiosos na mais interna |
| Disponibilidade | Redundância: cópias reserva que assumem quando uma parte falha |
| Manutenibilidade | Partes pequenas e fáceis de substituir |
Olhe a tabela: desempenho quer poucas partes grandes, manutenibilidade quer muitas pequenas. Elas puxam para lados opostos, então uma arquitetura é sempre um compromisso entre os atributos que mais importam para aquele sistema.
Ocultamento de informação
David Parnas teve uma ideia simples nos anos 1970: dividir o programa em módulos de modo que cada módulo esconda uma decisão que provavelmente vai mudar, como a forma de guardar os dados. Os outros módulos só conhecem uma interface pequena e estável (a lista do que o módulo oferece). Assim, quando a decisão muda, a mudança fica dentro de um módulo. Se seis arquivos leem a mesma lista global de pedidos e esperam que ela esteja ordenada por data, mudar essa lista quebra seis arquivos. É o contrário de esconder.
Visões, pastas e equipes
Um desenho só não mostra tudo. O modelo de visões 4+1 de Kruchten usa quatro visões: lógica (as ideias principais), de processos (o que roda ao mesmo tempo), de desenvolvimento (os módulos que os programadores manuseiam) e física (que máquinas rodam o quê). O "+1" é um conjunto de cenários que amarra as quatro. A visão sobre processadores e máquinas é a física.
Robert Martin também fala de uma arquitetura que grita (screaming architecture). A planta de uma biblioteca "grita" que é uma biblioteca. Do mesmo modo, as pastas de topo de um programa devem gritar o que o programa faz (pedidos, pagamentos), não qual framework ele usa. Se as pastas dizem só controllers, models e views, o framework é quem está decidindo a forma.
Grita o framework Grita o negócio
src/ src/
controllers/ pedidos/
models/ pagamentos/
views/ entregas/
helpers/ catalogo/A lei de Conway (1968) diz que as organizações constroem sistemas cuja estrutura copia o modo como a própria organização conversa. Três equipes escrevendo um compilador tendem a fazer um compilador de três passos. Ela descreve o que costuma acontecer, não é uma regra de projeto. A manobra de Conway inversa usa a lei de propósito: primeiro organize as equipes do jeito que você quer a arquitetura, depois deixe que a construam. Desenhar novos serviços no papel mantendo as equipes antigas costuma trazer a divisão antiga de volta com nomes novos.
↑ Voltar ao sumárioArquiteturas em camadas e hexagonal
Pense em uma escola. O aluno pede ao professor, o professor pede à secretaria, e a secretaria procura no arquivo. Ninguém pula do aluno direto para o arquivo. Essa é a arquitetura em camadas: o programa é cortado em níveis, do mais próximo do usuário até o mais próximo dos dados. Cada camada oferece serviços à camada de cima e usa apenas os serviços da camada logo abaixo. Por isso uma camada pode ser trocada sem incomodar as outras, desde que sua interface continue a mesma.
+------------------------+
| Apresentação | telas, páginas web
+------------------------+
| usa
v
+------------------------+
| Negócio | regras
+------------------------+
| usa
v
+------------------------+
| Persistência | acesso ao banco
+------------------------+Camada e tier são palavras diferentes
Uma camada (layer) é uma divisão lógica: um grupo de código com uma tarefa, como "apresentação". Um tier é uma divisão física: onde o código roda, por exemplo o navegador, o servidor de aplicação e o servidor de banco de dados. Você pode ter três camadas rodando em uma única máquina, ou duas camadas espalhadas por cinco máquinas. As duas contagens não dependem uma da outra.
Camadas estritas e relaxadas
Na divisão em camadas estrita (fechada) uma camada chama apenas a camada logo abaixo. Isso isola as mudanças, mas às vezes uma camada tem métodos cuja única tarefa é repassar a chamada. Na divisão relaxada (aberta) uma camada pode chamar qualquer camada abaixo dela. Isso elimina o repasse inútil, mas aumenta o acoplamento (quanto uma parte depende da outra), porque mais camadas passam a depender da mesma camada de baixo.
Quando a maioria das requisições atravessa uma camada que só repassa, chamamos isso de anti-padrão sinkhole (um anti-padrão é um mau hábito comum). Alguns casos assim são normais. Quando são a maioria, repense as camadas ou abra algumas delas, aceitando o acoplamento extra.
Arquitetura hexagonal: portas e adaptadores
Alistair Cockburn teve outra imagem. Pense em um notebook com tomadas nas laterais. O notebook não liga para o que você conecta, desde que o plugue encaixe na tomada. Na arquitetura hexagonal (também chamada de portas e adaptadores) a aplicação fica no centro. Ela conhece apenas portas: interfaces que a própria aplicação declara, dizendo o que ela precisa ou o que oferece. Cada tecnologia externa se conecta por um adaptador, uma peça que traduz entre a porta e a tecnologia. A palavra porta aqui não tem nada a ver com portas de rede.
Controller HTTP --+ +-- Adaptador PostgreSQL
Linha de comando -+--> [PORTA] <-+
Teste automático -+ APLICAÇÃO +-- Adaptador em memória
adaptadores de entrada adaptadores de saída
(iniciam a chamada) (são chamados)Por que um hexágono? Só porque é uma forma com muitos lados. Cockburn queria mostrar muitas portas equivalentes, sem "cima" e "baixo" como no desenho das camadas. O que importa é a diferença entre o dentro (a aplicação) e o fora (os adaptadores).
| Tipo de adaptador | Quem inicia a chamada? | Exemplos |
|---|---|---|
| De entrada (driving, primário) | O mundo externo chama a aplicação | Controller HTTP, ferramenta de linha de comando, um teste automático |
| De saída (driven, secundário) | A aplicação chama o mundo externo por uma porta que ela declarou | Repositório, enviador de e-mail, relógio |
Uma porta pode ter vários adaptadores: o banco de dados real, um que guarda os dados em memória, um dublê de teste. Como a aplicação depende só da porta, o adaptador é escolhido de fora. Assim as regras de negócio podem ser testadas sem banco de dados, e o adaptador real é testado separadamente.
A grande diferença das camadas clássicas
Nas três camadas clássicas as dependências apontam para baixo, então a camada de negócio depende da persistência e acaba moldada pelo banco de dados. Na arquitetura hexagonal o núcleo declara a porta, e a persistência a implementa. As chamadas continuam indo do núcleo para o banco, mas a dependência no código-fonte aponta para o núcleo.
// Em camadas: o negócio importa a camada de banco
import { NoteTable } from "../persistence/note-table";
// Hexagonal: o núcleo importa só a sua própria porta,
// e o adaptador do banco importa a porta
import type { NoteRepository } from "./ports/note-repository";O padrão BCE de Ivar Jacobson tem o mesmo espírito, com três tipos de objeto: boundary (conversa com os atores de fora), control (coordena os passos de um caso de uso) e entity (guarda dados e regras do domínio). É uma das influências da arquitetura limpa, junto com a hexagonal e o DCI: entity corresponde às entidades, control aos casos de uso, boundary aos adaptadores de interface. Se a classe que lê a requisição HTTP também guarda a sequência do fluxo, ela mistura boundary e control.
↑ Voltar ao sumárioArquitetura limpa e a regra de dependência
Imagine uma cebola. No meio está a parte mais preciosa, e cada casca em volta a protege. A arquitetura limpa de Robert Martin desenha o programa como círculos dentro de círculos. Do centro para fora: entidades (as regras de negócio mais gerais), casos de uso (regras desta aplicação em particular), adaptadores de interface (controllers, presenters, repositórios) e, por fora, frameworks e drivers (o framework web, o banco de dados, as ferramentas). Quanto mais para dentro, mais alto o nível da política.
+----------------------------------------+
| Frameworks e drivers |
| +----------------------------------+ |
| | Adaptadores de interface | |
| | +----------------------------+ | |
| | | Casos de uso | | |
| | | +----------------------+ | | |
| | | | Entidades | | | |
| | | +----------------------+ | | |
| | +----------------------------+ | |
| +----------------------------------+ |
+----------------------------------------+
toda seta de dependência aponta para dentroA regra de dependência
A regra de dependência diz: as dependências no código-fonte apontam apenas para dentro. O código de um círculo interno não conhece nenhum nome de um círculo externo, nem classe, nem função, nem tipo. Como resultado, as regras de negócio não mudam quando você troca o banco de dados ou o framework web.
Martin chama o banco de dados e a web de detalhes. São maneiras de trazer dados, enviá-los e guardá-los, mas não são o motivo de o sistema existir. As regras ficam no centro, e os detalhes se conectam por fora, como plug-ins. Isso permite adiá-los e substituí-los. Também dá as características principais que Martin lista: independência de frameworks, da interface de usuário e do banco de dados, e regras que podem ser testadas sem nenhum deles.
Ser independente de frameworks não significa nunca usar um. Significa que as regras de negócio não dependem dele. O framework fica no círculo mais externo e é tratado como uma ferramenta. Além disso, o código de fora muda com frequência e o de dentro raramente, e como as dependências apontam para dentro, o que muda muito depende do que muda pouco, nunca o contrário.
As chamadas vão para fora, as dependências vão para dentro
Aqui está o enigma. Um caso de uso precisa salvar uma nota no banco de dados, então em tempo de execução ele chama código de um círculo externo. Isso quebra a regra? Não, porque o fluxo de controle e a dependência no código-fonte são coisas diferentes. A solução é a inversão de dependência (o D do SOLID, um conjunto de cinco princípios de projeto). O caso de uso declara uma interface própria, a porta NoteRepository. A classe externa PostgresNoteRepository a implementa. Assim a chamada vai para fora, mas a dependência no código-fonte aponta para dentro.
Ordem das chamadas em tempo de execução:
NoteController
-> CreateNote
-> PostgresNoteRepository
-> banco de dados
Onde cada nome é declarado:
adapters/ NoteController
use-cases/ CreateNote, NoteRepository (interface)
drivers/ PostgresNoteRepository (a implementa)| Palavra | O que é |
|---|---|
| Inversão de dependência | Um princípio: o código de alto nível e o de baixo nível dependem ambos de uma abstração |
| Injeção de dependênciaum objeto recebe de fora o que precisa, em geral pelo construtor | Um mecanismo que entrega o objeto real a quem precisa dele |
| Contêiner de IoC | Uma ferramenta opcional que cria e conecta objetos automaticamente |
Achando uma violação
Para checar a regra, leia as linhas de import de cada arquivo e compare a camada de quem importa com a camada do que é importado. Importar algo mais para dentro é permitido, mesmo pulando círculos. Importar algo mais para fora é a violação. Um tipo também é um nome. Em TypeScript um import type some na compilação, mas o acoplamento continua no código-fonte, porque uma mudança no tipo externo quebra o arquivo interno. Nomes externos também podem se esconder sem uma linha de import: o tipo global Response ou um código de status HTTP dentro de um caso de uso já o prende à web.
// use-cases/create-note.ts
import { Note } from "../entities/note"; // para dentro: permitido
import { PostgresNoteRepository }
from "../drivers/postgres-note-repository"; // para fora: VIOLAÇÃOComo a regra trata do código-fonte, uma máquina pode verificá-la sem rodar o programa. Um script ou uma regra de lint associa cada pasta a uma camada e rejeita qualquer import de uma camada interna para uma externa. Rodando no build ou na integração contínua (as verificações automáticas a cada mudança), ele barra a violação antes de o código ser mesclado.
↑ Voltar ao sumárioEntidades e casos de uso
Pense em um restaurante. Algumas regras pertencem à própria comida: uma salada tem um vegetal. Outras pertencem ao funcionamento deste restaurante: os pedidos são anotados nesta ordem, depois a cozinha é avisada. O primeiro tipo vive nas entidades, o segundo nos casos de uso.
Na arquitetura limpa, a entidade fica no círculo mais interno e guarda as regras de negócio mais gerais, as que valeriam mesmo se o trabalho fosse feito à mão, no papel. Ela não depende de banco de dados, framework nem tela. Por isso muda pouco e pode servir a mais de uma aplicação da mesma empresa. Um caso de uso (também chamado de interactor) guarda uma regra específica da aplicação: a sequência de passos de uma operação, como "criar uma nota". Ele orquestra as entidades e só alcança o banco de dados, o relógio ou a rede por portas que ele mesmo declara. Não sabe quem o chama, nem onde os dados são guardados.
| Pergunta | Regra de domínio (entidade) | Regra de aplicação (caso de uso) |
|---|---|---|
| Vale em | Qualquer aplicação que use o conceito | Só esta aplicação |
| Exemplo | Um título não pode ser vazio nem ter mais de 80 caracteres | Um usuário não pode ter duas notas com o mesmo título |
| Precisa de outros dados? | Não, olha só o próprio objeto | Muitas vezes sim: consulta as notas já guardadas |
| Muda | Raramente | Com mais frequência |
As entidades guardam as regras fechadas que definem o conceito. Uma regra que é decisão de um produto específico, que ainda pode mudar, ou que precisa olhar outros dados guardados vai para um caso de uso. Assim a entidade continua reutilizável e a regra fica fácil de achar no código da aplicação. Protocolo, paginação e tabelas são detalhes das camadas externas, não são regras.
Objetos de valor que não podem estar errados
Um objeto de valor (value object) é um objeto pequeno definido só pelos seus valores, como um Title. Se o construtor é privado e a única entrada é um método de fábrica create, ninguém consegue construir um inválido. Quem recebe um Title como parâmetro pode confiar nele, e a regra está escrita em um único lugar. O estado inválido se torna impossível de representar.
export class Title {
private constructor(public readonly value: string) {}
static create(raw: string): Result<InvalidTitleError, Title> {
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length > 80) {
return fail(new InvalidTitleError(raw));
}
return ok(new Title(trimmed));
}
}Repare que create devolve um Result (também chamado de Either): ou um sucesso com o valor, ou uma falha com um erro. Erros esperados, como um título ruim, fazem parte do contrato da operação, então um Result os mostra na assinatura e obriga quem chama a decidir o que fazer. As exceções ficam para falhas que ninguém esperava, como um banco de dados fora do ar.
O modelo de domínio anêmico
Martin Fowler deu nome a um anti-padrão: o modelo de domínio anêmico. As classes de domínio só guardam dados, e todas as regras vivem em serviços procedurais separados em volta delas. A ideia central da orientação a objetos, manter dados e regras juntos, se perde. O resultado costuma ser regras duplicadas e objetos que podem acabar em estado inválido.
O que atravessa a fronteira
A entrada e a saída de um caso de uso são estruturas de dados simples, sem comportamento, definidas pela própria camada de casos de uso. O controller transforma a requisição nessa entrada, e o presenter transforma a saída no que a tela precisa. Assim nem o framework nem a entidade vazam para o outro lado. Um caso de uso também não deve saber se foi chamado por HTTP, por linha de comando ou por um teste: ele devolve um resultado em termos da aplicação, e cada adaptador o traduz. Códigos de status, SQL e console.log não pertencem a ele.
Portas para tempo e aleatoriedade
A hora atual e os valores aleatórios mudam a cada execução. O código que os lê diretamente (new Date(), crypto.randomUUID()) não é determinístico: não dá a mesma resposta para a mesma entrada. Por isso o caso de uso recebe um IdGenerator e um Clock como portas. Nos testes entregamos versões fixas, em produção as reais, e o caso de uso nunca muda.
Uma porta pertence ao código que a usa. Por isso NoteRepository é declarada junto dos casos de uso, e PostgresNoteRepository a implementa em uma camada externa. Um teste de CreateNote pode então usar um repositório em memória, um relógio falso e um gerador de ids falso, sem servidor e sem banco de dados. Ele roda em milissegundos e verifica só a regra da aplicação. SQL, rotas e a ligação das peças são verificados pelos testes de cada adaptador.
Adaptadores de interface
Quando você viaja para outro país, pode levar um adaptador de tomada para o carregador encaixar numa parede diferente. A camada de adaptadores de interface faz o mesmo trabalho para os dados. Ela fica entre os casos de uso e o mundo externo e converte os dados da forma melhor para casos de uso e entidades para a forma melhor para a web ou o banco de dados, e vice-versa. Controllers, presenters e gateways vivem aqui.
requisição -> Controller -> dado de entrada -> Caso de uso
|
dado de saída
v
tela <- View <- view model <- Presenter| Peça | Tarefa | O que não deve fazer |
|---|---|---|
| Controller | Recebe uma requisição externa, extrai os dados, chama o caso de uso, repassa o resultado | Decidir regras de negócio, acessar o banco, conhecer detalhes de exibição |
| Presenter | Transforma a saída do caso de uso em um view model: textos formatados, flags, rótulos | Decidir qual é a regra de negócio |
| View | Copia os valores do view model para a tela ou o terminal | Ter qualquer lógica |
| Gateway ou repositório | Traduz entre o formato de armazenamento e as entidades | Ser importado pelo caso de uso |
Um bom controller é fino: simples de testar e simples de trocar. A mesma saída de caso de uso pode ter presenters diferentes, por exemplo um para HTTP e outro para a linha de comando.
O objeto humilde
Algum código é difícil de testar, como desenhar pixels numa tela. O padrão Humble Object divide o comportamento em duas partes. A parte difícil de testar vai para um objeto "humilde" com o mínimo de lógica. Todo o resto vai para um objeto comum, fácil de testar. Na arquitetura limpa a view é o objeto humilde, e o presenter é a parte testável. Como o presenter já preparou cada texto e flag, a view só os copia.
Mantendo o framework fora
Se o controller recebesse o objeto de contexto do framework web, a camada de adaptadores passaria a depender de um detalhe da camada externa. Em vez disso, os adaptadores declaram tipos simples de requisição e resposta. O framework converte o seu contexto para eles e chama o controller. O controller pode então ser testado com objetos literais simples. Pelo mesmo motivo, nem a requisição do framework nem uma linha do banco são passadas para dentro: isso faria a camada interna conhecer algo da externa. O controller separa os campos e monta a estrutura de entrada.
// adapters/http.ts: tipos próprios, sem importar framework
export type HttpRequest = { body: unknown; params: Record<string, string> };
export type HttpResponse = { statusCode: number; body: unknown };Traduzindo erros
Um caso de uso diz o que aconteceu com um erro de domínio ou de aplicação, sem citar nenhum protocolo. Por exemplo, RemoveNote devolve NoteNotFound. Decidir que isso significa o status HTTP 404 é trabalho do adaptador: entrada inválida vira 400, recurso inexistente vira 404, falha inesperada vira 500. Um adaptador de linha de comando traduziria os mesmos erros de outro jeito, como uma mensagem e um código de saída.
Dois tipos de validação
São duas verificações, em lugares diferentes. A validação sintática pergunta se a entrada externa tem o formato esperado: os campos existem, os tipos estão certos? Acontece na fronteira, onde pode-se usar uma biblioteca de esquemas como o Zod. A validação de negócio protege as invariantes do domínio, por exemplo que um título não é vazio e tem no máximo 80 caracteres. Ela vive nas entidades e nos objetos de valor, para valer em qualquer caminho de entrada, não só pela web.
Onde mora o repositório?
A interface NoteRepository pertence à camada de casos de uso, que define o que ela precisa. A implementação é um adaptador. Na divisão em camadas apresentada por Otávio Lemos, um adaptador que conversa diretamente com um driver ou uma biblioteca externa, como o cliente do PostgreSQL, fica na camada mais externa, onde importar a biblioteca é permitido. A camada de adaptadores de interface fica assim livre de dependências externas. O caso de uso nunca deve importar o arquivo do adaptador, esse é o defeito a evitar.
Frameworks, drivers e a raiz de composição
Em uma casa, as paredes e os cômodos são a planta, mas as tomadas, as torneiras e a tinta são coisas que você pode trocar. O círculo mais externo da arquitetura limpa guarda essas coisas substituíveis: o framework web, o driver do banco de dados, as bibliotecas de entrada e saída e o pequeno código de ligação que as conecta aos adaptadores. Em geral se escreve pouco código próprio ali. Martin chama esse círculo de detalhes, porque nada nele define o que o sistema faz, apenas como é entregue e guardado.
Ele aconselha manter o framework "à distância de um braço". Um framework é escrito por pessoas que não conhecem o seu problema e não têm compromisso com ele. Se suas entidades e casos de uso estendem as classes base do framework, o compromisso é de mão única: seu código depende do framework, e uma nova versão ou uma troca significa reescrever o centro. Mantido na borda, atrás de adaptadores, o framework é usado sem que o sistema se case com ele.
A raiz de composição
Alguém precisa criar os objetos reais e ligá-los. Pense no eletricista que conecta tudo numa casa nova: ele precisa conhecer todos os cômodos, mas nenhum cômodo precisa conhecê-lo. Esse lugar é a raiz de composição (composition root; Martin a chama de Main). É o ponto de entrada do programa, o componente mais sujo e mais externo. Ele conhece todas as camadas e todas as ferramentas, então nada pode depender dele. Concentrar a montagem em um só lugar deixa o resto do código depender apenas de abstrações.
main/ (raiz de composição)
lê a configuração
new PostgresNoteRepository(url) --+
new UuidGenerator() --+--> new CreateNote(...)
new SystemClock() --+
entrega o objeto pronto aos pontos de entradaInjeção pelo construtor
Com a injeção pelo construtor, uma classe diz no construtor o que precisa e recebe um objeto pronto. Se em vez disso a classe cria a dependência com new, ou a busca em um service locator (uma prateleira global onde os objetos são procurados), ela fica presa a uma implementação e esconde essa ligação de quem a usa. Com a injeção, a dependência fica visível na assinatura e pode ser trocada nos testes.
// A: escondida, presa ao PostgreSQL
private readonly notes = new PostgresNoteRepository();
// B: visível, substituível
constructor(private readonly notes: NoteRepository) {}O experimento da troca
Um bom teste do projeto inteiro: adicione uma nova classe SqliteNoteRepository que implementa a porta. Que código existente precisa mudar para a aplicação rodar com ela? Se tudo por dentro depende de portas e a montagem está em fábricas da raiz de composição, a resposta é só a raiz de composição: uma fábrica lê a configuração na partida e escolhe a implementação. Se outro arquivo precisa mudar, há acoplamento indevido em algum lugar.
Configuração e segredos
Variáveis de ambiente, como MAX_NOTES, são um detalhe do ambiente de execução. Por isso são lidas na borda, pela raiz de composição, que as valida e converte uma só vez e as passa para dentro como valores simples pelos construtores. As camadas internas nunca tocam em process.env, e um teste configura um caso de uso apenas passando outro número.
A configuração que muda por ambiente, e sobretudo os segredos como a senha do banco de dados, ficam fora do código e fora do repositório. O arquivo .env real é listado no .gitignore. Um arquivo como .env.example vai para o repositório, com os nomes das variáveis e valores obviamente falsos. Quem chega novo sabe o que configurar, e nenhuma credencial fica no histórico.
ORMs
Um ORM (mapeador objeto-relacional) é uma ferramenta que converte linhas de tabela em objetos. É um detalhe de persistência e pertence à borda. Decorators na entidade, ou uma entidade que herda de uma classe do ORM (o estilo Active Record), fazem o centro depender da ferramenta. A alternativa compatível é o estilo Data Mapper: um esquema separado na camada externa e um repositório que traduz entre linhas e entidades.
↑ Voltar ao sumárioMonólito contra microsserviços
Um monólito é como uma marmita grande: tudo está em uma só caixa, e você carrega tudo junto. No software, um monólito é um sistema entregue como uma única unidade implantável (uma coisa que você publica inteira). A palavra trata de como o sistema é empacotado e publicado, não de como o código é organizado por dentro. Assim, um monólito pode ser bem organizado, e um grupo de serviços pode ser uma bagunça.
Um microsserviço é um serviço que pode ser implantado sozinho, é dono dos seus dados e é construído em torno de uma capacidade de negócio, como pagamentos ou catálogo. O tamanho em linhas de código não faz parte da definição. A pergunta útil é: posso mudar e publicar este serviço sem pedir aos outros?
| Característica | Monólito | Microsserviços |
|---|---|---|
| Publicação | Uma unidade, tudo junto | Cada serviço sozinho |
| Dados | Em geral um banco compartilhado | Cada serviço tem o seu banco |
| Chamadas entre as partes | Chamadas de função locais | Chamadas de rede, que podem falhar ou demorar |
| Bom em | Simplicidade, mudanças fáceis no início | Equipes independentes e escala independente |
| Risco principal | Um novelo de código embaraçado | Os custos de um sistema distribuído |
Um banco de dados por serviço
Se dois serviços leem e escrevem as mesmas tabelas, o esquema do banco vira um contrato entre eles que ninguém escreveu. Toda mudança estrutural precisa ser combinada e publicada junto, e isso anula a implantação independente. Por isso cada serviço guarda os seus dados e os mostra aos outros só pela sua API ou por eventos.
Monólito modular e monólito distribuído
O monólito modular tem uma única implantação com fronteiras fortes por dentro: cada módulo é uma área de negócio, expõe uma interface pública e não deixa os outros alcançarem seus detalhes. Ele dá boa parte da ordem dos microsserviços sem o custo de um sistema distribuído. Se um dia um serviço precisar ser extraído, a fronteira já existe.
O erro oposto é o monólito distribuído. O sistema foi cortado em serviços, mas eles continuam fortemente amarrados: precisam ser publicados juntos, compartilham um banco ou formam longas cadeias de chamadas síncronas. Ele tem os problemas dos dois mundos. Um teste prático: um serviço pode ser publicado sozinho? Se cada publicação exige os seis serviços de uma vez, numa ordem fixa, é um monólito distribuído.
Monólito primeiro
O conselho associado a Martin Fowler é começar com um monólito. No início de um produto ninguém sabe ainda onde ficam as fronteiras certas. Em um monólito, corrigir uma fronteira errada é uma refatoração local (reorganizar o código sem mudar o que ele faz). Entre serviços, a mesma correção significa mudar contratos de rede, migrar dados e coordenar publicações.
Corte por negócio, não por camada
Os serviços devem ser cortados por capacidade de negócio (ou contexto delimitado), não por camada técnica. Uma funcionalidade costuma atravessar todas as camadas: um campo no formulário, uma regra, uma coluna. Se os serviços são ui, business-logic e data-access, uma funcionalidade muda três serviços e três equipes. Se são catalogo, pedidos e pagamento, o que muda junto fica junto.
Por camada (ruim) Por negócio (melhor)
servico-ui servico-catalogo
servico-regras-negocio servico-pedidos
servico-acesso-dados servico-pagamento
servico-entregaA rede não é uma chamada de função
Uma chamada local termina ou falha junto com quem a chamou. Uma chamada de rede tem um terceiro resultado: desconhecido. Se uma requisição estoura o tempo limite, talvez o outro lado tenha feito o trabalho e só a resposta se perdeu. Isso é a falha parcial, e "a rede é confiável" é uma das falácias clássicas dos sistemas distribuídos. Tentar de novo às cegas pode cobrar um cartão duas vezes. A defesa é tornar a operação idempotente (fazer duas vezes tem o mesmo efeito de fazer uma), por exemplo enviando uma chave de idempotência em cada tentativa.
Cadeias de chamadas síncronas também prejudicam a disponibilidade, porque a requisição precisa de todos os serviços ao mesmo tempo, então as disponibilidades são multiplicadas. Com cada serviço no ar 99% do tempo e falhas independentes:
- 1 serviço
- 99 %
- 2 em cadeia
- 98.01 %
- 3 em cadeia
- 97.03 %
- 6 em cadeia
- 94.15 %
Para três serviços a conta é 0,99 x 0,99 x 0,99 = 0,970299, cerca de 97,03%. Para evitar cadeias longas, as equipes usam comunicação assíncrona, cache ou respostas degradadas.
Sagas
Fazer um pedido precisa reservar estoque em um serviço e cobrar um cartão em outro, cada um com seu banco. Não existe uma transação única para os dois. Uma saga divide a operação em transações locais, uma por serviço. Se um passo falha, os passos anteriores são desfeitos com ações compensatórias: novas transações com o efeito oposto, como liberar a reserva ou estornar a cobrança. Entre o início e o fim o sistema fica temporariamente inconsistente. Isso se chama consistência eventual.
↑ Voltar ao sumárioArquitetura orientada a eventos e CQRS
Na escola, o sinal toca e todo mundo sabe o que fazer, embora o sinal não converse com ninguém um por um. Em uma arquitetura orientada a eventos, partes de um sistema anunciam que algo aconteceu, e outras partes reagem. Quem anuncia não precisa saber quem escuta, então o acoplamento é baixo.
Evento ou comando?
Um evento é a notícia de um fato que já aconteceu, por isso é nomeado no passado: OrderPlaced. Quem o publica não o envia a ninguém em particular. Um comando é um pedido no imperativo: PlaceOrder. Ele é dirigido a um único tratador, que pode aceitá-lo ou recusá-lo. A diferença está na intenção da mensagem, não no transporte.
Tópico ou fila?
| Característica | Tópico publish/subscribe | Fila ponto a ponto |
|---|---|---|
| Quem recebe a mensagem? | Cada assinante recebe a sua própria cópia | Apenas um dos consumidores concorrentes |
| Usada para | Avisar várias partes do mesmo fato | Dividir trabalho |
| Adicionar mais consumidores | Mais reações ao mesmo evento | Mais vazão |
Garantias de entrega e idempotência
Os brokers (os programas que transportam mensagens) costumam prometer entrega pelo menos uma vez (at-least-once): nenhuma mensagem se perde, mas algumas podem chegar mais de uma vez, por exemplo quando a confirmação do consumidor não chega ao broker. Por isso o consumidor precisa ser idempotente: processar uma mensagem duas vezes deve dar o mesmo resultado que uma. A técnica usual é guardar o identificador de cada evento processado e ignorar os repetidos.
async function onOrderPaid(event: OrderPaid) {
// Sem a checagem, um evento repetido soma os pontos duas vezes
if (await alreadyProcessed(event.eventId)) return;
await addPoints(event.customerId, event.points);
await markProcessed(event.eventId);
}Ordem em um log particionado
Convenção: um log particionado como o Apache Kafka. Um tópico tem várias partições, e a chave da mensagem escolhe a partição. A garantia de ordem vale por partição, não para o tópico inteiro. Para manter os eventos de uma conta em ordem (AccountOpened, MoneyDeposited, AccountClosed), dê a todos a mesma chave, como o id da conta, para que vão para a mesma partição. Eventos de contas diferentes são processados em paralelo, sem ordem definida entre eles.
Coreografia ou orquestração?
Em uma coreografia, os serviços reagem aos eventos uns dos outros e nenhum conhece o fluxo inteiro, como dançarinos que olham uns para os outros. Em uma orquestração, um coordenador central diz a cada serviço o que fazer e em que ordem, como um maestro. A coreografia diminui o acoplamento, mas deixa o fluxo difícil de ver e depurar. A orquestração mantém o fluxo visível, ao custo de um componente que conhece todo mundo.
O problema da escrita dupla e o outbox
Suponha que um programa salva um pedido no banco de dados e depois publica OrderPlaced no broker. São dois sistemas e dois passos separados, então não são atômicos: o programa pode cair entre eles, deixando um pedido que foi salvo mas nunca anunciado. O padrão transactional outbox resolve isso. O evento é salvo em uma tabela do mesmo banco, na mesma transação da mudança de estado. Um relay lê essa tabela e publica no broker. Como o relay pode publicar a mesma linha duas vezes, a entrega é pelo-menos-uma-vez, e os consumidores precisam ser idempotentes.
uma transação depois, em segundo plano
+--------------------+ +-------+ +--------+
| INSERT pedido | | relay | ----> | broker |
| INSERT linha outbox| -------> | lê a | +--------+
+--------------------+ | tabela|
+-------+CQRS
O CQRS (Command Query Responsibility Segregation) separa o modelo que altera o estado (comandos) do modelo que lê (consultas). Isso é tudo o que o padrão exige. Event sourcing, bancos separados, mensageria e microsserviços são escolhas extras que aumentam o ganho em alguns casos e o custo em todos. Em um CRUD simples (criar, ler, atualizar, apagar), em que leituras e escritas têm o mesmo formato, a separação é complexidade sem retorno.
Se o modelo de leitura é atualizado de forma assíncrona a partir de eventos, ele fica desatualizado por pouco tempo depois de cada escrita. Um usuário renomeia um projeto, o comando funciona, e a lista carregada logo em seguida ainda mostra o nome antigo. Segundos depois mostra o novo. É o problema do read-your-own-writes (ler a própria escrita). Saídas comuns: mostrar o valor que o próprio comando devolveu, mandar a próxima leitura para o modelo de escrita, ou esperar até a projeção alcançar a versão escrita.
↑ Voltar ao sumárioFundamentos de domain-driven design
Imagine uma médica e um programador que precisam construir um sistema de hospital. Se a médica diz "prontuário" e o código do programador diz Record, eles vão se entender mal uma vez após a outra. O domain-driven design (DDD), de Eric Evans, é um jeito de construir software em torno de uma compreensão profunda do domínio, a área de negócio sobre a qual o software trata.
A linguagem ubíqua é o vocabulário que desenvolvedores e especialistas do domínio constroem juntos e usam em todo lugar: reuniões, documentos, testes e código. Se o especialista diz "apólice" e o código diz Contract, toda conversa precisa de uma tradução, e os mal-entendidos entram. A linguagem vale dentro de um contexto delimitado.
Design estratégico: o mapa geral
Um contexto delimitado (bounded context) é a fronteira dentro da qual um modelo e suas palavras têm um único significado. Em uma loja virtual, vendas pode usar "produto" para um item com preço e descrição, e a entrega pode usar "produto" para um pacote com peso e tamanho. Forçar um modelo único para a empresa toda produz classes inchadas e palavras confusas. O DDD aceita vários modelos, cada um consistente no seu contexto, e deixa explícitas as ligações entre eles.
O mapa de contextos mostra como os contextos se relacionam e quem tem poder sobre quem. Quando você depende de um contexto que não consegue influenciar e cujo modelo não combina com o seu, como um sistema de cobrança legado, você constrói uma camada anticorrupção. Ela traduz entre os dois modelos na fronteira. Dá trabalho escrever e manter, mas o seu novo modelo não herda os problemas do antigo.
O design estratégico também divide o domínio em subdomínios e decide onde gastar esforço. Uma startup de logística que vence pela otimização de rotas deve colocar ali suas melhores pessoas e a modelagem mais cuidadosa.
| Subdomínio | O que é | O que fazer |
|---|---|---|
| Domínio central (core) | O que diferencia a empresa (otimização de rotas) | Melhores pessoas, modelagem cuidadosa |
| De apoio (supporting) | Necessário, mas específico da empresa e sem ser um diferencial | Soluções simples |
| Genérico | Funciona igual em qualquer empresa (autenticação, notas fiscais) | Comprar ou reutilizar |
Design tático: as peças dentro de um contexto
Uma entidade é definida por uma identidade contínua, não pelos valores dos seus atributos. Um Customer pode mudar de endereço e de e-mail ao longo dos anos e continua sendo o mesmo cliente, então duas entidades são comparadas pelo identificador. Um objeto de valor é definido só pelos seus valores: não tem identificador, é imutável (nunca muda depois de construído) e é comparado campo a campo. Money (um valor e uma moeda) é o exemplo clássico. As operações sobre ele devolvem um novo objeto, o que evita efeitos colaterais quando uma instância é compartilhada.
Um agregado é um grupo de objetos tratado como uma unidade de consistência, com uma entidade raiz como única porta de entrada. Para um Order com seus objetos OrderItem, a raiz é Order. Os objetos de fora guardam referência só à raiz, de preferência pelo identificador. Cada transação altera um agregado, o que mantém as regras simples de garantir e as transações curtas.
Agregado Order
+---------------------------------+
| Order (raiz) |
| +-- OrderItem |
| +-- OrderItem |
| +-- ShippingAddress |
+---------------------------------+
^
| só a raiz é referenciada de fora
Customer (outro agregado, por id)O repositório dá ao domínio a ilusão de uma coleção de agregados em memória: adicionar, buscar por identidade, remover. Existe um repositório por raiz de agregado. Os objetos internos são carregados e salvos junto com a raiz, e quantas tabelas existem por trás é um detalhe de implementação. Se um modelo tem os agregados Order e Customer, tem dois repositórios, mesmo que os dados usem quatro tabelas.
Um serviço de domínio abriga uma operação de negócio que não é a tarefa natural de nenhuma entidade ou objeto de valor, em geral porque envolve vários deles, como uma taxa de transferência que depende de duas contas. Ele não guarda estado e fala a linguagem ubíqua. O serviço de aplicação fica uma camada acima: coordena o caso de uso, a transação e os repositórios, sem regra de negócio própria.
Um evento de domínio registra algo relevante para o negócio que já aconteceu: pedido feito, pagamento confirmado. Como descreve o passado, é imutável e tem nome no passado. Outras partes podem reagir a ele sem que quem o emite as conheça, o que diminui o acoplamento entre agregados e contextos.
↑ Voltar ao sumárioTrade-offs e registros de decisão de arquitetura
Quando você arruma a mochila da escola, uma mochila grande carrega mais, mas é mais pesada. Não existe mochila perfeita. Do mesmo modo, um ditado conhecido em arquitetura diz que não existem melhores práticas, só trade-offs. Toda decisão abre mão de algo: mais flexibilidade em geral custa mais indireção, e mais desempenho em geral custa facilidade de manutenção. Uma solução não é boa ou ruim em si, é adequada ou não para um conjunto de requisitos e limites. O trabalho do arquiteto é deixar explícito o que se ganha e o que se perde.
Mantenha as opções abertas
Robert Martin aconselha que uma boa arquitetura deixe você adiar decisões sobre detalhes, como o banco de dados ou o framework, porque quanto mais tarde você decide, mais você sabe. O último momento responsável é o ponto depois do qual não decidir começa a custar mais do que decidir. Adiar não é não fazer nada: é construir de modo que o detalhe possa ser escolhido e trocado depois. Se a equipe ainda não sabe quantas leituras e escritas o sistema terá, esconda o banco atrás de uma porta e escolha quando os fatos chegarem.
Portas de mão dupla e portas de mão única
Algumas decisões são como uma porta pela qual você pode passar e voltar: uma porta de mão dupla (two-way door). Escolher uma biblioteca de formatação de datas, escondida atrás de uma função sua, é uma delas. Errar é barato, então decida rápido e aprenda tentando. Outras decisões são uma porta de mão única (one-way door): difíceis de reverter. O formato dos identificadores em uma API pública usada por milhares de clientes é uma delas. Essas merecem análise, um protótipo e um registro escrito. O que define o tipo é o custo de voltar atrás, e um bom isolamento transforma muitas decisões em portas de mão dupla.
| Característica | Porta de mão dupla | Porta de mão única |
|---|---|---|
| Custo de um erro | Pequeno | Grande |
| Como decidir | Rápido, depois aprender | Analisar, prototipar, escrever um ADR |
| Exemplo | Uma biblioteca de formatação atrás de uma função | O formato dos identificadores de uma API pública |
Registros de decisão de arquitetura
Anos depois, ninguém lembra por que o sistema é como é. Um registro de decisão de arquitetura (ADR) é um documento curto que registra uma decisão arquitetural e a razão dela. O formato de Michael Nygard tem um título, um status (proposto, aceito, substituído), um contexto, a decisão e suas consequências.
# ADR 7: Usar PostgreSQL no serviço de pedidos
Status: aceito
Contexto: pedidos precisam de transações; avaliamos o MongoDB.
Decisão: vamos usar PostgreSQL.
Consequências: consistência forte (bom); escalar escritas
na horizontal é mais difícil (desvantagem aceita).Um ADR útil mostra a troca: quais opções foram consideradas, por que as outras foram rejeitadas e quais consequências negativas a equipe aceitou. Um registro que lista só vantagens é propaganda, não documentação. As desvantagens aceitas deixam alguém, mais tarde, perceber que o contexto mudou e que a decisão deve ser revista.
Os ADRs são só de acréscimo (append-only), como um diário. Quando uma decisão muda, por exemplo ao sair do PostgreSQL para outro banco, você não edita nem apaga o registro antigo. Você escreve um novo ADR com o novo contexto, e o antigo recebe o status substituído (superseded), com uma referência ao novo. O histórico mostra então o que se sabia em cada momento.
Nem toda decisão precisa de um ADR. Escreva um para decisões estruturalmente importantes e difíceis de reverter: o estilo arquitetural, as fronteiras entre módulos ou serviços, o modelo de persistência, os protocolos entre as partes. Decisões locais e baratas de desfazer já são registradas pelo código e pelo histórico de commits. Registrar tudo enterra as decisões que importam.
Superengenharia
Camadas, portas e inversão de dependência são uma troca: acrescentam indireção (passos extras entre duas coisas) para deixar as mudanças futuras mais baratas e proteger as regras de negócio. Um script que uma pessoa roda uma vez por mês, com uma implementação de cada interface e sem plano de ter outra, paga o custo e nunca recebe o benefício. Usar todos os elementos da arquitetura limpa sem necessidade é superengenharia (overengineering).
Assista ao vídeo: Visualising software architecture with the C4 model (Simon Brown)↑ Voltar ao sumárioComo todas as ideias se ligam
Todos esses estilos respondem à mesma pergunta: como manter as regras que importam a salvo dos detalhes que mudam? As camadas cortam o programa por nível. O hexágono e a arquitetura limpa vão além e viram as dependências na direção das regras. Os microsserviços aplicam o mesmo raciocínio a implantações inteiras, e os eventos deixam as partes conversarem sem se conhecer. O DDD diz onde as fronteiras devem ficar, e os ADRs lembram por que você as escolheu.
| Ideia | Em uma linha | O que custa |
|---|---|---|
| Camadas | Cada camada usa só a de baixo | Métodos que só repassam, acoplamento às camadas de baixo |
| Hexagonal | A aplicação declara portas, adaptadores se encaixam | Mais interfaces e arquivos |
| Arquitetura limpa | As dependências no código apontam só para dentro | Indireção, tradução extra de dados |
| Monólito modular | Uma implantação, fronteiras internas fortes | Disciplina para manter as fronteiras |
| Microsserviços | Implantação independente e dados próprios | Falhas de rede, consistência eventual |
| Eventos e CQRS | Reagir a fatos, separar leituras de escritas | Duplicatas, leituras desatualizadas, depuração mais difícil |
| DDD | Modelar o negócio com uma linguagem compartilhada | Tempo gasto com especialistas do domínio |
Erros comuns
- Acreditar que os nomes das pastas ou o número de círculos tornam uma arquitetura limpa. Quem faz isso é a regra de dependência.
- Confundir a ordem das chamadas em tempo de execução com a direção dos imports.
- Dividir em serviços antes de entender o domínio e acabar com um monólito distribuído.
- Colocar códigos de status, SQL ou tipos do framework dentro de um caso de uso ou de uma entidade.
- Usar "rápido" ou "seguro" como requisito sem um cenário mensurável.
- Usar eventos sem tornar os consumidores idempotentes.
- Escrever um ADR que esconde as desvantagens, ou editar um ADR antigo em vez de substituí-lo.