Pular para o conteúdo
15 min de leitura

Os princípios SOLID na prática: cinco regras para um código que aguenta mudança

Por Equipe Tech do Sonne ·

SOLID explicado sem dogma: o que cada princípio resolve, exemplos de violação e correção, e quando aplicá-lo custa mais do que a bagunça que evita.

Neste artigo

Todo mundo que programa há alguns anos já abriu um arquivo, precisou mudar uma linha e descobriu que aquela mudança quebrava três coisas aparentemente sem relação. O código funcionava, passava nos testes, entregava valor — mas era rígido de um jeito que só se percebe no momento em que a realidade pede uma alteração. SOLID é um conjunto de cinco princípios que nasceu justamente para dar nome a essa rigidez e sugerir formas de dissolvê-la antes que ela endureça a base inteira.

O problema é que SOLID virou, para muita gente, uma sopa de letrinhas que se decora para entrevista e se cita em code review sem que ninguém saiba explicar direito o que cada letra resolve na prática. Este artigo faz o caminho inverso: parte do problema concreto que cada princípio ataca, mostra um exemplo de violação que você provavelmente já escreveu, mostra a correção, e — talvez o mais importante — fala do custo de aplicar cada um sem critério. Porque SOLID mal usado produz exatamente o tipo de abstração inútil que ele deveria evitar.

S — Single Responsibility: uma razão para mudar#

Single Responsibility Principle. A formulação clássica diz que "uma classe deve ter apenas uma razão para mudar". A palavra que carrega o peso aqui é razão, não coisa. Não se trata de uma classe fazer uma única operação — se trata de ela responder a um único ator, a uma única fonte de mudança. Quando um módulo mistura regra de negócio, formatação de saída e persistência, ele passa a ter três chefes diferentes: o time de produto, o time de design e o time de banco de dados. Qualquer um deles pedindo algo obriga a mexer no mesmo arquivo, e mudanças que não têm nada a ver uma com a outra começam a colidir no mesmo lugar.

O sintoma no dia a dia é a classe que só cresce. Ela começa modesta, ganha um método para exportar CSV, depois um para mandar e-mail, depois um cache, e seis meses depois ninguém quer tocá-la porque não dá para saber o que mais vai desmoronar. Outro sintoma é o teste que precisa de meio mundo montado — banco, fila, servidor de e-mail — para verificar uma regra que, no fundo, é uma conta de três linhas. Se testar a lógica exige subir a infraestrutura, é porque a lógica está grudada em coisas que não são ela.

Exemplo de violação e correção. Considere um relatório que calcula, formata e salva, tudo no mesmo lugar:

``python class RelatorioVendas: def gerar(self, vendas): total = sum(v.valor for v in vendas) texto = f"Total de vendas: R$ {total:.2f}" with open("relatorio.txt", "w") as f: f.write(texto) return texto ``

Essa classe muda se a regra de cálculo mudar, se o formato do texto mudar e se o destino do arquivo mudar. Três razões, três atores. A correção separa o cálculo (que é a regra de negócio de verdade) da apresentação e da escrita:

```python class CalculadoraVendas: def total(self, vendas): return sum(v.valor for v in vendas)

class FormatadorRelatorio: def formatar(self, total): return f"Total de vendas: R$ {total:.2f}"

class RepositorioRelatorio: def salvar(self, texto, destino): with open(destino, "w") as f: f.write(texto) ```

Agora a regra de cálculo pode ser testada sem tocar em disco, o formato pode virar HTML sem risco para a conta, e a persistência pode ir para um banco sem que nada disso se contamine. O custo de ignorar o SRP não aparece no dia em que você escreve a classe gorda — aparece meses depois, quando cada alteração pequena vira uma escavação arqueológica e cada deploy carrega o medo de ter mexido no que não devia.

O — Open/Closed: aberto para extensão, fechado para modificação#

Open/Closed Principle. Um módulo deveria estar aberto para extensão e fechado para modificação. Traduzindo: você deveria conseguir adicionar comportamento novo sem editar o código que já funciona e já foi testado. O inimigo aqui é aquela cadeia de if/else ou switch que cresce a cada novo caso. Cada vez que surge uma variação nova, alguém abre a função central, adiciona mais um ramo, e reza para não ter esbarrado na lógica dos ramos antigos. Todo caso novo é uma cirurgia no coração do sistema.

