Conexão · Interrompida

Algo não carregou

Parte desta página não chegou até você. Recarregue para tentar novamente — se persistir, verifique sua conexão.

Pular para o conteúdo principal
Engineering10 min de leitura

Uma Fitness Function É Só um Teste Que Quebra o Build Quando a Arquitetura Desvia

Uma fitness function não é um artefato de framework — é um teste que quebra o build e codifica um invariante arquitetural. Codifico uma regra de camadas em cerca de 60 linhas de TypeScript usando a própria API do compilador, testo o teste contra árvores boas, ruins e de código gerado, e então traço a linha entre um invariante que vale a pena travar e um gate de métrica que sai pela culatra sob a lei de Goodhart.

Todos os Posts
2/4

"Fitness function" soa como um artefato especial que você precisa instalar um framework para obter. Não é. Uma fitness function é uma verificação automatizada que codifica um invariante arquitetural e quebra o build quando o código se afasta dele. Martin Fowler e os autores de Building Evolutionary Architectures a definem como um teste que mede o quão perto uma implementação está de seus objetivos de design declarados. A palavra vem da biologia evolutiva, mas a implementação é um teste unitário que retorna um código de saída diferente de zero.

Fui atrás do mecanismo concreto porque o jargão continuava aparecendo sem o encanamento por baixo dele. O texto do O'Reilly Radar do fim de 2025 sobre governança de arquitetura agêntica me lembrou por que a maioria dos times nunca chega lá: a ideia foi introduzida na primeira edição do livro, de 2017, e a versão ambiciosa foi "em grande parte frustrada pela fragilidade". Fitness functions que quebram a cada refatoração legítima são desativadas em um mês. Então a pergunta real não é como escrever uma. É qual invariante vale a pena travar, e como escrever a verificação para que ela quebre pelo motivo certo.

Este post codifica um único invariante como um teste que quebra o build em TypeScript, sem nenhum framework de arquitetura, então testa o teste, e então traça a linha que hoje sustento entre um invariante que merece um gate rígido e cerimônia que só irrita as pessoas.

O único invariante que eu travo

A regra na qual confio o suficiente para quebrar um build é um limite de camadas: a camada de domínio não deve importar a camada de transporte. O código de domínio guarda regras de negócio. O código de transporte guarda handlers HTTP, serializadores, cola de framework. O domínio não pode saber nada sobre como uma requisição chegou. Quando essa borda se inverte — uma entidade de domínio alcança um módulo HTTP — o grafo de dependências apodrece silenciosamente, e toda mudança futura no transporte arrisca quebrar lógica de negócio que jamais deveria tê-lo visto.

Esse é um bom gate por uma razão: é um fato estrutural, não um número. Um import ou existe ou não existe. Não há limiar para discutir e nada para burlar. Compare com "nenhum arquivo acima de 300 linhas", ao qual chego mais adiante e que deliberadamente não travo.

O mecanismo é uma caminhada pelo grafo de imports. Não preciso de uma ferramenta pesada para isso. Os padrões populares no mundo TypeScript são o dependency-cruiser e o ArchUnitTS, e ambos servem. Mas a verificação em si é pequena o bastante para que a própria API do compilador TypeScript dê conta, e escrevê-la à mão torna o modo de falha óbvio em vez de escondê-lo atrás de configuração.

A imagem que estou codificando se parece com isto: dois clusters de módulos, a maioria das arestas legais, uma aresta cruzando o limite no sentido errado.

O que a verificação de grafo de imports de fato faz

Aqui está a coisa toda — a fitness function e seus próprios testes em um único arquivo executável.

typescript
import * as ts from "typescript";
import * as path from "node:path";

type SourceUnit = { file: string; code: string };
type Violation = { from: string; to: string; spec: string };

// One invariant: nothing in src/domain may import from src/transport.
const RULE = { layer: "domain", mustNotReach: "transport" };

function layerOf(file: string): string | null {
  const m = file.replace(/\\/g, "/").match(/(?:^|\/)src\/([^/]+)\//);
  return m ? m[1] : null;
}

function isGenerated(file: string): boolean {
  return /\.gen\.ts$|\.generated\.ts$/.test(file);
}

function importsOf(unit: SourceUnit): string[] {
  const sf = ts.createSourceFile(unit.file, unit.code, ts.ScriptTarget.Latest, true);
  const specs: string[] = [];
  const visit = (node: ts.Node) => {
    if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
      specs.push(node.moduleSpecifier.text);
    }
    ts.forEachChild(node, visit);
  };
  visit(sf);
  return specs;
}

