SOLID
SOLID é um acrônimo para cinco princípios de boas práticas de desenvolvimento de software orientado a objetos, reunidos por Robert Cecil Martin, o Uncle Bob. Esses princípios apontam o norte para um código confiável, sustentável, fácil de manter e de estender.
Neste artigo, você vai ver cada princípio com um exemplo em C#, o problema que ele resolve e como corrigir o código. Além disso, vamos usar um mesmo cenário, o cálculo de imposto de renda, para mostrar como os princípios se complementam.
O que é SOLID
Cada letra do acrônimo representa um princípio de design. Juntas, elas formam a palavra em inglês que significa "sólido".
- S: Single Responsibility Principle, ou princípio da responsabilidade única
- O: Open/Closed Principle, ou princípio do aberto/fechado
- L: Liskov Substitution Principle, ou princípio da substituição de Liskov
- I: Interface Segregation Principle, ou princípio da segregação de interfaces
- D: Dependency Inversion Principle, ou princípio da inversão de dependência
De onde vieram os princípios SOLID
Robert Cecil Martin apresentou esse conjunto de princípios em seu artigo "Design Principles and Design Patterns", publicado em 2000. Contudo, o acrônimo SOLID só surgiu por volta de 2004, sugerido por Michael Feathers.
Alguns princípios são ainda mais antigos. Por exemplo, a ideia de substituição tem como precursora a cientista da computação Barbara Liskov, que dá nome à letra L.
Por que SOLID ainda importa
A documentação de arquitetura da Microsoft descreve o SOLID como um conjunto de técnicas críticas para aplicações modernas. Segundo o guia, classes que seguem esses princípios tendem a ficar pequenas, bem fatoradas e fáceis de testar.
Na prática, o SOLID trata do design técnico das camadas da aplicação e do desacoplamento entre elas. Ou seja, ele não depende do domínio do negócio, e sim de como você organiza o código.
S: princípio da responsabilidade única
O primeiro princípio do SOLID é o da responsabilidade única. Ele diz que um módulo deve ter apenas uma razão para ser alterado.
O problema de uma classe com dois motivos para mudar
Como exemplo, vamos criar uma classe para gerar o relatório do nosso imposto de renda:
public abstract class IncomeTaxReportGenerator
{
public abstract decimal CalculateTax();
public abstract string BuildReport();
}
Essa classe contém dois métodos. Um deles concentra a lógica do cálculo do imposto, enquanto o outro concentra a lógica de criação do relatório.
Nesse caso, existem dois motivos para alterar essa classe:
- Ajustar a maneira de calcular o imposto.
- Ajustar o template do relatório.
Isso quer dizer que a classe tem mais de uma responsabilidade. Portanto, uma mudança no layout do relatório pode quebrar, sem querer, o cálculo do imposto.
Separando responsabilidades segundo o SOLID
Podemos encapsular cada um desses métodos em classes distintas. Dessa maneira, cada classe tem um único motivo para mudar, e o princípio deixa de ser violado.
public class IncomeTaxCalculator
{
public decimal CalculateTax()
{
// implementar a lógica de cálculo
return 0m;
}
}
public class IncomeTaxReportBuilder
{
public string BuildReport(decimal taxAmount)
{
// implementar a lógica de montagem do relatório
return $"Tax amount: {taxAmount}";
}
}
Com essa alteração, a classe IncomeTaxReportGenerator passa a ter apenas um método, responsável por controlar o fluxo que gera o relatório.
public class IncomeTaxReportGenerator
{
public string Generate()
{
var calculator = new IncomeTaxCalculator();
var reportBuilder = new IncomeTaxReportBuilder();
var taxAmount = calculator.CalculateTax();
return reportBuilder.BuildReport(taxAmount);
}
}
Agora, essa classe só muda se surgir um novo passo na geração do relatório. Assim, ela tem um motivo único de mudança.
Um sinal prático de violação aparece na injeção de dependência. A documentação do .NET alerta que uma classe com muitas dependências no construtor provavelmente tem responsabilidades demais.
O: princípio do aberto/fechado
O segundo princípio do SOLID é o do aberto/fechado. Ele diz que um módulo deve estar aberto para extensão e fechado para modificação.
Quando uma nova regra exige mexer no código antigo
Seguindo nosso exemplo, imagine que surgiu a necessidade de calcular o mesmo imposto para dois países diferentes. Para isso, precisaríamos alterar o método de cálculo da classe IncomeTaxCalculator para que ele tenha essa inteligência.
Repare que, para adicionar uma funcionalidade nova, precisamos alterar uma funcionalidade existente. Portanto, a classe faz o oposto do princípio: está fechada para extensão e aberta para modificação.
Com o tempo, esse método vira uma sequência de if ou switch, um para cada país. Além disso, cada novo país coloca em risco os cálculos já testados e homologados.
Aplicando o Strategy para respeitar o SOLID
Existem muitas maneiras de alinhar o código a esse princípio. Neste caso específico, podemos usar um design pattern chamado Strategy.
Para implementar o Strategy, vamos começar criando um enum para representar os países e uma interface que servirá de contrato entre as diferentes calculadoras.
public enum ECountry
{
Brazil = 1,
Uruguay = 2
}
public interface IIncomeTaxCalculatorStrategy
{
bool AppliesTo(ECountry country);
double Calculate(int income);
}
O método Calculate contém a lógica de cálculo do imposto para aquele país. Já o método AppliesTo informa se aquela estratégia se aplica ao país em questão.
Agora, vamos criar duas classes que implementam a interface: BrazilianIncomeTaxCalculatorStrategy e UruguayanIncomeTaxCalculatorStrategy.
public class BrazilianIncomeTaxCalculatorStrategy : IIncomeTaxCalculatorStrategy
{
public bool AppliesTo(ECountry country)
=> country is ECountry.Brazil;
public double Calculate(int income)
{
return income * 0.25;
}
}
public class UruguayanIncomeTaxCalculatorStrategy : IIncomeTaxCalculatorStrategy
{
public bool AppliesTo(ECountry country)
=> country is ECountry.Uruguay;
public double Calculate(int income)
{
return income * 0.05;
}
}
O próximo passo é alterar a classe IncomeTaxCalculator, que passa a receber as estratégias no construtor. Além disso, o método Calculate recebe o país e devolve o valor calculado pela estratégia correspondente.
public class IncomeTaxCalculator
{
private readonly IEnumerable<IIncomeTaxCalculatorStrategy> _strategies;
public IncomeTaxCalculator(IEnumerable<IIncomeTaxCalculatorStrategy> strategies)
{
_strategies = strategies;
}
public double Calculate(ECountry country, int income)
{
// Escolhe a estratégia que atende ao país informado
var strategy = _strategies.First(x => x.AppliesTo(country));
return strategy.Calculate(income);
}
}
Com essa abordagem, um novo país exige apenas uma nova estratégia adicionada à lista. Por outro lado, a lógica já implementada e homologada fica intocada.
Dessa forma, a classe fica aberta a novas funcionalidades e fechada para modificações. O guia de arquitetura da Microsoft resume bem essa vantagem: adicionar classes novas é sempre mais seguro do que alterar classes existentes, porque nenhum código ainda depende delas.
Assim, o risco de regressão fica concentrado no código novo. Se quiser conhecer outros padrões como o Strategy, leia sobre a história dos design patterns.
L: princípio da substituição de Liskov
O terceiro princípio do SOLID é o da substituição, que leva o nome de Barbara Liskov. Ela apresentou a ideia em 1987, na palestra "Data abstraction and hierarchy", e a formalizou em 1994 com Jeannette Wing, no artigo "A behavioral notion of subtyping".
Em termos simples, o princípio diz que uma classe filha deve ser capaz de substituir a classe pai sem gerar comportamentos inesperados.
Uma classe base com comportamento esperado
Seguindo o exemplo, imagine que precisamos de uma implementação padrão para o método Calculate das estratégias. Para isso, criamos uma classe base que apenas realiza o cálculo e retorna o valor, sem validar o parâmetro recebido.
Toda classe derivada dessa base deve seguir o mesmo comportamento: receber o parâmetro, calcular e retornar o valor. Esse é o contrato da classe base, e quem a estende precisa garanti-lo.
public abstract class BaseIncomeTaxCalculatorStrategy : IIncomeTaxCalculatorStrategy
{
public abstract bool AppliesTo(ECountry country);
public virtual double Calculate(int income)
{
return income * 0.15;
}
}
Como agora temos uma classe base, as estratégias passam a estendê-la em vez de implementar a interface diretamente.
public class BrazilianIncomeTaxCalculatorStrategy : BaseIncomeTaxCalculatorStrategy
{
public override bool AppliesTo(ECountry country)
=> country is ECountry.Brazil;
public override double Calculate(int income)
{
return income * 0.25;
}
}
public class UruguayanIncomeTaxCalculatorStrategy : BaseIncomeTaxCalculatorStrategy
{
public override bool AppliesTo(ECountry country)
=> country is ECountry.Uruguay;
public override double Calculate(int income)
{
return income * 0.05;
}
}
A estratégia que quebra a substituição
Agora, vamos acrescentar Argentina ao enum ECountry e criar uma nova estratégia. Ela valida se o parâmetro income é menor que zero e, nesse caso, lança uma exceção.
public class ArgentinianIncomeTaxCalculatorStrategy : BaseIncomeTaxCalculatorStrategy
{
public override bool AppliesTo(ECountry country)
=> country is ECountry.Argentina;
public override double Calculate(int income)
{
// Nova pré-condição que a classe base não impõe
if (income < 0)
throw new ArgumentOutOfRangeException(nameof(income), "Income should not be negative.");
return income * 0.25;
}
}
Além disso, vamos criar um método na classe IncomeTaxCalculator que calcula o imposto para todas as estratégias. Repare que a classe recebe uma lista de BaseIncomeTaxCalculatorStrategy, então espera encontrar o comportamento da classe base.
public class IncomeTaxCalculator
{
private readonly IEnumerable<BaseIncomeTaxCalculatorStrategy> _strategies;
public IncomeTaxCalculator(IEnumerable<BaseIncomeTaxCalculatorStrategy> strategies)
{
_strategies = strategies;
}
public double Calculate(ECountry country, int income)
{
var strategy = _strategies.First(x => x.AppliesTo(country));
return strategy.Calculate(income);
}
public IEnumerable<double> CalculateForEveryCountry(int income)
{
foreach (var strategy in _strategies)
yield return strategy.Calculate(income);
}
}
Enquanto percorre as estratégias, o método CalculateForEveryCountry encontra uma classe que não respeita o princípio. Afinal, a estratégia argentina lança uma exceção quando o valor recebido é negativo.
Como esse comportamento não existe na classe base, ninguém trata a exceção, e o programa quebra. Em outras palavras, a classe filha não conseguiu substituir a classe pai com segurança.
Pré-condições e pós-condições no SOLID
Uma forma prática de checar a substituição de Liskov é olhar para as pré-condições e pós-condições de cada método. A pré-condição é o que o método exige para funcionar, enquanto a pós-condição é o que ele garante ao terminar.
A regra é direta: a classe derivada não pode fortalecer as pré-condições nem enfraquecer as pós-condições. Essas regras são as mesmas que Bertrand Meyer descreveu em 1988, no livro "Object-Oriented Software Construction".
No nosso exemplo, a estratégia argentina fortaleceu a pré-condição, porque passou a exigir um income não negativo. Por isso, quem usava a classe base sem essa exigência foi surpreendido pela exceção.
Como respeitar a substituição de Liskov
Quando uma classe estende outra, precisamos garantir que a classe derivada possa ocupar o lugar da classe herdada. Por isso, não devemos impor novas condições às entradas sem garantir que isso não quebre o programa.
Tanto o resultado dos métodos quanto o comportamento devem permanecer consistentes. Nesse caso, uma saída seria mover a validação para a classe base, tornando a regra parte do contrato de todas as estratégias.
Vale notar que o C# é uma linguagem fortemente tipada, e o compilador já garante a consistência dos tipos. Por outro lado, linguagens sem esse suporte em tempo de compilação ficam ainda mais expostas a esse tipo de problema.
I: princípio da segregação de interfaces
O quarto princípio do SOLID é o da segregação de interfaces. Ele diz que uma classe não deve ser forçada a implementar métodos que não vai usar.
O pinguim que precisa voar
Para ilustrar, vamos criar uma interface para representar aves:
public interface IBird
{
void Peck();
void Fly();
}
Em seguida, criamos uma classe para representar pinguins, que também são aves:
public class Penguin : IBird
{
public void Peck()
{
// implementar a lógica de bicar
}
public void Fly()
{
// pinguins não voam, então o que colocar aqui?
throw new NotSupportedException();
}
}
Aqui temos um problema. Para cumprir o contrato, a classe é obrigada a implementar um método que não faz sentido para ela, afinal, pinguins não voam.
Segregando a interface com SOLID
A solução é segregar a interface para não impor implementações desnecessárias.
public interface IBird
{
void Peck();
}
public interface IFlyable
{
void Fly();
}
Agora, o pinguim precisa implementar apenas o método que lhe diz respeito.
public class Penguin : IBird
{
public void Peck()
{
// implementar a lógica de bicar
}
}
Além disso, continuamos livres para criar uma ave que bica e voa, combinando as duas interfaces.
public interface IFlyableBird : IFlyable, IBird
{
}
public class Raven : IFlyableBird
{
public void Peck()
{
// implementar a lógica de bicar
}
public void Fly()
{
// implementar a lógica de voar
}
}
Consequências de ignorar esse princípio SOLID
Não seguir esse princípio aumenta a complexidade e, com isso, dificulta a manutenção do código. Além disso, abre a porta para comportamentos estranhos no programa, como um pinguim que voa.
Repare também na relação com Liskov. O Fly que lança NotSupportedException é exatamente o tipo de surpresa que a substituição tenta evitar.
D: princípio da inversão de dependência
O quinto princípio do SOLID é o da inversão de dependência. Ele diz que uma classe de alto nível não deve depender de uma classe de baixo nível, e que ambas devem depender de abstrações.
A analogia do cabo USB
Para ilustrar, pense em como funcionam os cabos USB, mais especificamente o conector do cabo. Existe uma relação entre conector e entrada, que a imagem abaixo representa.
Aqui há uma dependência direta entre o conector e a entrada. Porém, existem vários cabos e várias entradas USB diferentes.
Então, como garantir que esse conector funcione em todas as entradas? Na verdade, existe uma parte intermediária, um contrato, uma interface, que define o formato da entrada.
Portanto, a única coisa que o conector e a entrada precisam fazer para funcionar plenamente é seguir essa abstração.
Dependendo de abstrações no código
A melhor maneira de aderir a esse princípio é depender de abstrações em vez de implementações. Assim, podemos usar outras técnicas, como a injeção de dependência, para deixar o código resiliente e modular.
Voltando ao exemplo do relatório, a classe IncomeTaxReportGenerator criava suas dependências com new. Em vez disso, ela pode receber abstrações pelo construtor.
public interface IIncomeTaxCalculator
{
decimal CalculateTax();
}
public interface IIncomeTaxReportBuilder
{
string BuildReport(decimal taxAmount);
}
public class IncomeTaxReportGenerator
{
private readonly IIncomeTaxCalculator _calculator;
private readonly IIncomeTaxReportBuilder _reportBuilder;
// As dependências chegam prontas, sem new dentro da classe
public IncomeTaxReportGenerator(
IIncomeTaxCalculator calculator,
IIncomeTaxReportBuilder reportBuilder)
{
_calculator = calculator;
_reportBuilder = reportBuilder;
}
public string Generate()
{
var taxAmount = _calculator.CalculateTax();
return _reportBuilder.BuildReport(taxAmount);
}
}
Repare que o construtor agora declara tudo de que a classe precisa. A Microsoft chama isso de princípio das dependências explícitas, e ele deixa o código mais honesto com quem o consome.
Com isso, trocar a forma de cálculo ou o formato do relatório não exige mudar o gerador. Além disso, os testes de unidade podem usar implementações falsas das interfaces.
A documentação do .NET reforça esse ponto: a injeção de dependência é uma forma de implementar a inversão de dependência. Para se aprofundar, leia o artigo sobre Dependency Inversion Principle e o guia sobre AddTransient, AddScoped e AddSingleton.
Como aplicar SOLID no dia a dia
Os princípios SOLID não são regras para aplicar de uma vez em todo o projeto. Na prática, eles funcionam melhor como perguntas durante a revisão do código.
Por exemplo, quantos motivos essa classe tem para mudar? Adicionar uma regra nova exige editar código já testado?
Da mesma forma, vale perguntar se a classe filha mantém o contrato da classe pai.
Também vale lembrar que exagerar na abstração gera outro tipo de complexidade. Por isso, aplique os princípios quando o problema aparecer, e não para prever cenários que talvez nunca existam.
Se você está começando, os fundamentos de orientação a objetos ajudam muito a entender o SOLID. Em seguida, o artigo sobre Clean Code complementa bem o tema.
Conclusão
Durante o desenvolvimento, seguir os princípios SOLID gera um código mais coerente, com menos acoplamento e mais fácil de manter e reutilizar. Com isso, a solução cresce de forma consistente e contínua.
Além disso, o SOLID evita retrabalho e refatorações desnecessárias. No fim, cada letra do acrônimo ataca uma causa diferente de código frágil, e juntas elas formam uma base sólida para qualquer aplicação.
FAQ
O que significa SOLID?
É um acrônimo para cinco princípios de design orientado a objetos: responsabilidade única, aberto/fechado, substituição de Liskov, segregação de interfaces e inversão de dependência.
Quem criou os princípios SOLID?
Robert C. Martin reuniu os princípios, e Michael Feathers sugeriu o acrônimo por volta de 2004.
SOLID só vale para C#?
Não. Os princípios valem para qualquer linguagem orientada a objetos, como Java, TypeScript ou Python.
Qual a diferença entre inversão e injeção de dependência?
A inversão é o princípio de depender de abstrações. A injeção é uma técnica para entregar essas dependências a uma classe, normalmente pelo construtor.
Preciso aplicar todos os princípios em todo o código?
Não. Use os princípios como guia quando o código começar a ficar difícil de mudar, sem criar abstrações sem necessidade.