Tiago Tartari

Conteúdo

Exceptions para validar domínio de um software, certo ou errado?

Desenvolver uma aplicação em C# frequentemente envolve lidar com exceptions, que sinalizam erros ou anormalidades durante a execução de um programa, indicando que algo deu errado e que o fluxo normal não pode prosseguir.

Ao desenvolver aplicações line of business, o domínio (domain) representa o negócio ou problema que o sistema tenta resolver, e ele possui suas próprias regras e restrições que precisam ser respeitadas. Considerando que trabalhar com domínio envolve lidar com essas regras e restrições, surge um questionamento: como garantir que sejam cumpridas?

Podemos considerar duas formas para a validação do estado de um domínio. Uma é usar exceptions no construtor das classes que representam o domínio. Outra é utilizar uma propriedade de validação, como IsValid(), para identificar os objetos válidos ou inválidos no domínio.

Diante dessas duas formas, surgem perguntas importantes a serem respondidas:

  • Qual dessas abordagens é mais eficaz?
  • Qual impacta mais na performance do sistema?
  • Qual delas respeita as boas práticas ao desenvolver software line of business?

Estas são algumas das questões que abordo neste artigo, comparando as duas abordagens de validação de domínio através de exemplos práticos e testes de performance. Analisaremos os prós e contras de usar exceções ou a propriedade IsValid() para validar o domínio em C#.

Entretanto, para prosseguir, precisamos definir e explicar conceitos importantes sobre exceções (exception) e domínios (domain).

O que são exceptions em C#?

Uma exception é um objeto que representa um erro ou condição anormal ocorrendo durante a execução de um programa. As exceptions são usadas para sinalizar que algo deu errado e que o fluxo normal do programa não pode prosseguir. Elas são derivadas da classe Exception, que contém propriedades como Message, StackTrace e InnerException, fornecendo detalhes sobre o erro. Existem vários tipos de exceptions predefinidas no .NET, como ArgumentException, FileNotFoundException, NullReferenceException, InvalidOperationException entre outras. Você também pode criar suas próprias classes de exception personalizadas, herdando de Exception ou de alguma classe derivada.

Como lançar exceptions?

Para lançar uma exceção, você usa a palavra-chave throw, seguida de uma instância de uma classe de exceção. Por exemplo:

Quando você lança uma exceção, o tempo de execução procura um bloco catch que possa manipular a exceção. Um bloco catch é uma parte de uma instrução try...catch...finally que especifica o tipo de exceção que ele pode tratar e um bloco de código que será executado se a exceção ocorrer. Por exemplo:

Você pode ter vários blocos catch para tratar diferentes tipos de exceções. O primeiro bloco catch que for compatível com o tipo da exceção lançada será executado. Os blocos devem ser ordenados do mais específico para o mais genérico, para evitar que uma exceção mais derivada seja capturada por um bloco mais amplo.

Como capturar exceptions?

Para capturar uma exceção, você usa a palavra-chave catch, seguida de um parâmetro que representa a exceção que você quer tratar. O parâmetro deve ser do tipo Exception ou de algum tipo derivado dele. Por exemplo:

Dentro do bloco catch, você pode acessar as propriedades da exceção, como Message, StackTrace e InnerException, para obter mais informações sobre o erro e tomar as ações adequadas. Por exemplo, você pode mostrar uma mensagem de erro para o usuário, registrar o erro em um log, tentar corrigir o erro ou relançar a exceção para um nível superior. Por exemplo:

Você pode ter vários blocos catch para tratar diferentes tipos de exceções. O primeiro bloco catch que for compatível com o tipo da exceção lançada será executado. Os blocos catch devem ser ordenados do mais específico para o mais genérico, para evitar que uma exceção mais derivada seja capturada por um bloco catch mais baseado. Por exemplo:

Você também pode usar a palavra-chave when para filtrar as exceções com base em uma condição. Isso permite que você trate somente as exceções que atendem a um critério específico, e deixe as outras exceções passarem para o próximo bloco catch ou para o método de chamada. Por exemplo:

Qual é o custo computacional das exceptions?

