Uma classe que salva um usuário no banco de dados, manda um e-mail de boas-vindas e formata um recibo precisa de uma única mudança para quebrar duas funcionalidades com as quais não tem nada a ver. Você toca no template do e-mail e arrisca a escrita no banco. Você toca no layout do recibo e arrisca o e-mail. SOLID são cinco regras que atacam esse tipo de emaranhado a partir de ângulos diferentes, e compartilham uma ideia: uma classe deve mudar por um único motivo, e o resto do código deve depender do que algo promete fazer, não de como faz isso.
Os princípios vêm do paper de Robert C. Martin de 2000, “Design Principles and Design Patterns”. Michael Feathers depois os colocou na ordem que forma a sigla SOLID. Nenhum dos cinco trata de sintaxe ou de uma linguagem específica. Tratam de onde ficam as costuras do seu código, para que uma mudança em um lugar permaneça naquele lugar.
O que SOLID significa, e por que são cinco regras e não uma
- S, Single Responsibility: uma classe tem um único motivo para mudar.
- O, Open/Closed: comportamento novo entra como código novo, sem editar código que já funciona.
- L, Liskov Substitution: uma subclasse tem que se comportar como a classe mãe em todo lugar onde a classe mãe é usada.
- I, Interface Segregation: nenhuma classe deveria ser forçada a implementar métodos que não usa.
- D, Dependency Inversion: o código depende de uma abstração, não de uma classe concreta específica.
Eles se sobrepõem de propósito. Dependency Inversion é o que faz o Open/Closed funcionar na prática: você só consegue adicionar uma implementação nova sem tocar em código antigo se esse código foi escrito contra uma interface. Interface Segregation é o Single Responsibility aplicado a interfaces em vez de classes. Trate os cinco como visões de um único objetivo, não como cinco caixinhas para marcar.
Single Responsibility Principle: um único motivo para mudar
Aqui está uma classe com dois motivos para mudar escondidos em um único corpo:
| |
Uma mudança de esquema e uma mudança no template do e-mail agora afetam a mesma classe, User. Testar save() arrasta uma conexão com o banco (ou um mock dela), e testar send_welcome_email() arrasta um cliente SMTP, mesmo que nenhum dos dois testes se importe com o outro método. É também aqui que começa o emaranhado descrito em O Que É Spaghetti Code e Como Refatorá-lo: uma classe continua absorvendo tarefas que não são dela porque já estava lá.
Separe as tarefas:
| |
Agora cada classe muda pelo seu próprio motivo, e cada uma pode ser testada com um fake que cobre uma tarefa pequena. “Um único motivo para mudar” não é “um único método”. UserRepository poderia crescer com find_by_email e delete e continuar tendo uma única responsabilidade, porque cada método serve à mesma tarefa: persistir usuários.
Open/Closed Principle: adicionar comportamento sem editar código que funciona
Uma calculadora que se ramifica com base em uma tag de tipo:
| |
Adicionar um triângulo significa editar area() de novo. Pior: toda outra função que se ramifica com base em shape["type"] precisa ser encontrada e editada da mesma forma. Cada forma nova é uma mudança em código já testado, com risco de quebrar os casos do círculo e do retângulo.
Defina o contrato uma vez e adicione formas novas como classes novas:
| |
Adicionar Triangle é criar uma classe nova que implementa area(). total_area nunca muda, e Circle e Rectangle também não. É só isso que “aberto para extensão, fechado para modificação” significa: comportamento novo chega como código novo, e o código já coberto por testes continua intocado.
Liskov Substitution Principle: uma subclasse precisa manter as promessas da classe mãe
A falha clássica é um Square construído como subclasse de Rectangle:
| |
| |
resize_and_check funciona com qualquer Rectangle. Passe um Square e a asserção falha: definir a altura mudou a largura também, em silêncio, então a área é 16, não 20. Square passa a checagem de tipos como Rectangle, então o mypy não tem nada a apontar, mas ainda assim quebra o comportamento que todo código que recebe um Rectangle espera encontrar. Guarde essa distinção. Violações de Liskov raramente são sobre assinaturas de métodos, que as ferramentas já pegam. São sobre uma subclasse que muda, em silêncio, o que o código que a chama tem o direito de assumir.
Não resolva isso com um caso especial para quadrados dentro de resize_and_check. Resolva não tornando Square uma subclasse de Rectangle. Um quadrado não tem duas dimensões redimensionáveis de forma independente, então não consegue honrar o contrato de um Rectangle. Os dois podem implementar a interface Shape do exemplo de Open/Closed, sem nenhuma herança entre eles.
Interface Segregation Principle: não force métodos sem uso em uma classe
Uma interface que agrupa capacidades sem relação entre si:
| |
Todo código que tem um Printer em mãos agora precisa saber, fora do sistema de tipos, quais métodos são seguros em qual instância. A interface promete três capacidades e entrega uma, mais duas minas.
Separe a interface por capacidade em vez de por dispositivo:
| |
Uma função que só precisa imprimir recebe um Printable, e não existe nenhum .scan() para chamar por engano em um dispositivo que nunca teve um. O contrato vem do que o objeto implementa, não de um comentário avisando quais métodos vão lançar uma exceção.
Dependency Inversion Principle: dependa de abstrações, não de classes concretas
Um serviço construído direto sobre uma biblioteca de banco de dados:
| |
UserService está soldado ao SQLite. Migrar para Postgres, ou passar um repository falso em um teste, significa editar o próprio UserService em vez de trocar uma dependência.
| |
UserService agora depende da abstração UserRepository. Construir um SQLiteUserRepository(), ou um FakeUserRepository() para um teste, e passá-lo é um passo separado, e esse passo é a dependency injection. Os dois termos são usados como sinônimos, mas são coisas diferentes: dependency inversion é a regra de design (dependa de abstrações), e dependency injection é a técnica que fornece o objeto concreto. O Que É Dependency Injection (Com Exemplos) cobre o lado da injection, incluindo o que um container DI adiciona quando uma classe precisa de mais de uma ou duas dependências conectadas.
Quando SOLID se torna over-engineering
Dividir um script de vinte linhas em cinco interfaces não traz nada se existe exatamente uma implementação e nenhum plano para uma segunda. Interface Segregation e Dependency Inversion adicionam, cada uma, uma camada de indireção. Essa camada se paga quando você realmente troca implementações, como produção contra teste ou SQLite contra Postgres, ou quando precisa isolar uma classe atrás de um test double como descrito em Teste Unitário vs Teste de Integração vs End-to-End. Se nada é trocado nunca, a abstração é overhead puro.
Mais abstrações não significam design melhor. Um construtor que recebe oito interfaces injetadas não é mais SOLID do que um que recebe três. Significa que a classe por trás ainda tem muitas responsabilidades, o que te manda de volta para a primeira letra.
SOLID também é vocabulário orientado a objetos. Bastante código bom não tem nenhuma classe. Os hábitos por trás valem do mesmo jeito: funções pequenas com uma única tarefa, e dependências passadas em vez de buscadas em variáveis globais. Isso importa num estilo funcional mesmo quando nenhuma das cinco letras se aplica ao pé da letra.
Como começar a aplicar SOLID em um código já existente
Não refaça um código inteiro contra uma checklist. Aplique Single Responsibility primeiro, e só onde modificar uma classe já dói: um arquivo que causa conflito de merge todo sprint, ou um teste que precisa de três mocks sem relação antes de poder rodar. Coloque uma dependência atrás de uma abstração só quando uma segunda implementação ou um fake de teste for uma necessidade atual, não hipotética. A maioria dos problemas de Open/Closed e Liskov começa como um problema de Single Responsibility que faz nascer uma subclasse, então os outros três princípios tendem a seguir depois que esses dois hábitos se firmam.