Contexto do SOLID
Antigamente os sistemas eram desenvolvidos com um único objetivo: fazer o software funcionar. Os padrões utilizados eram monolíticos e rígidos, com forte acoplamento e com tudo interligado. Atualmente sistemas conhecidos como “Código espaguete” ou “Grande Bola de Lama”. Como tudo estava interligado, corrigir um erro em uma ponta do sistema geralmente causava erros em outras partes do sistema.
Crise do Software
Na década de 70 e 80 o desenvolvimento de software enfrentava uma crise. O hardware estava ficando incrivelmente potente e barato, mas o software estava se tornando tão complexo que as equipes não conseguiam mantê-los de forma eficiente.
Introdução ao SOLID
O SOLID veio pra transformar o desenvolvimento de software em uma tarefa artesanal em uma engenharia, focada em criar peças que se encaixam sem estarem “soldadas” umas nas outras. Ele é um conjunto de cinco princípios de design de software para a programação orientada a objetos (POO).
Em resumo, podemos dizer que os princípios funcionam como uma base/guia para os desenvolvedores arquitetarem soluções fáceis de manter, testar e evoluír ao longo do tempo.
Ao invés de desenvolvermos sistemas como “um grande bloco de cimento”, o SOLID nos ensina a construir softwares como peças “LEGO”, que se encaixam de forma inteligente.
1. Princípio da Responsabilidade Única
Esse é o primeiro pilar do SOLID. A definição clássica é “Uma classse deve ter um, e apenas um motivo pra mudar.”.
Na prática, isso quer dizer que uma classe deve ser responsável por uma funcionalidade/parte específica do software. Em sistemas “blocos de cimentos”, é comum encontrarmos as chamadas “God Objects” (Objeto Deus), uma classe gigantesca que sabem e fazem tudo. O que contradiz o princípio da responsabilidade única.
Analogia
Imagine um funcionário de um restaurante que é, ao mesmo tempo, o cozinheiro, o garçom e o faxineiro . Se ele precisar mudar a forma como limpa o chão, o serviço de cozinha e o atendimento podem ser afetados ou interrompidos. No SRP, dividimos essas tarefas entre especialistas para que um não atrapalhe a evolução do outro.
Exemplo
Vamos imaginar um cenário em que o princípio não foi aplicado e após isso vamos transformá-lo em um código modular. Imagine um sistema que gerencia pedidos de e-commerce.
Exemplo Problema
Na classe Pedido abaixo, temos três motivos diferentes para serem alterados. Se a regra de calculo, banco e e-mail mudar. Cada método representa uma responsabilidade diferente para a classe Pedido
class Pedido:
def calcular_total(self):
# Lógica de negócio (Preço, impostos, descontos)
pass
def salvar_no_banco(self):
# Lógica de infraestrutura (Conexão SQL, tabelas)
pass
def enviar_email_confirmacao(self):
# Lógica de comunicação (Servidor SMTP, template do e-mail)
pass
Exemplo Solução
Para aplicar o SRP, separamos as tarefas em “especialistas”. Cada classe agora tem apenas uma razão para existir e mudar.
-
Pedidovai conter apenas a lógica de cálculocalcular_total -
salvar_no_bancovai ser uma nova classe chamadaPedidoRepository, que vai implementar o Padrão Repository -
enviar_email_confirmacaovai ser um serviço separado apenas por enviar comunicações
Desse modo, se você decidir trocar o e-mail por uma mensagem no WhatsApp, você altera apenas o serviço respectivo. A regra de cálculo do Pedido então permanece segura e intocada, como uma peça LEGO independente
Exemplo aplicado a contexto: Clean Architecture, Mediator, CQRS e DDD
Veja como o SRP se manifesta em um CommandHandler usando Mediator:
// Este Handler tem UMA única razão para mudar:
// Se o fluxo de "Criação de Pedido" for alterado.
public class CriarPedidoHandler : IRequestHandler<CriarPedidoCommand, Guid>
{
private readonly IPedidoRepository _repository; // Interface (DIP)
private readonly IMediator _mediator;
public async Task<Guid> Handle(CriarPedidoCommand request, CancellationToken ct)
{
// 1. Regra de Negócio (DDD: Entidade decide se pode ser criada)
var pedido = new Pedido(request.ClienteId, request.Itens);
// 2. Persistência (Clean Arch: O Handler não sabe se é SQL ou NoSQL)
await _repository.SalvarAsync(pedido);
// 3. Comunicação (Mediator: Dispara um evento para quem interessar)
// Isso separa a criação do pedido do envio de e-mail (SRP puro!)
await _mediator.Publish(new PedidoCriadoEvent(pedido.Id));
return pedido.Id;
}
2. Princípio Aberto/Fechado
Esse é o segunda pilar do SOLID. Sua fundamentação existe para que os softwares criados sejam fáceis de manter e evoluir sem quebrar o que já funciona.
Certo! Mas o que diabos isso significa? Os sistemas não são estáticos, eles tendem a evoluír. O cenário comum no mercado, portanto, é: com novas definições de funcionalidades os desenvolvedores geralmente alteram o código-fonte existente, código este, que já foi testado e homologado. Resultando em um sistema difícil de manter e “sustentar”.
O OCP surge para mitigar esse problema e a definição foi criada por Bertrand Meyer:
"Entidades de software (classes, módulos, funções, etc.) devem estar abertas para extensão, mas fechadas para modificação."
- Aberto pra Extensão: Você é deve ser capaz de adicionar novos comportamentos e funcionalidades ao sistema à medida que os requisitos mudam.
- Fechada para Modificações: Ao adicionar essa nova funcionalidade, você não deve alterar o código-fonte existente que já foi estado e está funcionando
Metáfora da Tomada
Tenho um professor que comparou esse princípio com uma tomada. elétrica de parede.
- Ela é fechada pra modificação, já que você não precisa quebrar a parede e refazer a fiação elétrica da casa toda vez que compra um novo eletrodoméstico
- Ela é abertura para extensão, você pode instalar uma TV, ventilador ou carregador estendendo a utilidade daquela rede relétrica, apenas plugando algo novo. A interface (os buracos da tomada) permitee isso.
Exemplo prático
Vamos desenvolver um cálculo de Bônus Anual. Imagine uma classe CalculadoraDeBonus. Inicialmente, a empresa só tem "Desenvolvedores" e "Gerentes".
Exemplo Ruim (Violando OCP)
Nesta abordagem comum (mas ruim), usamos um switch ou if/else para verificar o cargo. O problema: quero adicionar dois novos cargos, diretor e estagiário. Para isso eu preciso abrir a classe e modificá-la diretamente! Isso viola o OCP
public class CalculadoraDeBonus
{
public decimal Calcular(string cargo, decimal salario)
{
if (cargo == "Gerente")
{
return salario * 0.2m;
}
else if (cargo == "Desenvolvedor")
{
return salario * 0.1m;
}
// Se a empresa criar o cargo "Diretor" amanhã,
// VOU TER QUE ALTERAR ESTE ARQUIVO.
return 0;
}
}
O Risco: Ao editar esse arquivo para adicionar o "Diretor", você pode sem querer apagar uma linha ou mudar a lógica do "Gerente", causando bugs em algo que já estava testado.
Solução (Aplicando OCP)
Vou trazer duas abordagens para fechar a classe para modificação e abri-las para extensão. Ambas abordagens resolvem o OCP, mas elas comunicam intenções diferentes sobre o seu sistema.
- Abordagem com Classe Abstrata
- Abordagem com Interfaces
Abordagem com Classe Abstrata
Use essa abrodagem quando existe uma relação forte de herança e você quer reaproveitar propriedades ou métodos
Cenário: Todo funcionário tem Nome e Salario (código compartilhado), mas o cálculo do bônus muda.
Contrato
public abstract class FuncionarioBase
{
// Código compartilhado (Reuso)
public string Nome { get; set; }
public decimal Salario { get; set; }
// Obriga as classes filhas a implementarem a lógica específica
public abstract decimal CalcularBonus();
// Método comum que já funciona para todos (não precisa sobrescrever)
public string ObterCracha() => $"Funcionário: {Nome}";
}
Aberto para extensões:
public class Gerente : FuncionarioBase
{
public override decimal CalcularBonus()
{
return Salario * 0.2m;
}
}
public class Desenvolvedor : FuncionarioBase
{
public override decimal CalcularBonus()
{
return Salario * 0.1m;
}
}
Uso
public void Processar(FuncionarioBase funcionario)
{
Console.WriteLine(funcionario.CalcularBonus());
}
Abordagem com Interface
Use esta abordagem quando o foco é o "O QUE FAZ" (Comportamento). Interfaces são contratos puros. Elas não carregam "bagagem" (estado ou variáveis) e permitem maior flexibilidade, pois uma classe pode implementar múltiplas interfaces.
Cenário: Aqui não nos importamos se é um funcionário, um prestador de serviço ou um robô. Só nos importamos se ele tem uma regra de bônus. Isso é frequentemente chamado de Strategy Pattern.
Contrato
// --- O Contrato (Interface) ---
public interface IRegraDeBonus
{
decimal Calcular(decimal salarioBase);
}
public class BonusParaDesenvolvedor : IRegraDeBonus
{
public decimal Calcular(decimal salarioBase)
{
return salarioBase * 0.1m;
}
}
Aberto para extensões:
// Note que não herdamos de ninguém, apenas assinamos o contrato
public class BonusParaGerente : IRegraDeBonus
{
public decimal Calcular(decimal salarioBase)
{
return salarioBase * 0.2m;
}
}
Uso
public class CalculadoraServico
{
// Recebemos a INTERFACE. Baixíssimo acoplamento.
public decimal ExecutarCalculo(decimal salario, IRegraDeBonus regra)
{
return regra.Calcular(salario);
}
}
Comparação entre abordagens: Abstract Class vs. Interface
| Característica | Abstract Class | Interface |
|---|---|---|
| Relacionamento | "É um" (Hierarquia estrita). Um Gerente é um Funcionário. | "Faz isso" (Capacidade). Essa classe sabe calcular bônus. |
| Reuso de Código |
Alto. Permite compartilhar propriedades (Nome, ID) e métodos concretos. |
Baixo/Nenhum. Apenas define as assinaturas dos métodos.* |
| Herança | Limitada. C# só permite herdar de 1 classe. Se usar Abstract, você "gasta" essa cartada. |
Múltipla. Uma classe pode implementar 5 interfaces diferentes (IBonificavel, IAutenticavel, etc). |
| Acoplamento | Médio. As filhas dependem da classe pai. | Baixíssimo. As classes só dependem do contrato. |
| Quando usar no OCP? | Quando você tem uma família de objetos intimamente relacionados que compartilham estrutura. | Quando você quer plugar comportamentos diferentes (Strategy Pattern) ou testabilidade máxima (Mocks). |
No desenvolvimento moderno de C# (.NET Core/Dependency Injection), Interfaces são usadas em 90% dos casos para garantir o OCP, pois facilitam muito os testes unitários (Mocking).
3. Princípio da Substituição de Liskov
O terceiro princípio é o Liskov Substituition Principle. Ele foi definido por Barbara Liskov em 1987 e, embora que por definição academia pareça ser um “bicho de setes cabeças”, na prática é bem simples e ideal para criar sistemas robustos.
O princípio estabelece que uma classe derivada deve poder substituir sua classe base sem alterar ou comprometer o comportamento esperado do programa. Em outras palavras, ao herdar a classe B a partir da classe A, o desenvolvedor deve conseguir realizar tudo o que era possível com A utilizando B, mantendo as mesmas garantias de funcionamento e, se necessário, acrescentando novos comportamentos, mas nunca removendo ou quebrando os existentes.
Para cumprir o príncpio pense assim: “O filho (classe que herda) deve conseguir fazer tudo que o pai (classe pai) prometeu que faria”. Se o pai diz "eu sei somar dois números", o filho não pode dizer "eu só sei somar se os números forem positivos". Isso seria uma surpresa desagradável para quem usa o código.
Analogia com Pássaros
Imagine que você tem um sistema para um zoológico.
- Você cria a Entidade
Passaro - Você define que todo
Passarotem a habilidade de voar.
Até aqui, tudo bem. Aí você cria os tipos específicos:
- Pardal (É um Pássaro): Voa? Sim. ✅ (Tudo certo, segue o princípio).
- Águia (É um Pássaro): Voa? Sim. ✅ (Tudo certo).
Agora vem o problema. Você precisa adicionar um Pinguim.
- Pinguim (É um Pássaro): Voa? Não. ❌
Onde o princípio quebra?
Se você tem uma parte do seu código que diz: “Pegue todos os pássaros e faça-os voar”, quando chegar a vez do Pinguim o programa vai travar ou dar erro, porque pinguins não voam. Na aplicação promete que todo pássaro voa, mas o pinguim não cumpre essa promessa.
Como consertar o exemplo do Pinguim?
Em vez de colocar "Voar" na categoria principal Passaro, você deve criar uma categoria mais específica ou separar as habilidades:
- Classe `Passaro` (Tem bico, bota ovos).
- Classe
PassaroQueVoa(Herda de Pássaro + sabe voar). - Classe
PassaroQueNadaHerda de Pássaro + sabe nadar).
Dessa forma o Pardal entra em PassaroQueVoa e o Peguim em PassaroQueNada
E o melhor: ninguém promete o que não pode cumprir, e o código não quebra.
Exemplo aplicado ao código
Vamos retomar o exemplo acima e desenvolver com o código com C# de duas formas, uma que viola o LSP e outra que não viola.
Violação Estrutural
Imagine que você tem uma classe abstrata Passaro. Em C#, é comum usarmos classes abstratas ou interfaces para definir contratos.
O exemplo abaixo viola o LSP porque o Pinguim não pode substituir Passaro sem causar efeitos colaterais (a exceção).
Classe Base
public abstract class Passaro
{
public abstract void Voar();
}
Implementação Concreta
public class Pardal : Passaro
{
public override void Voar()
{
Console.WriteLine("Pardal voando alto!");
}
}
Implementação que viola o LSP
public class Pinguim : Passaro
{
public override void Voar()
{
// O compilador obriga a implementar Voar(),
// mas a lógica não permite. O dev lança uma exceção.
throw new NotImplementedException("Pinguins não voam!");
}
}
O Jeito Correto
Em C#, a melhor forma de resolver isso é segregando as capacidades através de Interfaces. Nem todo pássaro é um IVoador.
Essa implementação abaixo torna impossível passar um Pinguim para um método que espera IVoador. O compilador do C# vai te impedir antes mesmo de você rodar o programa.
Definição de abstração (interface e classe abstrata)
// Define apenas o que é comum a TODOS
public abstract class Ave
{
public void Comer() { Console.WriteLine("Comendo..."); }
}
// Interface (Capacidade) separada
public interface IVoador
{
void Voar();
}
Implementações
// Pardal é uma Ave E sabe voar
public class Pardal : Ave, IVoador
{
public void Voar()
{
Console.WriteLine("Pardal voando!");
}
}
// Pinguim é apenas uma Ave (não implementa IVoador)
public class Pinguim : Ave
{
// Pode ter métodos de nadar aqui
}
4. Principio da Segragação de Interface
O ISP afirma que uma interface não deve forçar a classe a implementar métodos que elas não utilizam. A ideia é quebras grandes interfaces em menores e mais específicas, ou seja, em vez de interfaces gigantes e genéricas (”interfaces gordas”), devemos ter “interfaces magras”, quebradas e mais específicas.
“Clientes não devem ser forçados a depender de métodos que não usam.”
Pense nisso como um menu de restaurante:
- Violação do ISP: O restaurante obriga você a comprar o combo com hambúrguer, batata, refrigerante, sorvete e café, mesmo que você só queira o hambúrguer
- Aplicação do ISP: O menu é segregado. Você pode pedir itens individuais ou combos menores que façam sentido para o seu "apetite" (necessidade)
Exemplo didático do problema
Imagine que temos a Interface chamada ITrabalhador, ela possui métodos como Codificar(), Testar() e GerenciarProjetos().
- Você vai criar uma classe chamada
Programadorque implementa essa interface. Até esse cenário tudo ocorre como o esperado. - O problema é que se você criar uma classe
Gerente, e implementar a mesma interface ela será obrigada a implementarCodificar(), mesmo que o gerente não escreva código.
Essa prática cria um acoplamento desnecessário e “polui” o código com métodos vazios ou que lançam exceções como throw new NotImplementedException.
Exemplo técnico do problema
Vamos retomar o exemplo acima e desenvolver com o código com C# de duas formas, uma que viola o ISP e outra que não viola.
1. Violação estrutural
using System;
// Uma "Interface Gorda" que tenta fazer tudo
public interface ITrabalhador
{
void Codificar();
void Testar();
void GerenciarProjeto();
}
public class Programador : ITrabalhador
{
public void Codificar() => Console.WriteLine("Escrevendo código C#...");
public void Testar() => Console.WriteLine("Testando bugs...");
public void GerenciarProjeto() => Console.WriteLine("Atualizando o JIRA...");
// Ok, talvez o programador faça um pouco de tudo.
}
public class Gerente : ITrabalhador
{
public void Codificar()
{
// Oxente! O Gerente não deveria ser obrigado a ter este método.
throw new NotImplementedException("Gerentes não codificam!");
}
public void Testar()
{
throw new NotImplementedException("Gerentes não testam!");
}
public void GerenciarProjeto() => Console.WriteLine("Gerenciando o time...");
}
2. O Jeito Correto
Agora, vamos segregar (separar) as interfaces. Assim, cada classe só assina o "contrato" que realmente consegue cumprir.
// Interfaces segregadas e específicas (Interfaces Magras)
public interface ICodificador
{
void Codificar();
}
public interface ITestador
{
void Testar();
}
public interface IGerente
{
void GerenciarProjeto();
}
// O Programador implementa apenas o que faz sentido para ele
// Ele pode implementar múltiplas interfaces se necessário!
public class Programador : ICodificador, ITestador
{
public void Codificar() => Console.WriteLine("Escrevendo código C#...");
public void Testar() => Console.WriteLine("Testando bugs...");
}
// O Gerente agora está limpo e focado
public class Gerente : IGerente
{
public void GerenciarProjeto() => Console.WriteLine("Gerenciando o time...");
}
5. Princípio da Inversão de Dependência
A definição formal do quinto princípio parece um pouco técnica:
“Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstraçãoes”.
O conceito de Dependency Inversion (DI) pode deixar desenvolvedores imaturos confuso, isso porque esse conceito geralmente surge de uma necessidade prática e real e as explicações geralmente possuem níveis de abstrações que exiga a compreensão por completo do fluxo de um sistema. Antes de entendermos sobre o assunto temos que entender o que são os módulos de baixo e alto nível de um sistema
Módulos de alto e baixo nível
Entender como o sistema de comporta em níveis de módulos e a relação entre eles é de extrema importância para a compreensão do conceito de DI.
Alto nível
O foco aqui é a regra de negócio, lógica central, é aqui que existe o “valor” do que é o sistema. Deve ser algo estável e mudar pouco. Por exemplo: calcular o frete de um pedido, gerar uma fatura ou processar um pagamento.
Baixo nível
O foco é os detalhes de implementação, mecanismos concretos. São os braços e as pernas que executam o que é demandado pelo módulo de alto nível, mas não definem as regras. Isto posto, está mais sujeito a mudanças e alguns exemplos são: bancos de dados específicos, frameworks de interfaces, etc…
Relação entre alto e baixo nível
De forma resumida, podemos dizer que:
Alto nível = o que o sistema faz (regras de negócio).
Baixo nível = como o sistema faz (detalhes técnicos).
Abstração = a ponte que une os dois, mantendo o alto nível protegido dos detalhes.
Didaticamente explicando o conceito:
- O alto nível diz: “Preciso calculas fretes”
- O baixo nível responde: “Ok, sei falar com os Correios e calcular o valor”
Se o alto nível depender diretamente dos detalhes (Correios), ele fica preso apenas a um serviço, então se amanhã mudar para FedEx, a alteração deverá ser feita na alta escala do sistema (alto nível), o que é uma péssima prática.
Para resolver a problemática acima, podemos utilizar a seguinte abordagem:
- O alto nível só conhece a regra “alguém que calcule frete”
- O desenvolvedor cria a abstração
ICalculadoraFrete - O baixo nível (Correios ou FedEx) implementa essa abstração
- O alto nível não precisa mudar quando trocarmos serviço postal
Inversão de dependência
A ideia central da inversão de dependência é “desamarrar” as regras de negócios a um detalhe específico de implementação. Já que essa prática deixa o sistema rígido e difícil de mudar.
Analogia do dia a dia
- Sem DI: Um carro que só funciona se você abastecer com a gasolina da “Bandeira X”
-
Com DI: O carro depende apenas da abstação (interface) “combustível líquido inflamável”
- Pode ser gasolina comum, aditivada, etanol ou até biocombustível
Com base na analogia acima, a gente é capaz de perceber que com a Inversão de dependência o carro não se prende aos detalhe da gasolina (baixo nível), ele só depende da abstração de um combustível
Aplicando o princípio da Inversão de Dependência
Para utilizar a inversão de dependência vamos aplicar a Inversão de Controle (IoC) e Injeção de Dependência (DI)
1. Criamos uma interface (um contrato)
public interface INotificacaoService
{
void Enviar(string mensagem);
}
Aqui estamos dizendo: "Eu não me importo como a mensagem será enviada. Só quero que exista alguém capaz de executar o método Enviar()." Isso é lindo. Porque o PedidoService não quer saber de e-mail, SMS, WhatsApp, sinal de fumaça... ele só quer enviar.
2. Criamos implementações dessa interface
Enviar por E-mail:
public class EmailService : INotificacaoService
{
public void Enviar(string mensagem)
{
Console.WriteLine("Enviando Email: " + mensagem);
}
}
3. PedidoService não se importa mais com o como
Agora ele só recebe a dependência via construtor:
public class PedidoService
{
private readonly INotificacaoService _notificacaoService;
// INJEÇÃO DE DEPENDÊNCIA VIA CONSTRUTOR
public PedidoService(INotificacaoService notificacaoService)
{
_notificacaoService = notificacaoService;
}
public void FinalizarPedido()
{
_notificacaoService.Enviar("Pedido finalizado!");
}
}
Top comments (0)