O uso de exceções tem um impacto na performance do programa, pois envolve um custo computacional relacionado ao rastreamento da pilha, à execução dos blocos finally e à compatibilidade entre os mecanismos de exceção. Portanto, é recomendável usar exceções somente quando necessário e evitar lançar exceções genéricas, como Exception.

O custo computacional de uma exceção depende de vários fatores, como o tamanho da pilha de chamadas, o número de blocos finally, o tipo de exceção, o nível de otimização do código, etc. Não há uma fórmula simples para calcular o custo de uma exceção, mas alguns testes de desempenho mostram que o custo de uma exceção pode variar de algumas dezenas a algumas centenas de ciclos de CPU.

Para medir o custo de uma exceção, você pode usar ferramentas de análise de performance, como o, Pyroscope, Visual Studio Profiler, o BenchmarkDotNet ou o PerfView. Essas ferramentas permitem que você execute o seu código com diferentes cenários de exceções e compare os resultados em termos de tempo de execução, uso de memória, número de exceções geradas, etc. Você pode usar essas ferramentas para identificar e otimizar os pontos críticos do seu código que geram muitas exceções ou que têm um alto custo de exceção.

Em quais cenários as exceptions DEVEM ser usadas?

Exceptions devem ser usadas para proteger contra condições excepcionais do programa, e não para verificações rotineiras ou controle de fluxo. São apropriadas quando:

  • O método não pode completar sua funcionalidade definida.
  • Há uma chamada inadequada a um objeto, considerando seu estado atual.
  • Um argumento de método provoca uma exceção.

Exemplos de cenários adequados:

  • Falta de um arquivo esperado.
  • Interrupção inesperada de uma conexão de rede.
  • Passagem de um valor nulo para um método que não aceita nulo.
  • Formato de dados inválido ou incompatível.

Boas práticas:

  • Preferir tipos de exception específicos e informativos.
  • Incluir mensagens de erro claras e úteis.
  • Evitar exceptions genéricas, como Exception, SystemException ou ApplicationException.
  • Evitar capturar exceptions genéricas que possam ocultar erros críticos.
  • Não “engolir” exceptions sem tratá-las.
  • Não lançar exceptions em blocos finally ou destrutores.

Em quais cenários as exceptions NÃO DEVEM ser usadas?

Alguns exemplos de como não usar exceções em C# são:

  • Usar exceções para fins verificação de argumentos ou controle de fluxo é inadequado. Exceções são destinadas a lidar com situações imprevistas ou erros, e não para controle de fluxo normal. Em vez disso, como sugerido, estruturas condicionais devem ser utilizadas para verificar argumentos e controlar o fluxo. Isso está em consonância com as práticas recomendadas que enfatizam o uso de exceções apenas para circunstâncias verdadeiramente excepcionais​​​​​​.
  • Exceções não devem ser usadas para retornar valores ou transmitir informações que não sejam erros. Isso pode levar à confusão e a um código menos claro e mais difícil de manter. O uso de tipos de retorno ou parâmetros out é a abordagem recomendada para tais finalidades​​​​​​.
  • Lançar exceções intencionalmente como parte dos testes de código é uma prática inadequada. Isso pode mascarar problemas reais e não representa um uso efetivo das exceções. Em vez disso, ferramentas de teste unitário são muito mais apropriadas para testar o comportamento do código em diferentes cenários​​​​​​.

Validação do domínio usando exceptions no construtor

Usar exceções no construtor para validar o domínio ajuda a manter os objetos sempre corretos e de acordo com as regras do negócio. Isso impede que dados errados entrem no sistema e causem problemas. Além disso, essa forma de fazer as coisas torna a lógica de negócios mais simples, pois não é preciso checar se os objetos estão corretos toda vez que são usados. Isso deixa o código mais fácil de ler e cuidar.

Para validar usando exceções no construtor, é necessário checar se os dados recebidos estão corretos. Se algum dado estiver errado, uma exceção do tipo ArgumentException é lançada, com uma mensagem explicando o problema. Se os dados estiverem todos corretos, eles são usados para configurar o objeto.

Por exemplo, na classe CustomerWithExcept, que representa um cliente de uma loja online, temos regras como:

  • O id precisa ser um número inteiro maior que zero.
  • O name não pode ser vazio ou nulo.
  • A age deve ser um número entre 18 e 65.
  • O email não pode ser nulo e deve seguir o formato correto de e-mail.