export function checkLayering(units: SourceUnit[]): Violation[] {
  const violations: Violation[] = [];
  for (const unit of units) {
    if (isGenerated(unit.file)) continue;
    if (layerOf(unit.file) !== RULE.layer) continue;
    const dir = path.posix.dirname(unit.file.replace(/\\/g, "/"));
    for (const spec of importsOf(unit)) {
      if (!spec.startsWith(".")) continue; // third-party packages are not our layers
      const resolved = path.posix.normalize(path.posix.join(dir, spec));
      if (layerOf(resolved + "/") === RULE.mustNotReach) {
        violations.push({ from: unit.file, to: resolved, spec });
      }
    }
  }
  return violations;
}

// --- tests for the fitness function itself ---
function assert(cond: boolean, msg: string) {
  if (!cond) { console.error("FAIL:", msg); process.exit(1); }
}

const clean: SourceUnit[] = [
  { file: "src/domain/order.ts", code: `import { Money } from "./money";` },
  { file: "src/transport/http.ts", code: `import { Order } from "../domain/order";` },
];
const dirty: SourceUnit[] = [
  { file: "src/domain/order.ts", code: `import { send } from "../transport/http";` },
  { file: "src/domain/cache.gen.ts", code: `import { x } from "../transport/http";` },
];

assert(checkLayering(clean).length === 0, "clean tree must pass");
const found = checkLayering(dirty);
assert(found.length === 1, "one real violation; generated file must be ignored");
assert(found[0].spec === "../transport/http", "error must name the offending edge");

console.log(`ok: ${found.length} violation -> ${found[0].from} imports ${found[0].spec}`);

Rode com npx tsx fitness.ts. Ele imprime a única violação que encontrou na árvore suja e sai com zero porque as asserções se mantiveram; em CI você inverteria esse último passo para que qualquer violação saísse com valor diferente de zero.

Algumas linhas carregam o peso. importsOf usa ts.createSourceFile para parsear um módulo em uma AST e a percorre com ts.forEachChild, coletando apenas os nós ImportDeclaration cujo especificador é um literal de string. Essa é toda a extração de dependências — sem regex sobre o texto-fonte, que tropeçaria em comentários e strings. layerOf lê o nome da camada direto do caminho: o primeiro segmento sob src/. checkLayering resolve cada import relativo contra o diretório do arquivo importador com path.posix.join mais normalize, e então pergunta em qual camada o caminho resolvido cai. Se um arquivo de domain resolve um import para transport, essa aresta é uma violação, e o registro nomeia o especificador de import infrator exato — não apenas "existe uma violação".

Eu alimento unidades de fonte em memória aqui para que o exemplo rode como um único arquivo. Para CI, a única mudança é a entrada: percorra src/ no disco, leia cada arquivo .ts em um SourceUnit, e saia com valor diferente de zero quando o array retornado não estiver vazio. A lógica da verificação não muda.

Testando o teste

Uma fitness function que passa silenciosamente é pior que nenhuma fitness function, porque compra falsa confiança. Então a verificação precisa de seus próprios testes, e eles estão no fim do mesmo arquivo.

Três casos importam. Uma árvore sabidamente boa deve retornar zero violações, ou o gate está frouxo demais e deixará passar desvios reais. Uma árvore sabidamente ruim deve retornar exatamente as violações que plantei, ou o gate está medindo errado. E o erro deve nomear a aresta infratora — found[0].spec === "../transport/http" — porque um relatório de violação que não aponta para o import ruim desperdiça o tempo de quem tiver que consertá-lo às 17h de uma sexta-feira.

O quarto caso é o que aprendi a adicionar só depois que uma fitness function quebrou falsamente comigo: código gerado. A árvore dirty inclui src/domain/cache.gen.ts, que também importa transporte. Uma verificação ingênua o sinalizaria, o build ficaria vermelho por causa de código que ninguém escreveu à mão, e o time recorreria ao botão de desativar. isGenerated o exclui, e a asserção de que a árvore suja produz exatamente uma violação, não duas, trava essa exclusão. Essa é a fragilidade contra a qual o texto do O'Reilly alertou, capturada por um teste do teste em vez de por um incidente em produção.

O gate que eu recusei a adicionar

