Princípios
SOLID : Da teoria à prática no mundo real
![]() |
Este artigo revisita os princípios SOLID apresentando os conceitos com exemplos práticos. |
O SOLID é um acrônimo para cinco princípios de design de código na programação orientada a objetos, criado para facilitar a manutenção, escalabilidade e compreensão de softwares.
Muitos desenvolvedores veem o SOLID como uma teoria chata para passar em entrevistas. No entanto, o SOLID só faz sentido quando você sente a dor de um código rígido. Vamos analisar como esses princípios transformam um sistema de pedidos.
O cenário inicial
Imagine um serviço de criação de
pedidos comum:
public class PedidoService
{
public void Criar(Pedido pedido)
{
// Validação, persistência, e-mail e log, tudo aqui.
if (pedido.Valor <= 0) throw new Exception("Inválido");
SalvarNoBanco(pedido);
EnviarEmailConfirmacao(pedido);
}
}
|
Por que isso é um erro grave?
Acoplamento Excessivo: Para testar a regra
de valor, você é obrigado a lidar com conexões de banco e SMTP.
Efeito
Colateral: Um desenvolvedor alterando a string de conexão do banco pode apagar
acidentalmente a linha que envia o e-mail.
Dificuldade de Reuso: Se você
precisar validar um pedido em outra parte do sistema, terá que instanciar toda a
infraestrutura de e-mail e banco.
S — Princípio da Responsabilidade Única (SRP)
Uma classe
deve ter apenas um motivo para mudar.
❌ O Problema: A
classe PedidoService muda se a regra de validação mudar, se o
banco de dados mudar ou se o provedor de e-mail for trocado.
✅ A
Solução: Movemos a lógica de negócio para onde ela pertence. A solução
elegante não é apenas criar "mais classes", mas colocar cada responsabilidade em
seu devido lugar. No .NET moderno e DDD, a Entidade de Domínio deve ser a
guardiã das suas próprias regras.
Nota de Arquitetura: Em vez de apenas criar serviços externos, podemos mover a validação para o Modelo de Domínio. A própria entidade Pedido deve garantir sua consistência básica.
// --- DOMÍNIO --- // A Entidade Pedido agora é responsável apenas por ser um "Pedido Válido". // Usamos Primary Constructors (C# 12) para um design limpo. public class Pedido(decimal valor) { public decimal Valor { get; private set; } = valor > 0 ? valor : throw new ArgumentException("O valor do pedido deve ser positivo."); } // --- SERVIÇOS DE ABSTRAÇÃO --- public interface IPedidoRepository { void Salvar(Pedido pedido); } public interface IEmailService { void EnviarConfirmacao(Pedido pedido); } // --- APLICAÇÃO --- // O PedidoService agora é um "Orquestrador". // Sua única responsabilidade é coordenar o fluxo de criação. public class PedidoService(IPedidoRepository repository, IEmailService emailService) { public void Criar(decimal valor) { var novoPedido = new Pedido(valor); // A validação acontece aqui! repository.Salvar(novoPedido); emailService.EnviarConfirmacao(novoPedido); } } |
O que mudou ?
Separação de Interesses (SoC): Agora, a lógica de "como salvar" está no
Repositório. A lógica de "como enviar" está no Serviço de E-mail. A lógica de "o
que é um pedido" está na Entidade.
Facilidade de Testes: Você pode criar
um Teste de Unidade para a classe Pedido sem precisar de mocks complexos, apenas
passando um valor negativo e esperando a exceção.
Manutenibilidade: Se
você trocar o banco SQL Server por MongoDB, você altera apenas a implementação
do IPedidoRepository. O PedidoService e a Entidade Pedido permanecem intocados e
seguros.
Regra de Ouro: Se, ao descrever o que uma classe faz, você usar
a palavra "E" (ex: "Ela valida o pedido E salva no banco"), você provavelmente
está violando o SRP.
Nota: Na ASP.NET Core, os Controllers frequentemente violam o SRP ao acumular múltiplas dependências e lógicas de orquestração. O uso da biblioteca MediatR resolve isso ao reduzir o Controller a uma única responsabilidade: receber a requisição e despachar um comando. Toda a lógica de negócio e infraestrutura é movida para Handlers isolados, garantindo um código altamente coeso, fácil de testar e independente de quem dispara a ação.
O — Princípio Aberto/Fechado (OCP)
Entidades de software
(classes, módulos, funções) devem estar abertas para extensão, mas fechadas para
modificação.
O OCP é, para muitos, o coração do SOLID. Se você mexe em código que já está
funcionando para adicionar uma funcionalidade, você está correndo o risco de
quebrar o que já está estável.
❌ O Problema (Código Rígido)
Imagine um sistema de processamento de pagamentos. No início, você
aceita apenas Cartão. Depois, precisa adicionar Boleto e Pix. Sem o
OCP, seu
código vira um emaranhado de condicionais.
public class ProcessadorPagamento { public void Processar(Pedido pedido, string tipoPagamento) { if (tipoPagamento == "Cartao") { // Lógica complexa de cartão } else if (tipoPagamento == "Boleto") { // Lógica complexa de boleto } // Toda vez que surgir um novo método (Pix, PayPal), // você terá que abrir esta classe e modificá-la. } } |
Por que isso é ruim?
1. Risco de Regressão: Ao alterar a classe para
adicionar "Pix", você pode acidentalmente quebrar a lógica do "Cartão".
2. Testes Frágeis: Você precisa testar novamente todos os tipos de pagamento a
cada mudança, mesmo aqueles que não foram alterados.
3. Acúmulo de Débito
Técnico: A classe tende a crescer infinitamente, tornando-se uma "Classe Deus".
✅ A Solução (Extensibilidade com Abstração)
A ideia é que
você possa adicionar novos comportamentos apenas criando novas classes, sem
tocar no código que já foi testado e homologado
// 1. Definimos o contrato (Abstração) public interface IPagamentoStrategy { bool PodeProcessar(string tipo); void Processar(decimal valor); } // 2. Criamos implementações específicas (Extensão) public class PagamentoCartao : IPagamentoStrategy { public bool PodeProcessar(string tipo) => tipo == "Cartao"; public void Processar(decimal valor) => Console.WriteLine($"Processando {valor} no Cartão..."); } public class PagamentoPix : IPagamentoStrategy { public bool PodeProcessar(string tipo) => tipo == "Pix"; public void Processar(decimal valor) => Console.WriteLine($"Processando {valor} no Pix..."); } |
O Ponto Crítico: Como o PedidoService decide qual usar?
Para que o princípio Aberto/Fechado seja implementado de forma plena, o serviço
de alto nível deve ser 'agnóstico' às implementações. Isso significa que o
PedidoService não deve conhecer — e nem se importar com — quantas ou quais
estratégias de pagamento existem no sistema.
Em vez de instanciar classes
concretas ou gerenciar listas manualmente, utilizamos o Container de Injeção de
Dependência (DI) do .NET para injetar uma coleção de abstrações (IEnumerable<IPagamentoStrategy>).
Dessa forma, a escolha da estratégia correta deixa de ser uma decisão
escrita no código e passa a ser uma configuração de
infraestrutura. O resultado é um sistema onde você adiciona novos recursos
apenas plugando novas classes no container, mantendo o núcleo da aplicação
totalmente isolado e protegido de mudanças.
// O PedidoService está FECHADO para modificação. // Ele aceita qualquer número de estratégias via construtor (C# 12 Primary Constructor). public class PedidoService(IEnumerable<IPagamentoStrategy> estrategias) { public void RealizarPagamento(decimal valor, string tipo) { // O código busca a estratégia correta sem usar IF ou SWITCH var estrategia = estrategias.FirstOrDefault(e => e.PodeProcessar(tipo)) ?? throw new NotSupportedException($"Tipo {tipo} não suportado."); estrategia.Processar(valor); } } |
O que ganhamos?
1. Aberto para Extensão: Se o cliente
pedir suporte a "Cripto", você apenas cria uma classe PagamentoCripto :
IPagamentoStrategy e a registra no seu container de DI.
2. Fechado para
Modificação: Você não alterou uma única linha de PedidoService para adicionar o
novo pagamento.
3. Responsabilidade Definida: Cada classe de estratégia
cuida apenas de um tipo de pagamento, respeitando também o SRP.
Dica do Especialista: No .NET, o registro dessas estratégias é
feito geralmente no Program.cs, o que isola a configuração do comportamento da
lógica de negócio.
L — Princípio da Substituição de Liskov (LSP)
Subclasses
devem ser substituíveis por suas classes base sem quebrar o sistema.
❌ O Problema (Violação do Princípio)
Imagine que seu
sistema trata todos os métodos de pagamento da mesma forma. Você define uma
classe base com o método Estornar. Quando você adiciona o Pix ou Dinheiro,
percebe que eles não permitem estorno automático via API.
public abstract class MetodoPagamento { public abstract void Pagar(decimal valor); public abstract void Estornar(decimal valor); // Nem todo mundo pode fazer isso! } public class CartaoCredito : MetodoPagamento { public override void Pagar(decimal valor) => Console.WriteLine("Pagando com Cartão..."); public override void Estornar(decimal valor) => Console.WriteLine("Estorno processado."); } public class Pix : MetodoPagamento { public override void Pagar(decimal valor) => Console.WriteLine("Pagando via Pix..."); // VIOLAÇÃO DO LSP: Pix não suporta estorno automático aqui. // Isso quebra a confiança de quem usar a classe base 'MetodoPagamento' public override void Estornar(decimal valor) => throw new NotSupportedException("Pix não suporta estorno automático."); } |
Por que isso é ruim?
Se você tiver uma lista de List<MetodoPagamento> e tentar fazer um loop para estornar todos os pagamentos de um dia cancelado, seu sistema irá travar (crash) ao chegar no objeto Pix.
Você seria forçado a usar um if (pagamento is not Pix) antes de chamar o método, o que também viola o OCP.
✅ A Solução (Respeitando o LSP)
A solução é não forçar
comportamentos em quem não pode executá-los. Movemos a capacidade de estorno
para uma interface específica. Assim, garantimos que qualquer objeto que herde
de MetodoPagamento seja 100% funcional para as operações que ele promete
entregar.
// Contrato base: Todo pagamento deve poder 'Pagar' public abstract class MetodoPagamento { public abstract void Pagar(decimal valor); } // Contrato opcional: Apenas para quem suporta estorno public interface IEstornavel { void Estornar(decimal valor); } // Implementações específicas public class CartaoCredito : MetodoPagamento, IEstornavel { public override void Pagar(decimal valor) { /* ... */ } public void Estornar(decimal valor) { /* ... */ } } public class Pix : MetodoPagamento { // Pix cumpre 100% do que 'MetodoPagamento' promete. public override void Pagar(decimal valor) { /* ... */ } } |
O que aprendemos aqui?
O LSP nos ensina que a herança
não deve ser usada apenas para reaproveitar código, mas para garantir que o
comportamento seja consistente.
Se você precisa lançar uma NotImplementedException ou verificar o tipo da classe (is Pix) para evitar um erro, seu design de classes está violando o LSP.
I — Princípio da Segregação de Interface (ISP)
Nenhum
cliente deve ser forçado a depender de métodos que não utiliza.
❌ O Problema (Interface "Gorda")
Imagine uma interface
que tenta centralizar todas as operações de um sistema de usuários. Ao fazer
isso, você cria um acoplamento desnecessário para qualquer classe que precise de
apenas uma funcionalidade.
public interface IUsuarioService { void Cadastrar(Usuario usuario); void Deletar(int id); void GerarRelatorioVendas(int usuarioId); // Método de análise de dados } // O problema aparece no CLIENTE: public class RelatorioFinanceiro(IUsuarioService usuarioService) { public void Gerar() { // Este serviço só precisa gerar relatórios. // Mas ele tem acesso total aos métodos 'Deletar' e 'Cadastrar'. // Isso viola a segurança e a clareza do design. usuarioService.GerarRelatorioVendas(123); } } |
Por que isso é ruim?
Se amanhã você precisar mudar a assinatura do método Deletar, a classe RelatorioFinanceiro (que não tem nada a ver com deleção) precisará ser recompensada ou, no mínimo, testada novamente, pois sua dependência mudou.
✅ A Solução (Interfaces Segregadas)
Devemos quebrar a
interface gigante em contratos menores e específicos. Assim, cada cliente
consome apenas o que é estritamente necessário para sua função.
// Interfaces específicas e granulares public interface IUsuarioCadastro { void Cadastrar(Usuario usuario); void Deletar(int id); } public interface IUsuarioRelatorio { void GerarRelatorioVendas(int usuarioId); } // Agora o cliente depende apenas da interface de relatório public class RelatorioFinanceiro(IUsuarioRelatorio relatorioService) { public void Gerar() { // O código está protegido: este serviço nem sabe que existem métodos de deleção. relatorioService.GerarRelatorioVendas(123); } } // Uma classe de serviço pode implementar múltiplas interfaces se necessário public class UsuarioService : IUsuarioCadastro, IUsuarioRelatorio { public void Cadastrar(Usuario usuario) { /* ... */ } public void Deletar(int id) { /* ... */ } public void GerarRelatorioVendas(int usuarioId) { /* ... */ } } |
O que aprendemos aqui?
O ISP não é sobre o tamanho da
interface em si, mas sobre o impacto nos clientes. Se você tem uma interface com
10 métodos e todos os clientes usam os 10, ela não viola o ISP. Mas se um
cliente usa apenas 1 método e "enxerga" os outros 9, você criou uma dependência
frágil.
No .NET, isso é muito comum em APIs que usam o padrão Repository.
Em vez de passar um IRepository completo (com CRUD) para uma classe de consulta,
você pode passar apenas um IReadOnlyRepository.
D — Princípio da Inversão de Dependência (DIP)
Módulos
de alto nível não devem depender de módulos de baixo nível. Ambos devem depender
de abstrações. Abstrações não devem depender de detalhes. Detalhes devem
depender de abstrações.
❌ O Problema (Dependência Rígida)
No design tradicional, as
classes de lógica de negócio (Alto Nível) dependem diretamente de classes de
infraestrutura (Baixo Nível), como bancos de dados ou APIs externas.
// Módulo de Baixo Nível (Detalhe de Infraestrutura) public class SqlServerRepository { public void Salvar(string dados) => Console.WriteLine("Salvando no SQL Server..."); } // Módulo de Alto Nível (Lógica de Negócio) public class PedidoService { // ERRO: O serviço está acoplado a uma implementação específica. private readonly SqlServerRepository _repository = new(); public void Processar(string pedido) => _repository.Salvar(pedido);
}
|
Por que isso é ruim?
1. Rigidez: Se você quiser mudar
para MongoDB, terá que alterar o PedidoService.
2. Impossível de Testar: Você não consegue testar a lógica de negócio sem ter um
banco SQL Server real rodando.
3. Violação de Propriedade: A lógica de
negócio está sendo "ditada" pelas capacidades da infraestrutura.
✅ A Solução (Invertendo o Controle)
Para aplicar o DIP,
inserimos uma abstração (interface) entre os dois. A grande sacada aqui é a
Inversão de Propriedade: a interface deve pertencer ao módulo de alto nível
(Domínio), e não ao detalhe.
// --- CAMADA DE DOMÍNIO (Alto Nível) --- // A abstração "mora" aqui. O domínio define o que ele precisa. public interface IPedidoRepository { void Salvar(string dados); } // O serviço depende apenas da abstração (usando Primary Constructor do C# 12) public class PedidoService(IPedidoRepository repository) { public void Processar(string pedido) => repository.Salvar(pedido); } // --- CAMADA DE INFRAESTRUTURA (Baixo Nível) --- // O detalhe agora se adapta ao que o Domínio exigiu. public class SqlServerRepository : IPedidoRepository { public void Salvar(string dados) => /* Lógica SQL */ Console.WriteLine("SQL..."); } public class MongoDbRepository : IPedidoRepository { public void Salvar(string dados) => /* Lógica Mongo */ Console.WriteLine("Mongo..."); } |
O que foi "Invertido"?
1. Inversão de Dependência:
Antes, o PedidoService apontava para o SqlServerRepository. Agora, tanto o
PedidoService quanto o SqlServerRepository apontam para a interface
IPedidoRepository.
2. Inversão de Propriedade: No modelo errado, a
interface costuma ser vista como parte da infraestrutura. No DIP, a interface é
um contrato do Domínio. É o Domínio dizendo: "Para eu trabalhar, alguém precisa
me entregar um serviço que tenha este contrato".
3. Inversão de Fluxo de
Controle: O fluxo de execução continua sendo o Serviço chamando o Repositório,
mas o fluxo de compilação mudou. O módulo de infraestrutura agora precisa
referenciar o módulo de domínio para implementar a interface.
DIP
vs Injeção de Dependência (DI): É vital que entender que:
• DIP é o conceito (não dependa de classes concretas).
•
DI é como
implementamos isso (passando a instância pelo construtor).
•
IoC Container
(como o do ASP.NET Core) é a ferramenta que automatiza essa entrega.
Conclusão do SOLID
Ao aplicar os cinco princípios,
transformamos um código "espaguete" em um conjunto de componentes legos: peças
pequenas, intercambiáveis e fáceis de testar. O SOLID não elimina a
complexidade, ele a organiza para que o sistema possa crescer sem entrar em
colapso sob o próprio peso.
O Custo da Organização
Embora o SOLID torne o código testável e fácil de manter, ele tem um preço:
complexidade inicial.
• Aumento de arquivos: O que era uma classe vira
três interfaces e três implementações.
• Carga Cognitiva: Rastrear o fluxo
exige navegar por mais abstrações.
Regra de ouro:
Para CRUDs extremamente simples e descartáveis, o SOLID pode ser
overengineering. Mas, para sistemas que pretendem sobreviver ao próximo ano, ele
é o seu melhor seguro de vida.
E estamos conversados...
"Faze-nos voltar, Senhor Deus dos Exércitos; faze
resplandecer o teu rosto, e seremos salvos. "
Salmos 80:19
Referências:
NET - Unit of Work - Padrão Unidade de ...