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.

NotaNa 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:


José Carlos Macoratti