O próximo movimento tentador é continuar adicionando gates. "Nenhum arquivo acima de 300 linhas." "A cobertura de testes deve ficar acima de 80 por cento." "No máximo 5 parâmetros por função." Esses parecem fitness functions. São gates de métrica, e gates de métrica são onde a prática dá errado.

A razão é a lei de Goodhart, formulada pelo economista Charles Goodhart em 1975: quando uma medida vira um alvo, ela deixa de ser uma boa medida. A versão forte é mais afiada — até uma busca honesta e de boa-fé pela métrica, empurrada longe o bastante, danifica o objetivo do qual a métrica era um proxy. Um gate de cobertura é o caso clássico. Desenvolvedores perseguindo o número escrevem testes que executam linhas sem afirmar nada, então a cobertura sobe enquanto a qualidade real dos testes cai, e os testes de integração que de fato pegariam uma regressão são pulados porque são mais difíceis de escrever e movem menos o número. Um gate de contagem de linhas é pior: ele pune a refatoração que deleta código e recompensa dividir um módulo coeso em três incoerentes para se esgueirar abaixo do limiar.

A verificação de camadas não tem esse modo de falha. Não há número para otimizar e nenhuma forma honesta de "melhorar a pontuação" exceto não escrever o import proibido. A verificação mede um fato sobre a estrutura, e a única forma de satisfazê-la é manter a estrutura correta. Essa é a propriedade que hoje filtro antes de travar qualquer coisa: um engenheiro bem-intencionado pode tornar esta métrica melhor enquanto piora a base de código? Se sim, não entra no CI como gate rígido.

Quando travar, quando avisar, quando reportar

Nem todo invariante merece uma quebra de build. Depois de errar isso nas duas direções, classifico verificações candidatas em três baldes.

Gate rígido de CI, para invariantes estruturais que são binários e não podem ser burlados: limites de camadas, "nenhum módulo importa o pacote interno de um irmão", "nenhum acesso direto ao banco de dados fora da camada de repositório", "nenhuma dependência cíclica". Esses ou se mantêm ou não, e uma violação é sempre um problema real.

Aviso, não um gate, para verificações que geralmente estão certas mas têm exceções legítimas: uma nova dependência de terceiros aparecendo, uma superfície de API pública crescendo. Exiba o achado no pull request, deixe um humano julgar, não bloqueie o merge.

Relatório periódico, para qualquer coisa em formato de métrica: tamanhos de arquivo, tendência de cobertura, complexidade. Acompanhe a direção ao longo do tempo, olhe numa revisão, mas nunca a conecte a um build vermelho. Uma tendência que você discute é informação; um limiar que você impõe é um convite para burlá-lo.

O último modo de falha é organizacional, não técnico. Uma fitness function apodrece se ninguém a possui. No dia em que a verificação de camadas quebrar por um motivo que o autor não entende — uma convenção de caminho mudou, um novo padrão de arquivo gerado apareceu — alguém vai comentá-la para entregar, e ela nunca volta. A correção é a mesma disciplina de qualquer teste: quando quebra, você ou conserta o código ou conserta a verificação, e nunca a silencia. É por isso que mantenho a verificação pequena e seus próprios testes ao lado dela. Uma fitness function que consigo ler em uma tela é uma que vou reparar em vez de deletar.

Conclusões acionáveis:

  • Comece com um invariante que seja um fato estrutural, não um número — um limite de camadas é o primeiro gate de maior valor.
  • Escreva a verificação contra a própria ferramenta de AST da linguagem antes de recorrer a um framework; a API do compilador TypeScript percorre imports em uma dúzia de linhas.
  • Faça o relatório de violação nomear a aresta infratora exata, não apenas sua existência.
  • Teste a fitness function com uma árvore sabidamente boa, uma sabidamente ruim, e um caso de código gerado, para que ela quebre apenas por desvio real.
  • Filtre todo gate candidato contra a lei de Goodhart: se a otimização honesta da métrica pode machucar a base de código, não faça dela um gate rígido.

Recorra a uma fitness function quando um invariante é binário, estrutural e caro de violar silenciosamente — camadas, direção de dependências, limites de módulo. Evite conectar uma a qualquer métrica com um limiar, porque o limiar vira o alvo e o alvo é burlado. Uma fitness function que quebra pelo motivo errado é desativada em um mês; uma que nomeia uma violação estrutural real em termos claros conquista seu lugar no build.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.