O princípio existe porque modificar código estável é caro e arriscado. Aquele if gigante concentra em um só ponto o conhecimento de todas as variações possíveis, e esse ponto vira um gargalo: qualquer pessoa que precise adicionar um tipo de pagamento, um formato de exportação ou um canal de notificação tem que entender a função inteira antes de encostar nela. Quando o comportamento variável está fechado atrás de uma abstração, o caso novo é um arquivo novo, isolado, que não põe em risco o que já roda em produção.

Exemplo de violação e correção. O clássico é a função que decide o desconto por tipo de cliente com um encadeamento que só cresce:

``javascript function calcularDesconto(cliente, valor) { if (cliente.tipo === "regular") return valor 0.95; if (cliente.tipo === "vip") return valor 0.85; if (cliente.tipo === "parceiro") return valor * 0.75; // e a cada novo tipo, mais um if aqui dentro... } ``

A correção inverte a direção: cada tipo de cliente conhece a própria regra, e a função de cima só pede que ela seja aplicada.

```javascript const descontos = { regular: (valor) => valor 0.95, vip: (valor) => valor 0.85, parceiro: (valor) => valor * 0.75, };

function calcularDesconto(cliente, valor) { const regra = descontos[cliente.tipo]; if (!regra) throw new Error(Tipo desconhecido: ${cliente.tipo}); return regra(valor); } ```

Agora um tipo novo de cliente é uma entrada nova no mapa — o corpo da função calcularDesconto não muda mais. O custo de ignorar o OCP é a regressão silenciosa: você adiciona o caso da vez, algo no else de baixo para de valer, e o bug só aparece na conta de um cliente específico semanas depois. Vale a ressalva de que OCP não pede que você adivinhe toda extensão futura — pede que, quando um eixo de variação já se mostrou (e um segundo caso apareceu), você o feche atrás de uma abstração em vez de continuar empilhando ramos.

L — Liskov Substitution: o subtipo tem que honrar o contrato#

Liskov Substitution Principle. Se um código funciona com um tipo base, ele tem que continuar funcionando quando você passa qualquer subtipo no lugar, sem saber que trocou. É a garantia de que herança não seja uma armadilha. O princípio, formulado por Barbara Liskov, diz que um subtipo precisa ser um substituto genuíno do supertipo — não basta compilar, ele tem que se comportar de acordo com o que o contrato do pai prometeu a quem o usa.

A violação de Liskov é traiçoeira porque o compilador não a pega. A hierarquia é válida, os tipos batem, tudo parece certo — até que o subtipo faz algo que quebra a expectativa de quem só conhecia a interface do pai. Ele lança uma exceção onde o pai sempre retornava um valor, ignora um argumento que o pai respeitava, ou aperta uma pré-condição que o chamador não tinha como saber. O resultado é aquele bug em que o código genérico, escrito para o tipo base, se comporta de um jeito estranho só com uma subclasse específica.

Exemplo de violação e correção. O exemplo mais conhecido é o do quadrado que herda de retângulo. Parece natural — um quadrado é um retângulo, na geometria. No código, não:

```java class Retangulo { protected int largura, altura; void setLargura(int l) { this.largura = l; } void setAltura(int a) { this.altura = a; } int area() { return largura * altura; } }

class Quadrado extends Retangulo { void setLargura(int l) { this.largura = l; this.altura = l; } void setAltura(int a) { this.largura = a; this.altura = a; } } ```

Um código que recebe um Retangulo, faz setLargura(5) e setAltura(4) e espera área 20 vai receber 16 se lhe passarem um Quadrado, porque o Quadrado sabota a independência entre os lados que o contrato do Retangulo prometia. A correção não é remendar a herança: é reconhecer que essa relação de "é um" não vale para o comportamento que importa. Modele os dois como implementações de uma abstração comum de forma imutável:

```java interface Forma { int area(); }

record Retangulo(int largura, int altura) implements Forma { public int area() { return largura * altura; } }

record Quadrado(int lado) implements Forma { public int area() { return lado * lado; } } ```

Sem setters que se contradizem, sem contrato quebrado. O custo de ignorar Liskov é a desconfiança que se espalha pela base: quando subclasses não são substituíveis de verdade, as pessoas começam a preencher o código de verificações de tipo (if (x instanceof Quadrado)) para se defender das surpresas — e cada uma dessas verificações é uma pequena confissão de que a hierarquia mentiu.

I — Interface Segregation: ninguém depende do que não usa#

Interface Segregation Principle. Nenhum cliente deveria ser forçado a depender de métodos que não usa. Interfaces largas, que juntam num só contrato tudo o que um sistema sabe fazer, obrigam quem implementa a lidar com métodos que não fazem sentido para o seu caso. O sintoma é o método implementado só para lançar NotImplementedException ou para retornar null, um bilhete pregado na parede dizendo "isto aqui não deveria estar no meu contrato".

O princípio existe porque dependência é acoplamento, e depender de uma interface gorda te acopla a tudo que ela declara, inclusive às partes que você nunca chama. Quando um método novo entra numa interface inchada, todas as implementações precisam ser tocadas, mesmo as que não têm nada a ver com aquele método. Interfaces pequenas e focadas, ao contrário, deixam cada cliente depender exatamente do que precisa e nada além — e um contrato coeso é mais fácil de entender, de implementar e de simular num teste.

Exemplo de violação e correção. Uma interface de "trabalhador" que assume que todo trabalhador humano se comporta igual:

```java interface Trabalhador { void trabalhar(); void comer(); void dormir(); }

class Robo implements Trabalhador { public void trabalhar() { / ok / } public void comer() { throw new UnsupportedOperationException(); } public void dormir() { throw new UnsupportedOperationException(); } } ```

O robô é forçado a implementar comer e dormir só porque estão no contrato, e cada implementação vazia é uma mentira que só explode em tempo de execução. A correção quebra o contrato grande em contratos pequenos que se combinam conforme a necessidade:

```java interface Trabalhavel { void trabalhar(); } interface Biologico { void comer(); void dormir(); }

class Humano implements Trabalhavel, Biologico { / implementa os três / } class Robo implements Trabalhavel { / implementa só trabalhar / } ```

Agora o robô só depende do que faz sentido para ele. O custo de ignorar o ISP é o efeito dominó: uma mudança na interface gorda se propaga para dezenas de implementações que não deveriam nem estar cientes daquela parte do contrato, e cada uma vira um ponto de atrito num pull request que deveria ser pequeno.

D — Dependency Inversion: dependa de abstrações, não de detalhes#

Dependency Inversion Principle. Módulos de alto nível não deveriam depender de módulos de baixo nível; ambos deveriam depender de abstrações. E as abstrações não deveriam depender de detalhes — os detalhes é que dependem das abstrações. Em termos práticos: a sua regra de negócio não pode saber que os dados vêm do PostgreSQL, que o e-mail sai pelo SendGrid ou que o cache é Redis. Ela deveria falar com uma interface — "um repositório de pedidos", "um serviço de notificação" — e deixar que a escolha concreta seja plugada de fora.

O princípio existe porque a parte valiosa e estável do seu sistema é a regra de negócio, e a parte volátil são os detalhes de infraestrutura. Bancos, provedores e bibliotecas mudam; a lógica que faz o negócio ser o que é muda muito menos. Quando o alto nível depende diretamente do baixo nível, você inverteu a estabilidade: o que é estável fica refém do que é volátil, e trocar o banco de dados obriga a abrir a lógica de negócio. Inverter a dependência recoloca cada coisa no seu lugar — e, de quebra, a regra de negócio passa a ser testável com um dublê em memória, sem precisar de banco nenhum.

Exemplo de violação e correção. Um serviço de pedidos que instancia o banco por conta própria:

```python class ServicoPedido: def __init__(self): self.db = PostgresConexao(host="prod-db", porta=5432)

def registrar(self, pedido): self.db.executar("INSERT INTO pedidos ...") ```

Essa classe está soldada ao PostgreSQL. Não dá para testá-la sem um banco de verdade, não dá para trocar de armazenamento sem editá-la. A correção é receber a abstração de fora, em vez de criar o detalhe por dentro:

```python class RepositorioPedido: # abstração (interface) def salvar(self, pedido): ...

class ServicoPedido: def __init__(self, repositorio: RepositorioPedido): self.repositorio = repositorio

def registrar(self, pedido): self.repositorio.salvar(pedido) ```

Quem constrói a aplicação decide se injeta um RepositorioPostgres, um RepositorioEmMemoria para os testes ou um RepositorioMock. O ServicoPedido não sabe e não se importa. O custo de ignorar o DIP é a impossibilidade de testar sem infraestrutura e a migração dolorosa: no dia em que o banco precisar mudar, ou em que um provedor externo for descontinuado, a mudança que deveria ser localizada num único adaptador vira uma busca por todos os pontos do código que instanciaram o detalhe diretamente.

Quando SOLID atrapalha#

Aqui está a parte que raramente aparece nos tutoriais. SOLID é uma coleção de heurísticas para gerenciar mudança, e heurística aplicada sem julgamento vira dogma — e dogma custa caro. O erro mais comum de quem acabou de aprender os princípios é aplicá-los a tudo, o tempo todo, como se cada linha de código merecesse uma interface e cada if fosse uma violação a ser corrigida. O resultado é o oposto do que SOLID promete: um sistema mais difícil de entender, não mais fácil.

Abstração sem um segundo caso é adivinhação. Criar uma interface para uma implementação que só tem uma classe concreta, "porque um dia pode ter outra", é escrever complexidade a crédito contra um futuro que talvez nunca chegue. Enquanto só existe uma implementação, a interface é peso morto: mais um arquivo para abrir, mais um salto para seguir quando você lê o código, e nenhuma flexibilidade real, porque não há do que trocar. A regra prática é esperar o segundo caso concreto aparecer. Duplicação visível é barata de corrigir; a abstração errada, criada cedo demais em cima de um palpite, é caríssima de desfazer porque já vazou para todo lado.

YAGNI é o contrapeso natural. "You Aren't Gonna Need It" — você não vai precisar disso — é o princípio que segura a mão antes de generalizar por antecipação. SOLID te empurra para pontos de extensão; YAGNI te lembra de só construir o ponto de extensão que a realidade já pediu. Os dois não são inimigos: o bom design vive na tensão entre eles. Aplique OCP no eixo em que a variação já se manifestou, e resista à tentação de aplicá-lo em todos os outros eixos hipotéticos ao mesmo tempo, porque cada ponto de flexibilidade que você adiciona é código que alguém vai ter que entender, testar e manter para sempre.

Fragmentar demais também é um problema. Levar o SRP ao extremo produz aquele sistema com trezentas classes de uma linha, em que seguir um fluxo simples exige abrir doze arquivos e pular de um método minúsculo para outro sem nunca ver a lógica inteira num lugar só. Coesão é uma virtude tão real quanto a separação de responsabilidades: coisas que mudam juntas e são lidas juntas devem morar juntas. "Uma razão para mudar" não é "uma linha de código por classe" — é um agrupamento honesto em torno de um ator e de um motivo. Quando a separação começa a espalhar uma única ideia por vários arquivos, ela deixou de servir à clareza.

Contexto mais adequado do que dogma. Um script que roda uma vez por mês, um protótipo que existe para validar uma hipótese, o núcleo de uma biblioteca usada por milhares de projetos e um serviço central que quinze pessoas mantêm — cada um pede uma dose diferente de rigor. Encher um script descartável de interfaces e injeção de dependência é desperdício; deixar o núcleo de um sistema crítico virar uma classe-monólito de dois mil pares é irresponsabilidade. O sinal para investir em SOLID não é a ideologia, é a dor concreta: quando você percebe que está com medo de mudar uma parte do código, ou quando testar algo simples exige montar o mundo inteiro, ou quando o mesmo switch cresceu pela terceira vez. Aí, sim, o princípio adequado paga o próprio custo.

O que fica#

Se você tirar uma coisa daqui, que seja isto: os cinco princípios não são cinco regras independentes para memorizar, são cinco ângulos de uma mesma preocupação — projetar sistemas que absorvem mudança sem se estilhaçar. SRP separa as razões de mudar; OCP deixa o novo entrar sem mexer no velho; LSP garante que a substituição não traia o contrato; ISP evita que dependências arrastem peso morto; DIP protege o que é estável do que é volátil. Todos apontam para a mesma direção: reduzir o raio de estrago de uma alteração.

E o julgamento vem antes das regras. SOLID é uma ferramenta para gerenciar o custo da mudança onde a mudança de fato dói — não uma medalha que se ganha por ter interfaces em todo canto. Aplique cada princípio quando ele resolver uma dor real e observável, deixe o código simples enquanto ele ainda é simples, e trate a abstração como um investimento que precisa se justificar, não como um imposto que se paga por reflexo. O melhor código não é o mais SOLID no papel — é o que a próxima pessoa, você inclusive daqui a seis meses, consegue mudar sem medo.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly