Princípios SOLID: Deixe Seu Código Mais Limpo e Sustentável

Tempo de leitura: 16 minutos

Os princípios SOLID na programação orientada a objetos têm como seu principal propósito tornar nosso código mais compreensível, flexível e de fácil manutenção.

Além disso, esses princípios tornam a escrita do código mais legível e fácil de testar, facilitando também a colaboração entre desenvolvedores. E o mais importante, nos orienta na direção de uma codificação com boas práticas.

SOLID é um acrônimo criado por Michael Feathers, são um conjunto de regras de design de código baseadas em cinco princípios da orientação a objetos e do design de código. Eles foram inicialmente apresentados por Robert J. Martin, também conhecido como Uncle Bob, em seu trabalho lançado no ano 2000. A abreviação SOLID, no entanto, foi criada por Michael Feathers após observar que esses cinco princípios poderiam ser agrupados em uma única palavra. E estes princípios são:

S – Single Responsibility Principle (Princípio da responsabilidade única)
O – Open-Closed Principle (Princípio aberto/fechado)
L – Liskov Substitution Principle (Princípio da substituição de Liskov)
I – Interface Segregation Principle (Princípio da segregação da interface)
D – Dependency Inversion Principle (Princípio da inversão da dependência)

Neste artigo, irei aprofundar em cada um desses princípios e ilustrar seu funcionamento por meio de exemplos em TypeScript. Optei por utilizar uma linguagem amplamente reconhecida e que deve ser familiar para a maioria dos programadores de outras linguagens.

Princípio da Responsabilidade Única (SRP)

O Princípio da Responsabilidade Única, conhecido como SRP, afirma que uma classe deve ter uma única razão para sofrer mudanças. Isso significa que uma classe deve ter uma única função e realizar apenas uma tarefa específica.

Vamos dar uma olhada em um exemplo para esclarecer esse conceito. Muitas vezes, somos tentados a agrupar classes semelhantes, o que resulta em um código altamente acoplado. No entanto, essa abordagem, infelizmente, entra em conflito com o Princípio da Responsabilidade Única. Por quê?

O objeto ValidatePerson abaixo possui três métodos: dois métodos de validação (ValidateName() e ValidateAge()), e um método Display().

class ValidatePerson {
    private name: string;
    private age: number;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    private ValidateName(name: string): boolean {
        if (name.length > 3) {
            return true;
        } else {
            return false;
        }
    }

    private ValidateAge(age: number): boolean {
        if (age > 18) {
            return true;
        } else {
            return false;
        }
    }

    Display(): void {
        if (this.ValidateName(this.name) && this.ValidateAge(this.age)) {
            console.log(`Nome: ${this.name} e Idade: ${this.age}`);
        } else {
            console.log('Inválido');
        }
    }
}

Agora, vamos organizar cada elemento em seu devido lugar. Como resultado, a nova classe DisplayPerson terá a responsabilidade de exibir informações de uma pessoa, como pode ser observado no trecho de código a seguir:

class DisplayPerson {
    private name: string;
    private age: number;
    private validate: ValidatePerson;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
        this.validate = new ValidatePerson(this.name, this.age);
    }

    Display(): void {
        if (
            this.validate.ValidateName(this.name) &&
            this.validate.ValidateAge(this.age)
        ) {
            console.log(`Nome: ${this.name} e Idade: ${this.age}`);
        } else {
            console.log('Inválido');
        }
    }
}

Com isso, você terá cumprido o princípio da responsabilidade única, o que significa que suas classes agora têm apenas uma razão para mudar. Se você quiser alterar a classe DisplayPerson, isso não afetará a classe ValidatePerson.

Dessa forma, você estará cumprindo o princípio da responsabilidade única, garantindo que suas classes tenham apenas uma razão para serem modificadas. Se houver necessidade de fazer alterações na classe DisplayPerson, isso não afetará a classe ValidatePerson.

Princípio Aberto-Fechado (OCP)