Um construtor é usado para receber esses dados e verificar se estão corretos. Se houver algum erro, uma exceção é lançada. Usamos também um tipo Email para cuidar da parte de verificar o formato do e-mail.

Com esse código, podemos garantir que os dados dos clientes são válidos no momento da criação dos objetos CustomerWithExcept e Email. Se algum dado for inválido, uma exceção será lançada e o objeto não será criado. Isso evita que dados inválidos entrem no domínio e causem inconsistências ou erros.

Como medir o impacto das exceptions na performance do sistema?

Para medir o impacto das exceções na performance do sistema, podemos usar a ferramenta BenchmarkDotnet. Ela permite que você execute o seu código com diferentes cenários de exceções e compare os resultados em termos de tempo de execução, uso de memória, número de exceções geradas, etc. Você pode usar essas ferramentas para identificar e otimizar os pontos críticos do seu código que geram muitas exceções ou que têm um alto custo de exceção.

Definindo o cenário de teste

Imaginemos um sistema de e-commerce que processa dados de clientes CustomerModel. Os dados podem ser válidos ou inválidos segundo as regras do domínio. O sistema lida com um volume significativo desses dados.

Comparando as duas abordagens de validação de domínio

Para comparar as abordagens, desenvolvemos dois métodos: um que cria objetos CustomerWithIsValid e outro CustomerWithExcept, ambos a partir de CustomerModel. Um método valida o domínio através de exceções no construtor, e o outro usa uma propriedade IsValid() para marcar objetos válidos ou inválidos.

CustomerWithExcept

Esta classe valida os dados no construtor, lançando ArgumentException para dados inválidos, garantindo que apenas objetos válidos sejam criados.

CustomerWithIsValid

Diferentemente, esta classe acumula erros em uma lista, permitindo a criação do objeto mesmo com dados inválidos. A validade é verificada posteriormente pelo método IsValid().

Para usar a ferramenta BenchmarkDotNet, precisamos instalar o pacote NuGet BenchmarkDotNet no nosso projeto, e adicionar os atributos [Benchmark] e [MemoryDiagnoser] nos nossos métodos de teste. O atributo [Benchmark] indica que o método é um benchmark, e o atributo [MemoryDiagnoser] indica que queremos coletar informações sobre o uso de memória. O código dos nossos métodos de teste fica assim:

Com esse código, estamos prontos para executar o teste de performance e comparar as duas abordagens de validação de domínio. Para executar o teste, basta rodar o projeto e esperar os resultados. O relatório do teste será mostrado no console. O relatório vai conter informações como o tempo de execução médio, o desvio padrão, o erro, o uso de memória, o número de exceções geradas, etc. Você pode usar essas informações para analisar e comparar as duas abordagens de validação de domínio.

Resultados do teste de performance

Ao executar o teste, observamos e comparamos várias métricas. Esses resultados nos ajudam a entender o comportamento de cada abordagem em termos de performance e uso de memória.

A tabela a seguir mostra os resultado do teste de performance para as duas abordagens de validação de domínio. Os resultados são apresentados em nanosegundos (ns) para o tempo de execução e em bytes (B) para o uso de memória.

MétodoMédia (ns)Erro (ns)Desvio Padrão (ns)Mediana (ns)Geração 0Alocado (Bytes)
BenchmarkCreateCustomerWithIsValid689.613.7726.20681.70.0849358
BenchmarkCreateCustomerWithException5,820.4218.98624.765,502.90.1221511

