Este artigo ensinará tudo que você precisa saber para aplicar os princípios SOLID aos seus projetos.

Começaremos dando uma olhada na história deste termo. Em seguida, entraremos nos detalhes essenciais - os por que e como de cada princípio - criando um design de classe e melhorando-o passo a passo.

Então pegue uma xícara de café ou chá e vamos começar!

fundo

Os princípios SOLID foram introduzidos pela primeira vez pelo famoso Cientista da Computação Robert J. Martin (também conhecido como Tio Bob) em seu papel em 2000. Mas a sigla SOLID foi introduzida posteriormente por Michael Feathers.

Tio Bob também é autor de livros best-sellers Código limpo, Arquitetura Limpa, e é um dos participantes do "Agile Alliance".

Portanto, não é uma surpresa que todos esses conceitos de codificação limpa, arquitetura orientada a objetos e padrões de design estejam de alguma forma conectados e complementares uns aos outros.

Todos eles têm o mesmo propósito:

"Para criar código compreensível, legível e testável no qual muitos desenvolvedores possam trabalhar de forma colaborativa."

Vamos examinar cada princípio um por um. Seguindo a sigla SOLID, eles são:

  • o SPrincípio de Responsabilidade Inteiro
  • o OPrincípio da caneta fechada
  • o euIskov Princípio de Substituição
  • o EuPrincípio de Segregação de Interface
  • o DPrincípio de inversão de dependência

O Princípio da Responsabilidade Única

O Princípio de Responsabilidade Única afirma que uma classe deve fazer uma coisa e, portanto, deve ter apenas um único motivo para mudar.

Para expor este princípio de forma mais técnica: Apenas uma mudança potencial (lógica do banco de dados, lógica de registro e assim por diante) na especificação do software deve ser capaz de afetar a especificação da classe.

Isso significa que se uma classe for um contêiner de dados, como uma classe Book ou uma classe Student, e tiver alguns campos relativos a essa entidade, ela deve mudar apenas quando mudarmos o modelo de dados.

É importante seguir o princípio da responsabilidade única. Em primeiro lugar, como muitas equipes diferentes podem trabalhar no mesmo projeto e editar a mesma classe por motivos diferentes, isso pode levar a módulos incompatíveis.

Em segundo lugar, torna o controle de versão mais fácil. Por exemplo, digamos que temos uma classe de persistência que lida com operações de banco de dados e vemos uma mudança nesse arquivo nos commits do GitHub. Seguindo o SRP, saberemos que ele está relacionado ao armazenamento ou a coisas relacionadas ao banco de dados.

Conflitos de mesclagem são outro exemplo. Eles aparecem quando equipes diferentes alteram o mesmo arquivo. Mas se o SRP for seguido, menos conflitos aparecerão - os arquivos terão um único motivo para serem alterados e os conflitos existentes serão mais fáceis de resolver.

Armadilhas comuns e antipadrões

Nesta seção, veremos alguns erros comuns que violam o Princípio da Responsabilidade Única. Em seguida, falaremos sobre algumas maneiras de corrigi-los.

Veremos o código de um programa de fatura de livraria simples como exemplo. Vamos começar definindo uma classe de livro para usar em nossa fatura.

class Book {	String name;	String authorName;	int year;	int price;	String isbn;	public Book(String name, String authorName, int year, int price, String isbn) {		this.name = name;		this.authorName = authorName;		this.year = year;        this.price = price;		this.isbn = isbn;	}}

Esta é uma aula de livro simples com alguns campos. Nada chique. Não estou tornando os campos privados para que não precisemos lidar com getters e setters e possamos nos concentrar na lógica.

Agora vamos criar a classe de nota fiscal que conterá a lógica de criação da nota fiscal e cálculo do preço total. Por enquanto, suponha que nossa livraria venda apenas livros e nada mais.

