Introdução
Como já dito anteriormente: A restrição: quando o comportamento de uma função depende de estado invisível no momento da leitura (uma variável global, um efeito colateral escondido atrás de uma "consulta", uma flag booleana cujo significado não está no call site), reproduzir um bug exige reconstruir todo esse contexto invisível — e é exatamente por isso que esses bugs consomem tempo desproporcional de debug comparado a bugs em código puro. O caso mais grave documentado na literatura de engenharia de software é o Therac-25 (1985-1987): uma máquina de radioterapia cujo software tinha uma condição de corrida (estado compartilhado mutável, dependente de timing) que, combinada com a remoção dos interlocks de segurança em hardware que existiam nas versões anteriores, liberou doses de radiação até 100x acima do previsto, matando ao menos três pacientes. O relatório de Nancy Leveson e Clark Turner (1993) aponta diretamente para o problema de confiar em estado compartilhado sem isolamento. Num registro bem menos trágico mas igualmente didático, o colapso da Knight Capital em 2012 — perda de US$460 milhões em 45 minutos — aconteceu porque uma flag booleana reaproveitada (que ligava um código morto de 8 anos atrás, chamado "Power Peg") foi ativada acidentalmente num deploy incompleto: ninguém mais conseguia dizer com certeza o que aquela flag realmente controlava. Isso é o princípio #7 na prática: booleanos e nulos acumulam significado ambíguo com o tempo até que ninguém consegue mais raciocinar sobre o que eles realmente ligam ou desligam.
O fio condutor continua o mesmo: estado escondido é a causa raiz dos bugs mais caros de rastrear. A diferença de um bug em código limpo pra um bug em código com estado escondido é a diferença entre achar uma chave debaixo do tapete e achar uma chave que foi escondida em algum lugar aleatório da casa há três meses, por alguém que nem trabalha mais aqui. Vamos nos três princípios, cada um com mais de um exemplo.
#5 Funções Puras
A base é a transparência referencial (Strachey, 1967): uma expressão só pode ser trocada pelo seu valor sem mudar o comportamento do programa se ela não depender de nem alterar nada fora dela. Backus, no discurso do Turing Award de 1977, chama o estilo oposto (mutação de estado compartilhado, ao estilo da arquitetura von Neumann) de gerador de uma distância conceitual enorme entre o problema e o código — cada função pura elimina essa distância porque pode ser entendida e testada isolada do resto do universo.
Por que isso importa na prática: uma função pura é testável sem mock, sem setup, sem ordem de execução. Você chama com os mesmos argumentos e sempre recebe o mesmo resultado. Uma função impura como CreateOrder(items) do exemplo esconde duas fontes de variação (Guid.NewGuid(), DateTime.Now) dentro da regra de negócio — isso significa que testar essa função exige ou aceitar não-determinismo no teste, ou fazer wrapping/mocking de chamadas estáticas do sistema, o que é sinal de que a impureza vazou pra dentro de algo que deveria ser lógica pura.
A correção não é só "passar como parâmetro" por estética — é sobre onde a decisão não-determinística deve morar. Guid.NewGuid() e DateTime.Now são decisões de infraestrutura (que ID usar, que relógio consultar), não decisões de negócio. Empurrar isso pra borda (controller, handler, o que for) significa que a regra de negócio (CreateOrder) fica livre de qualquer coisa que não seja lógica pura de domínio.
// Impura — Guid.NewGuid() e DateTime.Now escondidos dentro da regra de negócio
Order CreateOrder(List<Item> items)
{
return new Order(Guid.NewGuid(), items, DateTime.Now);
}
// Pura — id e data entram como parâmetro; quem gera esses valores é a borda (controller, por exemplo)
Order CreateOrder(List<Item> items, Guid id, DateTime createdAt)
{
return new Order(id, items, createdAt);
}
#7 Evitar null/booleanos
O artigo conecta isso diretamente ao caso Knight Capital: uma flag booleana reaproveitada ativou um código morto de 8 anos, e ninguém mais sabia com certeza o que aquela flag controlava. Isso não é sobre "booleano é ruim". É sobre o seguinte: um booleano só carrega um bit de informação, mas o significado desse bit precisa ser reconstruído de cabeça por quem lê o código, e esse significado tende a se degradar com o tempo — a flag original tinha um propósito claro, mas depois de anos de reaproveitamento, ninguém consegue mais dizer com certeza o que ligar/desligar aquele bit realmente faz no sistema como um todo hoje.
Com null o problema é análogo mas com uma variante a mais: null costuma significar simultaneamente "ainda não temos esse valor", "esse valor não se aplica" e "erro ao buscar esse valor" — três situações completamente diferentes, comprimidas na mesma representação. Quem lê if (cliente.Endereco == null) não sabe qual dos três casos está tratando, e o compilador não ajuda a diferenciar.
Exemplo — null com múltiplos significados:
// Ambíguo — null pode significar "não processado", "não se aplica" ou "erro"
public decimal? DesconTotalAplicado { get; set; }
if (pedido.DesconTotalAplicado == null)
{
// Ainda não calculado? Pedido sem desconto? Falha no cálculo?
// A leitura desse if não responde isso.
}
// Explícito — cada estado é um caso nomeado
public enum StatusDesconto { NaoCalculado, SemDesconto, ErroNoCalculo, Aplicado }
public record ResultadoDesconto(StatusDesconto Status, decimal Valor);
if (pedido.Desconto.Status == StatusDesconto.NaoCalculado)
{
// Agora não há ambiguidade — o call site já diz o que está acontecendo
}
Exemplo — booleano que vira flag de reaproveitamento:
// Perigoso — flag genérica que qualquer feature futura pode reaproveitar
public bool ModoAlternativo { get; set; }
if (config.ModoAlternativo)
{
ExecutarFluxoLegado(); // Daqui a 2 anos: alguém vai reativar isso achando que é outra coisa?
}
// Explícito — cada modo tem nome e propósito documentado no próprio tipo
public enum ModoExecucao { Padrao, ReprocessamentoLegado, MigracaoDados }
switch (config.Modo)
{
case ModoExecucao.ReprocessamentoLegado:
ExecutarFluxoLegado();
break;
}
O segundo exemplo é literalmente o padrão do "Power Peg" da Knight Capital — uma flag que ligava um caminho de código antigo, sem que o significado dela estivesse escrito em lugar nenhum além da cabeça de quem a criou originalmente (e essa pessoa provavelmente já não estava mais na empresa quando o incidente aconteceu).
Regra prática: se um booleano ou um null pode significar mais de uma coisa dependendo do contexto, ele deveria ser um enum, ou um tipo de resultado explícito (tipo Option<T>/Result<T,E>, ou um record com status nomeado). O custo de escrever o enum é uma linha; o custo de não escrever é alguém, meses depois, precisando reconstruir de memória o que aquele bit realmente significa — exatamente o tipo de reconstrução de contexto invisível que a introdução do artigo descreve como a razão desses bugs consumirem tempo desproporcional.
#12 Command Query Separation
Um método deve ser um Comando (faz algo, muda estado, não retorna valor) ou uma Query (responde uma pergunta, retorna valor, não muda nada) — nunca as duas coisas ao mesmo tempo.
O motivo disso se conectar diretamente ao fio condutor da introdução: uma função que parece uma pergunta ("me diga X") mas na verdade também é uma ordem escondida ("e de paço muda Y") é o caso mais insidioso de estado escondido, porque o nome da função mente sobre o que ela faz. Quem lê o call site vê uma leitura e não tem motivo nenhum pra suspeitar de um efeito colateral — e é exatamente essa lacuna entre o que o código aparenta fazer e o que ele realmente faz que faz um bug consumir horas de debug ao invés de minutos.
Exemplo clássico — método que parece query mas é comando:
// Viola CQS — o nome sugere consulta, mas o método também gera efeito colateral
public bool ValidarEProcessarPagamento(Pagamento pagamento)
{
var valido = pagamento.Valor > 0 && pagamento.CartaoValido;
if (valido)
{
_repositorio.Salvar(pagamento); // Efeito colateral escondido atrás de um nome de "validação"
_servicoEmail.Enviar(pagamento.Cliente, "Pagamento processado");
}
return valido;
}
// Chamada — parece inofensiva, mas dispara persistência e envio de e-mail
if (ValidarEProcessarPagamento(pagamento))
{
// ...
}
Quem escreve if (ValidarEProcessarPagamento(pagamento)) num teste, ou numa tentativa de debug, provavelmente só quer checar se o pagamento é válido — e sem querer dispara persistência no banco e envio de e-mail de verdade. Isso é o análogo direto do "Power Peg" da Knight Capital: uma chamada que parece uma coisa (checagem) mas na verdade também é outra (ativação de um fluxo real), e a discrepância só aparece quando já é tarde.
Correção:
// Query — só responde, sem efeito colateral
public bool ValidarPagamento(Pagamento pagamento)
{
return pagamento.Valor > 0 && pagamento.CartaoValido;
}
// Comando — só age, não retorna valor de decisão
public void ProcessarPagamento(Pagamento pagamento)
{
_repositorio.Salvar(pagamento);
_servicoEmail.Enviar(pagamento.Cliente, "Pagamento processado");
}
// Chamada — a intenção de cada linha é legível sem precisar abrir a implementação
if (ValidarPagamento(pagamento))
{
ProcessarPagamento(pagamento);
}
Segundo exemplo, um padrão bem comum em código com estado mutável em memória — método que "verifica e incrementa" ao mesmo tempo:
// Viola CQS — parece uma consulta ("ainda tem estoque?") mas decrementa o estoque
public bool TemEstoqueDisponivel(int produtoId, int quantidade)
{
var produto = _estoque.Buscar(produtoId);
if (produto.Quantidade >= quantidade)
{
produto.Quantidade -= quantidade; // Mutação escondida dentro de uma pergunta
return true;
}
return false;
}
// Separado — a pergunta não muta nada; a ordem é explícita e separada
public bool TemEstoqueDisponivel(int produtoId, int quantidade)
{
return _estoque.Buscar(produtoId).Quantidade >= quantidade;
}
public void ReservarEstoque(int produtoId, int quantidade)
{
var produto = _estoque.Buscar(produtoId);
produto.Quantidade -= quantidade;
}
Note que essa separação também resolve, de quebra, um problema de race condition: se dois lugares diferentes chamam TemEstoqueDisponivel na versão original (achando que é só uma checagem), ambos podem decrementar o estoque sem saber que o outro também fez isso — o mesmo tipo de condição de corrida sobre estado compartilhado que o Therac-25 exibia, só que em domínio de negócio ao invés de hardware médico.
Regra prática: antes de escrever um método, pergunte "esse método responde uma pergunta ou executa uma ordem?" Se a resposta for "as duas coisas", é sinal de que ele precisa ser dividido em dois. O teste rápido: consegue chamar o método várias vezes seguidas sem mudar o resultado do programa (fora o retorno em si)? Se sim, é query. Se não, é comando — e não deveria fingir ser outra coisa através de um nome que sugere só leitura
Top comments (0)