O princípio aberto-fechado pode parecer confuso à primeira vista, pois envolve uma dualidade de conceitos. Conforme definido por Bertrand Meyer na Wikipedia, o princípio aberto-fechado (OCP) estabelece que entidades de software, como classes, módulos e funções, devem estar abertas para extensão, porém fechadas para modificação.

Esta definição pode ser confusa, mas um exemplo e uma explicação adicional ajudarão você a entender.

Existem dois atributos principais no OCP:

  1. Está aberto para extensão – Isso significa que você pode estender o que a classe pode fazer.
  2. Está fechado para modificação – Isso significa que você não pode alterar o código, mesmo que possa estender o comportamento de uma classe.

O OCP significa que uma classe, módulo, função e outras entidades podem estender seu comportamento sem modificar seu código. Em outras palavras, uma entidade deve ser extensível sem modificar a própria entidade. Como?

Por exemplo, imagine que você tenha uma lista de sabores de sorvete que contenha as opções disponíveis. Na classe MakeIceCream, há um método make() que verifica a existência de um sabor específico e exibe uma mensagem correspondente.

const iceCreamFlavors: string[] = ['chocolate', 'baunilha'];

class MakeIceCream {
    constructor(private sabor: string) {}

    make(): void {
        if (iceCreamFlavors.indexOf(this.sabor) > -1) {
            console.log('Sucesso. Você agora tem sorvete.');
        } else {
            console.log('Fracasso. Nenhum sorvete para você.');
        }
    }
}

O código acima não segue o princípio OCP. Por quê? Bem, porque o código não está preparado para ser estendido, o que significa que, para adicionar novos sabores, você seria obrigado a editar diretamente a listaiceCreamFlavors. Isso, por sua vez, significa que o código não está respeitando o princípio de ser fechado para modificações.

Para resolver essa questão, seria necessário introduzir uma classe ou entidade adicional responsável pela adição de sabores, para que você não precise mais modificar o código diretamente para realizar qualquer extensão.

const iceCreamFlavors: string[] = ['chocolate', 'baunilha'];

class MakeIceCream {
    constructor(private sabor: string) {}

    make(): void {
        if (iceCreamFlavors.indexOf(this.sabor) > -1) {
            console.log('Sucesso. Você agora tem sorvete.');
        } else {
            console.log('Fracasso épico. Nenhum sorvete para você.');
        }
    }
}

class AddIceCream {
    constructor(private sabor: string) {}

    add(): void {
        iceCreamFlavors.push(this.sabor);
    }
}

Aqui, incluimos uma nova classe chamada addIceCream para gerenciar a adição de sabores à lista iceCreamFlavors utilizando o método add(). Isso faz com que o seu código esteja alinhado com o princípio OCP, pois ele se torna fechado para modificações diretas, mas aberto para extensões, permitindo que novos sabores sejam adicionados sem impactar diretamente na lista.

const makeIceCream = new MakeIceCream('morango');
const addMorango = new AddIceCream('morango');

addMorango.add();
makeIceCream.make();

Além disso, perceba que o SRP está realmente em vigor, pois você criou uma nova classe.

Princípio da Substituição de Liskov (LSP)

Em 1987, o Princípio da Substituição de Liskov (LSP) foi apresentado por Barbara Liskov durante sua palestra na conferência “Abstração de Dados”. Algum tempo depois, ela definiu o princípio da seguinte maneira:

“Se q(x) é uma propriedade demonstrável dos objetos x de tipo T. Então q(y) deve ser verdadeiro para objetos y de tipo S onde S é um subtipo de T”.

O princípio afirma que, em herança, objetos de uma classe pai devem ser substituíveis por objetos de suas classes filhas sem causar problemas na aplicação.

De forma simplificada, queremos que objetos das classes filhas se comportem da mesma forma que os objetos da classe pai.

Um exemplo comum é o relacionamento entre retângulos e quadrados. Todos os quadrados são retângulos, pois ambos são quadriláteros com ângulos retos. No entanto, nem todo retângulo é um quadrado; para ser considerado um quadrado, os lados devem ter o mesmo comprimento.