Análise dos resultados

  1. Tempo Médio de Execução (Média): O método BenchmarkCreateCustomerWithIsValid tem um tempo médio de execução significativamente menor (689.6 ns) em comparação com o BenchmarkCreateCustomerWithException (5,820.4 ns). Isso indica que a abordagem de validação sem exceções IsValid() é mais rápida.
  2. Erro e Desvio Padrão: O erro e o desvio padrão para o método BenchmarkCreateCustomerWithException são mais altos, o que sugere maior variabilidade nos resultados desse método. Isso pode ser devido ao custo adicional de manipulação de exceções.
  3. Mediana: A mediana para o método BenchmarkCreateCustomerWithIsValid é próxima à média, indicando consistência nos resultados. Para o método BenchmarkCreateCustomerWithException, a mediana é um pouco menor que a média, mas ainda assim muito mais alta que a do primeiro método.
  4. Geração 0 (Gen0): A alocação de memória na geração 0 é ligeiramente maior para o método com exceções. Embora a diferença não seja grande, ela reflete o custo adicional de gerar e manipular exceções.
  5. Memória Alocada: Há mais memória sendo alocada pelo método BenchmarkCreateCustomerWithException, o que é consistente com o custo adicional de gerar exceções.
  6. Outliers: Foram removidos outliers nos dois métodos, indicando variações nos tempos de execução. Os outliers removidos no método BenchmarkCreateCustomerWithException são especialmente altos, sugerindo casos em que as exceções impactam significativamente o desempenho.

Os testes de performance revelam uma diferença significativa entre duas abordagens de validação de domínio em C#. A primeira, usando CustomerWithExcept, implementa a validação por meio de exceções. A segunda, CustomerWithIsValid, utiliza uma propriedade de validação. Os resultados mostram que a abordagem CustomerWithIsValid é mais eficiente, tanto em termos de tempo de execução quanto no uso de memória.

Especificamente, CustomerWithIsValid registrou um tempo médio de execução e uso de memória menores em comparação com CustomerWithExcept. Esta diferença é justificável, já que os intervalos de confiança das medições não se sobrepõem, reforçando a confiabilidade dos resultados.

Essa eficiência pode ser atribuída ao fato de que CustomerWithIsValid evita os custos computacionais associados ao lançamento e captura de exceções. Em situações onde há muitos dados inválidos, essa economia de recursos se torna ainda mais perceptível. Portanto, em cenários onde a performance é um atributo de qualidade indispensável e exceções podem ser frequentes, optar por uma abordagem de validação que não dependa de exceções pode ser a escolha mais acertada.

Trade-off entre as abordagens de validação de domínio

Como vimos nos tópicos anteriores, as duas abordagens de validação de domínio que comparamos têm suas vantagens e desvantagens, que devem ser consideradas na hora de escolher a melhor abordagem para cada cenário.

Performance vs. Garantia de Consistência

A abordagem com exceções garante a validade e consistência dos objetos do domínio, mas a um custo computacional mais alto, afetando a performance. Por outro lado, a abordagem IsValid tem melhor performance, mas não garante automaticamente que todos os objetos sejam válidos e consistentes.

Complexidade do Código vs. Robustez

Enquanto exceções simplificam a lógica de negócios ao centralizar a validação no construtor, a abordagem IsValid() pode tornar o código mais complexo, pois exige verificações adicionais de validade ao longo do programa.

Prós e contras de cada abordagem

Abordagem com exceções. A principal vantagem dessa abordagem é a garantia de que todos os objetos criados estão em conformidade com as regras do domínio desde o início. Isso se deve ao fato de que qualquer tentativa de criar um objeto com dados inválidos resultará imediatamente em uma exceção, evitando assim a propagação de dados inconsistentes pelo sistema. Além disso, esta abordagem simplifica o fluxo de controle do programa, já que os erros são tratados logo no ponto de entrada, tornando o código mais limpo e direto no que se refere à gestão de erros. O lado negativo dessa estratégia está no seu impacto sobre a performance, especialmente em cenários com uma alta taxa de dados inválidos. O processo de lançar e capturar exceções implica um custo computacional significativo, o que pode reduzir a eficiência geral do sistema. Além disso, se as exceções não forem gerenciadas de maneira adequada, podem levar a interrupções indesejadas no fluxo normal do programa, afetando a confiabilidade e a disponibilidade do sistema.

Abordagem com propriedade IsValid(). A grande força dessa abordagem é a sua performance superior, decorrente da ausência do custo associado ao lançamento e tratamento de exceções. Esta estratégia também contribui para a estabilidade do programa, pois não provoca interrupções súbitas no fluxo de execução, o que é particularmente benéfico em sistemas onde a continuidade é crucial. Além disso, a abordagem com IsValid() evita os efeitos colaterais que podem surgir com o uso excessivo de exceções. Por outro lado, esta técnica não garante automaticamente a validade e a consistência dos objetos. Isso pode levar a inconsistências nos dados, caso as verificações de validade não sejam realizadas de forma rigorosa em todo o código. Além disso, a necessidade de verificar constantemente a propriedade IsValid() pode complicar a lógica de negócios, tornando o código mais complexo e potencialmente mais difícil de manter.