public class Invoice {	private Book book;	private int quantity;	private double discountRate;	private double taxRate;	private double total;	public Invoice(Book book, int quantity, double discountRate, double taxRate) {		this.book = book;		this.quantity = quantity;		this.discountRate = discountRate;		this.taxRate = taxRate;		this.total = this.calculateTotal();	}	public double calculateTotal() {	        double price = ((book.price - book.price * discountRate) * this.quantity);		double priceWithTaxes = price * (1 + taxRate);		return priceWithTaxes;	}	public void printInvoice() {            System.out.println(quantity + "x " + book.name + " " +          book.price + "$");            System.out.println("Discount Rate: " + discountRate);            System.out.println("Tax Rate: " + taxRate);            System.out.println("Total: " + total);	}        public void saveToFile(String filename) {	// Creates a file with given name and writes the invoice	}}

Aqui está nossa classe de fatura. Ele também contém alguns campos sobre faturamento e 3 métodos:

  • calcularTotal método, que calcula o preço total,
  • imprimir fatura método, que deve imprimir a fatura para o console, e
  • salvar em arquivo método, responsável por gravar a fatura em um arquivo.

Você deve se dar um segundo para pensar sobre o que há de errado com esse design de classe antes de ler o próximo parágrafo.

Ok, então o que está acontecendo aqui? Nossa classe viola o Princípio da Responsabilidade Única de várias maneiras.

A primeira violação é a imprimir fatura método, que contém nossa lógica de impressão. O SRP afirma que nossa classe deve ter apenas um único motivo para mudar, e esse motivo deve ser uma mudança no cálculo da fatura para nossa classe.

Mas nesta arquitetura, se quiséssemos mudar o formato de impressão, precisaríamos mudar a classe. É por isso que não devemos ter a lógica de impressão misturada com a lógica de negócios na mesma classe.

Existe outro método que viola o SRP em nossa classe: o salvar em arquivo método. Também é um erro extremamente comum misturar lógica de persistência com lógica de negócios.

Não pense apenas em termos de escrever em um arquivo – pode ser salvar em um banco de dados, fazer uma chamada de API ou outras coisas relacionadas à persistência.

Então, como podemos corrigir essa função de impressão, você pode perguntar.

Podemos criar novas classes para nossa impressão e lógica de persistência, portanto, não precisaremos mais modificar a classe de fatura para esses fins.

Criamos 2 classes, InvoicePrinter e InvoicePersistence, e mova os métodos.

public class InvoicePrinter {    private Invoice invoice;    public InvoicePrinter(Invoice invoice) {        this.invoice = invoice;    }    public void print() {        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");        System.out.println("Discount Rate: " + invoice.discountRate);        System.out.println("Tax Rate: " + invoice.taxRate);        System.out.println("Total: " + invoice.total + " $");    }}

public class InvoicePersistence {    Invoice invoice;    public InvoicePersistence(Invoice invoice) {        this.invoice = invoice;    }    public void saveToFile(String filename) {        // Creates a file with given name and writes the invoice    }}

Agora, nossa estrutura de classes obedece ao Princípio de Responsabilidade Única e cada classe é responsável por um aspecto de nossa aplicação. Ótimo!

Princípio aberto-fechado

O princípio aberto-fechado requer que as classes devem ser abertas para extensão e fechadas para modificação.

Modificação significa alterar o código de uma classe existente e extensão significa adicionar nova funcionalidade.

Portanto, o que este princípio quer dizer é: Devemos ser capazes de adicionar novas funcionalidades sem tocar no código existente para a classe. Isso porque sempre que modificamos o código existente, corremos o risco de criar possíveis bugs. Portanto, devemos evitar tocar no código de produção testado e confiável (principalmente), se possível.

Mas como vamos adicionar novas funcionalidades sem tocar na classe, você pode perguntar. Geralmente é feito com a ajuda de interfaces e classes abstratas.

Agora que cobrimos os fundamentos do princípio, vamos aplicá-lo ao nosso aplicativo de fatura.

Digamos que nosso chefe venha até nós e diga que deseja que as faturas sejam salvas em um banco de dados para que possamos pesquisá-las facilmente. Nós pensamos bem, isso é fácil, chefe fácil, me dê um segundo!

Criamos o banco de dados, nos conectamos a ele e adicionamos um método de salvamento ao nosso InvoicePersistence classe:

public class InvoicePersistence {    Invoice invoice;    public InvoicePersistence(Invoice invoice) {        this.invoice = invoice;    }    public void saveToFile(String filename) {        // Creates a file with given name and writes the invoice    }    public void saveToDatabase() {        // Saves the invoice to database    }}

Infelizmente, nós, como desenvolvedores preguiçosos da livraria, não projetamos as classes para serem facilmente estendidas no futuro. Portanto, para adicionar esse recurso, modificamos o InvoicePersistence classe.

Se nosso design de classe obedecesse ao princípio Aberto-Fechado, não precisaríamos alterar essa classe.

Portanto, como o desenvolvedor preguiçoso, mas inteligente, da livraria, vemos o problema de design e decidimos refatorar o código para obedecer ao princípio.

interface InvoicePersistence {    public void save(Invoice invoice);}

Mudamos o tipo de InvoicePersistence para Interface e adicione um método de salvamento. Cada classe de persistência implementará este método de salvamento.

public class DatabasePersistence implements InvoicePersistence {    @Override    public void save(Invoice invoice) {        // Save to DB    }}

public class FilePersistence implements InvoicePersistence {    @Override    public void save(Invoice invoice) {        // Save to file    }}

Portanto, nossa estrutura de classe agora se parece com isto:

SOLID Tutorial 1

Agora, nossa lógica de persistência é facilmente extensível. Se nosso chefe nos pedir para adicionar outro banco de dados e tiver 2 tipos diferentes de bancos de dados, como MySQL e MongoDB, podemos fazer isso facilmente.

Você pode pensar que poderíamos simplesmente criar várias classes sem uma interface e adicionar um método save para todas elas.

Mas digamos que estendamos nosso aplicativo e tenhamos várias classes de persistência como InvoicePersistence, BookPersistence e nós criamos um PersistenceManager classe que gerencia todas as classes de persistência:

public class PersistenceManager {    InvoicePersistence invoicePersistence;    BookPersistence bookPersistence;        public PersistenceManager(InvoicePersistence invoicePersistence,                              BookPersistence bookPersistence) {        this.invoicePersistence = invoicePersistence;        this.bookPersistence = bookPersistence;    }}

Agora podemos passar qualquer classe que implemente o InvoicePersistence interface para esta classe com a ajuda de polimorfismo. Essa é a flexibilidade que as interfaces fornecem.

Princípio de Substituição Liskov

O Princípio de Substituição de Liskov afirma que as subclasses devem ser substituíveis por suas classes básicas.

Isso significa que, dado que a classe B é uma subclasse da classe A, devemos ser capazes de passar um objeto da classe B para qualquer método que espere um objeto da classe A e o método não deve dar nenhuma saída estranha nesse caso.

Este é o comportamento esperado, porque quando usamos herança assumimos que a classe filha herda tudo o que a superclasse possui. A classe filha estende o comportamento, mas nunca o restringe.

Portanto, quando uma classe não obedece a esse princípio, isso leva a alguns bugs desagradáveis ​​que são difíceis de detectar.

O princípio de Liskov é fácil de entender, mas difícil de detectar no código. Então, vamos ver um exemplo.

class Rectangle {	protected int width, height;	public Rectangle() {	}	public Rectangle(int width, int height) {		this.width = width;		this.height = height;	}	public int getWidth() {		return width;	}	public void setWidth(int width) {		this.width = width;	}	public int getHeight() {		return height;	}	public void setHeight(int height) {		this.height = height;	}	public int getArea() {		return width * height;	}}

Temos uma classe Rectangle simples e um getArea função que retorna a área do retângulo.

Agora decidimos criar outra classe para Squares. Como você deve saber, um quadrado é apenas um tipo especial de retângulo onde a largura é igual à altura.

class Square extends Rectangle {	public Square() {}	public Square(int size) {		width = height = size;	}	@Override	public void setWidth(int width) {		super.setWidth(width);		super.setHeight(width);	}	@Override	public void setHeight(int height) {		super.setHeight(height);		super.setWidth(height);	}}

Nossa classe Square estende a classe Rectangle. Definimos altura e largura com o mesmo valor no construtor, mas não queremos que nenhum cliente (alguém que usa nossa classe em seu código) altere a altura ou o peso de uma forma que possa violar a propriedade square.

Portanto, substituímos os configuradores para definir as duas propriedades sempre que uma delas for alterada. Mas, ao fazer isso, violamos o princípio da substituição de Liskov.

Vamos criar uma classe principal para realizar testes no getArea função.

class Test {   static void getAreaTest(Rectangle r) {      int width = r.getWidth();      r.setHeight(10);      System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());   }   public static void main(String[] args) {      Rectangle rc = new Rectangle(2, 3);      getAreaTest(rc);      Rectangle sq = new Square();      sq.setWidth(5);      getAreaTest(sq);   }}

O testador de sua equipe acabou de criar a função de teste getAreaTest e diz que o seu getArea função não passa no teste de objetos quadrados.

No primeiro teste, criamos um retângulo onde a largura é 2 e a altura é 3 e chamamos getAreaTest. A saída é 20 conforme o esperado, mas as coisas dão errado quando passamos pelo quadrado. Isso ocorre porque a chamada para setHeight A função no teste também define a largura e resulta em uma saída inesperada.

Princípio de Segregação de Interface

Segregação significa manter as coisas separadas, e o Princípio de Segregação de Interface trata de separar as interfaces.

O princípio afirma que muitas interfaces específicas do cliente são melhores do que uma interface de uso geral. Os clientes não devem ser forçados a implementar uma função de que não precisam.

Este é um princípio simples de entender e aplicar, então vamos ver um exemplo.

public interface ParkingLot {	void parkCar();	// Decrease empty spot count by 1	void unparkCar(); // Increase empty spots by 1	void getCapacity();	// Returns car capacity	double calculateFee(Car car); // Returns the price based on number of hours	void doPayment(Car car);}class Car {}

Modelamos um estacionamento muito simplificado. É o tipo de estacionamento onde você paga uma taxa por hora. Agora considere que queremos implantar um estacionamento gratuito.

public class FreeParking implements ParkingLot {	@Override	public void parkCar() {			}	@Override	public void unparkCar() {	}	@Override	public void getCapacity() {	}	@Override	public double calculateFee(Car car) {		return 0;	}	@Override	public void doPayment(Car car) {		throw new Exception("Parking lot is free");	}}

Nossa interface de estacionamento era composta de 2 coisas: lógica relacionada ao estacionamento (estacionar, desestacionar, obter capacidade) e lógica relacionada ao pagamento.

Mas é muito específico. Por causa disso, nossa classe FreeParking foi forçada a implementar métodos de pagamento que são irrelevantes. Vamos separar ou segregar as interfaces.

SOLID Tutorial

Agora separamos o estacionamento. Com este novo modelo, podemos ir ainda mais longe e dividir o PaidParkingLot para suportar diferentes tipos de pagamento.

Agora nosso modelo é muito mais flexível, extensível e os clientes não precisam implementar nenhuma lógica irrelevante porque fornecemos apenas funcionalidades relacionadas ao estacionamento na interface do estacionamento.

Princípio de Inversão de Dependência

O princípio de Inversão de Dependência afirma que nossas classes devem depender de interfaces ou classes abstratas em vez de classes e funções concretas.

No dele artigo (2000), Tio Bob resume este princípio da seguinte forma:

“Se o OCP declara o objetivo da arquitetura OO, o DIP declara o mecanismo primário”.

Esses dois princípios estão de fato relacionados e já aplicamos esse padrão antes, enquanto discutíamos o princípio aberto-fechado.

Queremos que nossas classes sejam abertas à extensão, portanto, reorganizamos nossas dependências para depender de interfaces em vez de classes concretas. Nossa classe PersistenceManager depende de InvoicePersistence em vez das classes que implementam essa interface.

Conclusão

Neste artigo, começamos com a história dos princípios SOLID e, em seguida, tentamos adquirir uma compreensão clara dos por que e como cada princípio. Nós até mesmo refatoramos um aplicativo de fatura simples para obedecer aos princípios SOLID.

Quero agradecer a você por ter lido todo o artigo e espero que os conceitos acima estejam claros.

Sugiro manter esses princípios em mente ao projetar, escrever e refatorar seu código para que ele seja muito mais limpo, extensível e testável.

Se você estiver interessado em ler mais artigos como este, você pode se inscrever no meu do blog lista de discussão para ser notificado quando eu publicar um novo artigo.