Tendo isso em mente, suponha que você tenha uma classe Rectangle para calcular a área de um retângulo e realizar outras operações, como definir a cor:

class Rectangle {
    protected width: number | undefined;
    protected height: number | undefined;
    protected color: string | undefined;

    setWidth(width: number): void {
        this.width = width;
    }

    setHeight(height: number): void {
        this.height = height;
    }

    setColor(color: string): void {
        this.color = color;
    }

    getArea(): number | undefined {
        if (this.width !== undefined && this.height !== undefined) {
            return this.width * this.height;
        }
        return undefined;
    }
}

Sabendo plenamente que todos os quadrados são retângulos, você pode herdar as propriedades do retângulo. Como a largura e a altura devem ser iguais, você pode ajustar:

class Square extends Rectangle {
    setWidth(width: number): void {
        this.width = width;
        this.height = width;
    }

    setHeight(height: number): void {
        this.width = height;
        this.height = height;
    }
}

Dando uma olhada no exemplo, ele deve funcionar corretamente:

let retangulo = new Rectangle();
retangulo.setWidth(10);
retangulo.setHeight(5);
console.log(retangulo.getArea()); // 50

Mas de acordo com o LSP, você deseja que os objetos de suas subclasses (filha) se comportem da mesma maneira que os objetos de sua superclasse (pai). Isso significa que, se você substituir o Rectangle pelo Square, tudo ainda deve funcionar bem:

let quadrado = new Square();
quadrado.setWidth(10);
quadrado.setHeight(5);

Mas primeiramente você deve obter 100, porque o setWidth(10) foi configurado com 10. Mas em seguida por causa do setHeight(5), isso retornará 25.

let quadrado = new Square();
quadrado.setWidth(10);
quadrado.setHeight(5);
console.log(quadrado.getArea()); // 25

No entanto, esse código viola o Princípio de Substituição de Liskov (LSP). No caso em questão, a classe Square redefine o método setWidth() da classe Rectangle. Isso significa que, se você substituir um objeto Rectangle por um objeto Square, o comportamento do programa pode mudar inesperadamente.

Para corrigir essa violação, podemos mover o método setColor() para uma classe base comum. Dessa forma, tanto a classe Rectangle quanto a classe Square poderão herdar o método setColor(). Em seguida, você cria uma classe individual para retângulo e quadrado.

class Shape {
    private color: string | undefined;

    setColor(color: string): void {
        this.color = color;
    }

    getColor(): string | undefined {
        return this.color;
    }
}

class Rectangle extends Shape {
    private width: number | undefined;
    private height: number | undefined;

    setWidth(width: number): void {
        this.width = width;
    }

    setHeight(height: number): void {
        this.height = height;
    }

    getArea(): number | undefined {
        if (this.width !== undefined && this.height !== undefined) {
            return this.width * this.height;
        }
        return undefined;
    }
}

class Square extends Shape {
    private side: number | undefined;

    setSide(side: number): void {
        this.side = side;
    }

    getArea(): number | undefined {
        if (this.side !== undefined) {
            return this.side * this.side;
        }
        return undefined;
    }
}

Dessa forma, você pode definir a cor e obter a cor usando tanto a superclasse quanto as subclasses, mantendo a consistência no comportamento esperado. Isso garante que, ao substituir objetos da superclasse por objetos de suas subclasses, a aplicação continuará a funcionar conforme o esperado, aderindo assim ao Princípio de Substituição de Liskov (LSP).

// superclasse
let forma = new Shape();
forma.setColor('vermelho');
console.log(forma.getColor()); // vermelho

// subclasse
let retangulo = new Rectangle();
retangulo.setColor('vermelho');
console.log(retangulo.getColor()); // vermelho

// subclasse
let quadrado = new Square();
quadrado.setColor('vermelho');
console.log(quadrado.getColor()); // vermelho

Princípio da Segregação de Interfaces (ISP)