Recomendações para escolher a melhor abordagem para cada cenário

Na hora de decidir qual abordagem adotar, é essencial considerar as características específicas do sistema e as necessidades do projeto. Para sistemas onde a performance é um fator crítico e os dados inválidos são comuns, a abordagem com IsValid() pode ser a mais adequada. Em contrapartida, em sistemas onde a validade e a consistência dos dados são primordiais, e a performance não é uma preocupação tão grande, a abordagem com exceções pode ser mais apropriada. É importante lembrar que não existe uma solução única que se aplique a todos os cenários, e a decisão deve ser baseada em uma análise cuidadosa das vantagens e desvantagens de cada método.

Exceptions causam impactos nas métricas e monitoramento

Além dos fatores já discutidos, é importante considerar o impacto das exceções nos processos e métricas de SRE. A validação de domínio usando exceções no construtor, apesar de suas vantagens em garantir a validade e consistência dos objetos, pode gerar desafios específicos para equipes de SRE. Uma alta frequência de exceções pode provocar alertas falsos nos painéis de monitoramento, criando um cenário de volatilidade nos indicadores de erros. Isso ocorre porque as exceções, mesmo quando tratadas adequadamente no código, ainda podem ser registradas como erros nos sistemas de monitoramento.

Essa volatilidade nas métricas pode levar a uma interpretação equivocada do estado de saúde do sistema, com taxas de erro que variam significativamente, dificultando a análise precisa da performance e confiabilidade do sistema. Remover essas exceções dos dashboards não é uma solução definitiva, pois elas podem continuar influenciando as métricas de forma indireta. Portanto, é crucial que equipes de SRE considerem o impacto potencial das exceções na integridade das métricas e nos processos de alerta.

Por outro lado, a abordagem IsValid() pode ser mais alinhada com as necessidades de SRE, pois minimiza a geração de alertas falsos ao evitar o uso de exceções. Esta abordagem contribui para uma visão mais estável e confiável do estado do sistema, facilitando a gestão e a análise dos indicadores de saúde e performance.

Conclusão

A validação de domínio usando exceções no construtor assegura que os objetos estejam sempre alinhados às regras do negócio, garantindo validade e consistência. Esta abordagem simplifica tanto a lógica de negócios quanto o controle de fluxo, facilitando a comunicação e o tratamento de erros. Contudo, ela implica um custo computacional mais elevado, podendo afetar a performance do sistema, especialmente com dados inválidos em grande quantidade. Além disso, há riscos para a confiabilidade e disponibilidade do sistema caso as exceções não sejam gerenciadas corretamente.

Por outro lado, a validação de domínio utilizando a propriedade IsValid() marca os objetos como válidos ou inválidos sem lançar exceções, o que beneficia a performance por evitar o custo associado ao tratamento de exceções. Esta abordagem também contribui para uma maior confiabilidade e disponibilidade, pois não interrompe o fluxo normal do programa e minimiza efeitos colaterais indesejados. No entanto, ela não garante automaticamente a validade e consistência dos objetos, o que pode resultar em inconsistências ou erros no sistema. Adicionalmente, pode tornar a lógica de negócios e o controle de fluxo mais complexos, dificultando a comunicação e o tratamento de erros.

Ao escolher entre as abordagens de validação de domínio em C#, os desenvolvedores e times de SRE devem ponderar os trade-offs entre performance, confiabilidade, consistência dos dados e integridade das métricas de monitoramento. A decisão ideal varia conforme as características do sistema e as prioridades da equipe, incluindo a facilidade de manutenção, a clareza na comunicação de erros, e a precisão nas métricas de SRE. Avaliar cuidadosamente esses aspectos ajudará a determinar a abordagem mais adequada para cada projeto, equilibrando eficiência técnica com a robustez operacional.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?