O Princípio da Segregação de Interfaces (ISP) afirma que “um cliente nunca deve ser forçado a implementar uma interface que ele não usa, ou os clientes não devem ser forçados a depender de métodos que eles não usam”. O que isso significa?

Assim como o termo segregação sugere – isso se trata de manter as coisas separadas, ou seja, separar as interfaces.

Uma interface típica conterá métodos e propriedades. Quando você implementa essa interface em qualquer classe, a classe precisa definir todos os métodos, mesmo que você não os use ou não se apliquem a essa classe.

interface ShapeInterface {
    calculateArea();
    calculateVolume();
}

Quando uma classe implementa essa interface, todos os métodos devem ser definidos, mesmo que você não os use ou não se apliquem a essa classe.

class Square implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //Não é possível
    }  
}

class Cuboid implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }    
}

class Rectangle implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //Não é possível
    }   
}

Olhando o exemplo acima, você notará que não é possível calcular o volume de um quadrado ou retângulo. Como a classe implementa a interface, você precisa definir todos os métodos, mesmo que não vá usá-los.

Para corrigir isso, você precisaria segregar a interface.

interface ShapeInterface {
    calculateArea();
}

interface ThreeDimensionalShapeInterface {
    calculateArea();
    calculateVolume();
}

Agora você pode implementar a interface específica que funciona com cada classe.

class Square implements ShapeInterface {
    calculateArea(){
        //...
    } 
}

class Cuboid implements ThreeDimensionalShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }    
}

class Rectangle implements ShapeInterface {
    calculateArea(){
        //...
    }  
}

Dessa forma, você pode implementar a interface específica que funciona com cada classe.

Princípio da Inversão de Dependência (DIP)

Esse princípio tem como objetivo acoplar de forma fraca os módulos de software, para que módulos de alto nível (que fornecem lógica complexa) sejam facilmente reutilizáveis e não afetados por mudanças nos módulos de baixo nível (que fornecem recursos de utilidade).

De acordo com a Wikipedia, esse princípio afirma que:

  • Módulos de alto nível não devem importar nada dos módulos de baixo nível. Ambos devem depender de abstrações (por exemplo, interfaces).
  • Abstrações devem ser independentes de detalhes. Detalhes (implementações concretas) devem depender de abstrações.

Em termos simples, esse princípio afirma que suas classes devem depender de interfaces ou classes abstratas em vez de classes concretas e funções concretas. Isso torna suas classes abertas para extensão, seguindo o princípio aberto-fechado.

Vamos olhar um exemplo. Ao construir uma loja, você desejará que sua loja utilize um gateway de pagamento genérico como PayNow ou qualquer outro método de pagamento preferido. Você pode escrever seu código fortemente acoplado a essa API sem pensar no futuro.

Mas e se você descobrir outro gateway de pagamento que oferece um serviço muito melhor, como o PayPal? Então, se torna um problema mudar do PayNow para o PayPal, o que não deveria ser um problema em programação e design de software.

class Store {
    private payNow: PayNow;

    constructor(private user: string) {
        this.payNow = new PayNow(user);
    }

    purchaseBook(quantity: number, price: number): void {
        this.payNow.makePayment(price * quantity);
    }

    purchaseCourse(quantity: number, price: number): void {
        this.payNow.makePayment(price * quantity);
    }
}

class PayNow {
    constructor(private user: string) {}

    makePayment(amountInDollars: number): void {
        console.log(`${this.user} fez um pagamento de ${amountInDollars}`);
    }
}

Como podemos ver no exemplo acima, ao mudar o gateway de pagamento, você irá precisar adicionar uma nova classe, mas irá alterar a classe Store. Isso além de ferir o Princípio da Inversão de Dependência, também vai contra o princípio aberto-fechado.

Para corrigir isso, você deve garantir que suas classes dependam de interfaces ou classes abstratas em vez de classes concretas. Para este exemplo, essa interface conterá todo o comportamento que você deseja que seu API tenha e não dependerá de nada. Ela serve como intermediário entre os módulos de alto e baixo nível.

class Store {
    private paymentProcessor: PaymentProcessor;

    constructor(paymentProcessor: PaymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    purchaseBook(quantity: number, price: number): void {
        this.paymentProcessor.pay(quantity * price);
    }

    purchaseCourse(quantity: number, price: number): void {
        this.paymentProcessor.pay(quantity * price);
    }
}

class PayNowPaymentProcessor implements PaymentProcessor {
    private user: string;
    private payNow: PayNow;

    constructor(user: string) {
        this.user = user;
        this.payNow = new PayNow();
    }

    pay(amountInDollars: number): void {
        this.payNow.makePayment(this.user, amountInDollars);
    }
}

class PayNow {
    makePayment(user: string, amountInDollars: number): void {
        console.log(`${user} fez um pagamento de ${amountInDollars}`);
    }
}

interface PaymentProcessor {
    pay(amountInDollars: number): void;
}

let store = new Store(new PayNowPaymentProcessor('John Doe'));
store.purchaseBook(2, 10);
store.purchaseCourse(1, 15);

No código acima, você notará que a classe PayNowPaymentProcessor é uma interface entre a classe Store e a classe PayNow. Em uma situação em que você precise usar o PayPal, tudo o que você precisa fazer é criar um PayPalPaymentProcessor que funcione com a classe PayPal, e tudo funcionará sem afetar a classe Store.

class Store {
    private paymentProcessor: PaymentProcessor;

    constructor(paymentProcessor: PaymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    purchaseBook(quantity: number, price: number): void {
        this.paymentProcessor.pay(quantity * price);
    }

    purchaseCourse(quantity: number, price: number): void {
        this.paymentProcessor.pay(quantity * price);
    }
}

class PayPalPaymentProcessor implements PaymentProcessor {
    private user: string;
    private paypal: PayPal;

    constructor(user: string) {
        this.user = user;
        this.paypal = new PayPal();
    }

    pay(amountInDollars: number): void {
        this.paypal.makePayment(this.user, amountInDollars);
    }
}

class PayPal {
    makePayment(user: string, amountInDollars: number): void {
        console.log(`${user} fez um pagamento de ${amountInDollars} via PayPal`);
    }
}

interface PaymentProcessor {
    pay(amountInDollars: number): void;
}

const store = new Store(new PayPalPaymentProcessor('Jane Doe'));
store.purchaseBook(3, 12);
store.purchaseCourse(2, 20);

Esse é um exemplo simples, mas ilustra bem o Princípio da Inversão de Dependência, onde você evita que as classes de alto nível dependam das classes de baixo nível diretamente, tornando suas classes mais flexíveis e fáceis de estender e manter.

Espero que este guia tenha sido útil para você na sua jornada de desenvolvimento de software. Entender e aplicar os princípios SOLID da programação orientada a objetos pode ser um verdadeiro divisor de águas para melhorar a qualidade do seu código.

Lembre-se de que esses princípios não são apenas regras abstratas, mas ferramentas práticas que podem facilitar muito o seu trabalho e tornar sua vida como desenvolvedor mais tranquila. Ao seguir a Responsabilidade Única, o Aberto/Fechado, a Substituição de Liskov, a Segregação de Interfaces e a Inversão de Dependência, você está criando uma base sólida para um código mais limpo, flexível e sustentável.

A beleza desses princípios está na sua aplicabilidade universal. Não importa em qual linguagem você programa, ou qual tipo de projeto está desenvolvendo, eles são valiosos em qualquer cenário.

Lembre-se, você não está apenas escrevendo código para máquinas, mas também para colegas de equipe e futuros desenvolvedores. E um código bem estruturado é um presente que continua dando. Então, continue aplicando esses princípios e assista como seu código evolui para se tornar mais robusto e fácil de manter.

Boa sorte em sua jornada de desenvolvimento, e lembre-se de que aprender e melhorar continuamente é o que nos torna grandes desenvolvedores. Continue codificando! 👨‍💻🚀