DEV Community

Cover image for SOLID - Princípios da Programação Orientada a Objetos (OOP)
William Koller
William Koller

Posted on

SOLID - Princípios da Programação Orientada a Objetos (OOP)

Princípios de Design

Bons sistemas de software começam com um código limpo. Por um lado, se os tijolos não são bem-feitos, a arquitetura de construção perde a importância. Por outro lado, você pode fazer uma bagunça considerável com tijolos bem-feitos. É aí que entra, os princípios SOLID - Robert C. Martin

Os princípios SOLID orientam a organização das funções e estruturas de dados de uma classe e como essas classes devem ser interconectadas. O uso do termo 'classe' não limita a aplicação dos princípios apenas a softwares orientados a objetos; qualquer agrupamento acoplado de funções e dados em um sistema de software pode se beneficiar deles. O objetivo dos princípios SOLID é criar estruturas de software de nível médio que:

  • Tolerem mudanças,
  • Sejam fáceis de entender,
  • Sirvam como base para componentes reutilizáveis em muitos sistemas de software.

História

A história dos princípios SOLID é extensa. No final dos anos 1980, foram discutidos em reuniões sobre princípios de design com participantes da USENET (um precursor das redes sociais modernas). Ao longo dos anos, os princípios evoluíram: alguns foram eliminados, outros mesclados ou adicionados. O conjunto final se estabilizou no início dos anos 2000, embora a ordem de apresentação tenha variado. Em 2004, Michael Feathers formalizou os princípios em um e-mail, observando que, se Robert C. Martin reorganizasse os princípios, suas iniciais formariam a palavra SOLID - e assim nasceram os princípios SOLID.

Iremos falar cada palavra SOLID, abaixo.

SRP: Princípio de Responsabilidade Única (Single Responsability Principle)

O Princípio de Responsabilidade Única (SRP) afirma que uma classe deve ter apenas uma razão para mudar, ou seja, ela deve ter apenas uma responsabilidade ou tarefa. Isso significa que cada classe deve focar em uma única parte da funcionalidade do software, sendo responsável apenas por uma parte do comportamento do sistema. Isso facilita a manutenção, testes e entendimento do código, além de melhorar a coesão e reduzir o acoplamento entre as partes do sistema.

OCP: O Princípio de Aberto/Fechado (Open/Closed Principle)

O Princípio de Aberto/Fechado (OCP) afirma que uma entidade de software (como uma classe, módulo ou função) deve estar aberta para extensão, mas fechada para modificação. Isso significa que o comportamento de uma classe pode ser estendido sem alterar seu código-fonte original. A ideia é permitir que novas funcionalidades sejam adicionadas ao sistema sem a necessidade de alterar o código existente, reduzindo o risco de introduzir novos bugs e facilitando a manutenção e evolução do software.

LSP: Princípoir de Substituição de Liskov (Liskob Substitution Principle)

O Princípio de Substituição de Liskov (LSP) afirma que uma subclasse deve ser substituível por sua superclasse sem alterar o comportamento desejado do programa. Em outras palavras, objetos de uma classe derivada devem poder ser usados no lugar de objetos da classe base sem que o programa funcione de maneira incorreta. Isso garante que as subclasses preservem a integridade do sistema, respeitando o contrato estabelecido pela superclasse, o que facilita a reutilização e a manutenção do código.

ISP: Princípio de Segregação de Interface (Interface Segregation Principle)

O Princípio de Segregação de Interface (ISP) afirma que uma classe não deve ser obrigada a implementar interfaces que não utiliza. Em vez de ter uma única interface grande, é melhor ter várias interfaces menores e específicas, para que as classes possam implementar apenas o que realmente precisam. Isso reduz o acoplamento entre componentes e aumenta a coesão, facilitando a manutenção e evolução do software.

DIP: Princípio da Inversão de Dependência (Dependency Inversion Principle)

O Princípio da Inversão de Dependência (DIP) afirma que módulos de alto nível não devem depender de módulos de baixo nível, mas ambos devem depender de abstrações. Além disso, abstrações não devem depender de detalhes; os detalhes devem depender de abstrações. Isso significa que a estrutura do código deve ser orientada a interfaces ou classes abstratas, permitindo que a implementação concreta possa ser alterada sem afetar os módulos que utilizam essas abstrações. Isso promove um código mais flexível, reutilizável e de fácil manutenção.

Iremos mostrar uns exemplos em código de cada palavra SOLID, abaixo.

SRP: Princípio de Responsabilidade Única (Single Responsability Principle)

class Order {
  public items: Item[];

  constructor() {
    this.items = [];
  }

  addItem(item: Item) {
    this.items.push(item);
  }

  calculateTotal() {
    let total = 0;
    for (const item of this.items) {
      total += item.price;
    }
    return total;
  }
}

class Item {
  name: string;
  price: number;

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

class OrderPrinter {
  print(order: Order) {
    console.log('Order Details:');
    for (const item of order.items) {
      console.log(`- ${item.name}: $${item.price}`);
    }
    console.log(`Total: $${order.calculateTotal()}`);
  }
}

const order = new Order();
order.addItem(new Item('Shirt', 20)); // - Shirt: $20

order.addItem(new Item('Pants', 50)); // - Pants: $50

order.addItem(new Item('Shoes', 100)); // - Shoes: $100

order.addItem(new Item('Hat', 10)); // - Hat: $10

const orderPrinter = new OrderPrinter();
orderPrinter.print(order); // Total: $180
Enter fullscreen mode Exit fullscreen mode

O Princípio de Responsabilidade Única (SRP) estabelece que uma classe deve ter apenas uma razão para mudar, ou seja, deve possuir uma única responsabilidade ou tarefa.

Como o código respeita o SRP

Responsabilidade de Gerenciamento de Itens:
  • A classe Order tem uma responsabilidade clara: gerenciar os itens de um pedido.

  • O método addItem permite adicionar itens ao pedido. Sua única responsabilidade é modificar a lista de itens (this.items) dentro do objeto Order.

Responsabilidade de Cálculo do Total:

O método calculateTotal é responsável por calcular o total do pedido somando os preços dos itens na lista.
Ele não está alterando o estado do objeto Order, apenas calculando e retornando um valor com base nos itens já existentes.

Ambos os métodos (addItem e calculateTotal) estão diretamente relacionados à principal responsabilidade da classe Order, que é gerenciar os itens do pedido e realizar operações relacionadas a esses itens.
Justificativa de Conformidade com o SRP
Coesão: A classe Order possui alta coesão, já que todos os métodos e propriedades estão diretamente relacionados ao gerenciamento de um pedido.
Facilidade de Mudança: Se houver uma mudança na forma como os itens são gerenciados ou como o total é calculado, essas mudanças serão confinadas a esta única classe. Por exemplo, se a lógica de cálculo do total mudar (por exemplo, descontos ou taxas), apenas o método calculateTotal precisa ser atualizado.
Clareza: A classe Order é fácil de entender porque sua responsabilidade é única e bem definida. Qualquer desenvolvedor pode olhar para essa classe e compreender rapidamente que ela serve para gerenciar itens de um pedido e calcular o total desses itens.

Portanto, a classe Order demonstra uma aplicação clara do Princípio de Responsabilidade Única (SRP), mantendo uma única responsabilidade e tornando o código mais fácil de manter, entender e modificar.

OCP: O Princípio de Aberto/Fechado (Open/Closed Principle)

interface Shape {
  calculateArea(): number;
}

class Rectangle implements Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  calculateArea(): number {
    return this.width * this.height;
  }
}

class Circle implements Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

class AreaCalculator {
  private shapes: Shape[];

  constructor(shapes: Shape[]) {
    this.shapes = shapes;
  }

  calculateTotalArea(): number {
    let totalArea = 0;
    for (const shape of this.shapes) {
      totalArea += shape.calculateArea();
    }
    return totalArea;
  }
}

const rectangle = new Rectangle(5, 10);
const circle = new Circle(7);

const areaCalculator = new AreaCalculator([rectangle, circle]);
const totalArea = areaCalculator.calculateTotalArea();
console.log("Total area:", totalArea); // Total area: 203.93804002589985T
Enter fullscreen mode Exit fullscreen mode

O Princípio de Aberto/Fechado (OCP) afirma que uma entidade de software deve estar aberta para extensão, mas fechada para modificação. Isso significa que o comportamento de uma classe pode ser estendido sem alterar seu código-fonte original.
Como o código respeita o OCP
Interface Shape:
Define um contrato (calculateArea) que todas as formas (shapes) devem implementar. Isso permite que novas formas possam ser adicionadas sem modificar a interface ou as classes existentes.

Classes Rectangle e Circle:
Ambas implementam a interface Shape, fornecendo suas próprias implementações de calculateArea.
Se quisermos adicionar uma nova forma (por exemplo, Triangle), podemos criar uma nova classe que implemente Shape sem modificar as classes Rectangle ou Circle.

Classe AreaCalculator:
Aceita uma lista de objetos que implementam a interface Shape.
O método calculateTotalArea itera sobre essa lista e calcula a área total chamando calculateArea em cada forma.
Se novas formas forem adicionadas, não é necessário modificar AreaCalculator, pois ela depende da abstração Shape.

Justificativa de Conformidade com o OCP
Extensibilidade: O design permite a adição de novas formas sem modificar o código existente. Para adicionar uma nova forma, basta criar uma nova classe que implemente a interface Shape.
Evita Modificações: O código existente das classes Rectangle, Circle e AreaCalculator permanece inalterado quando novas formas são adicionadas, minimizando o risco de introdução de novos bugs.
Uso de Abstrações: A classe AreaCalculator trabalha com a abstração Shape em vez de classes concretas, promovendo a flexibilidade e aderência ao OCP.

class Triangle implements Shape {
  private base: number;
  private height: number;

  constructor(base: number, height: number) {
    this.base = base;
    this.height = height;
  }

  calculateArea(): number {
    return 0.5 * this.base * this.height;
  }
}

const triangle = new Triangle(10, 5);
const areaCalculatorWithTriangle = new AreaCalculator([rectangle, circle, triangle]);
const newTotalArea = areaCalculatorWithTriangle.calculateTotalArea();
console.log("New total area:", newTotalArea); // Calcula a área total incluindo o triângulo
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, adicionar uma nova forma (Triangle) não requer nenhuma modificação nas classes Rectangle, Circle ou AreaCalculator, demonstrando claramente a aplicação do Princípio de Aberto/Fechado (OCP).

LSP: Princípoir de Substituição de Liskov (Liskob Substitution Principle)

abstract class BaseNotification {
  abstract send(message: string): void;
}

class EmailNotification extends BaseNotification {
  constructor(private emailAddress: string) {
    super();
  }
  send(message: string): void {
    console.log(`Sending email to ${this.emailAddress}: ${message}`);
  }
}

class SMSNotification extends BaseNotification {
  constructor(private phoneNumber: string) {
    super();
  }

  send(message: string): void {
    console.log(`Sending SMS to ${this.phoneNumber}: ${message}`);
  }
}

class PushNotification extends BaseNotification {
  constructor(private deviceToken: string) {
    super();
  }

  send(message: string): void {
    console.log(`Sending push notification to ${this.deviceToken}: ${message}`);
  }
}

function notifyUser(baseNotification: BaseNotification, message: string) {
  baseNotification.send(message);
}

const emailNotification = new EmailNotification('user@example.com');
const smsNotification = new SMSNotification('123-456-7890');
const pushNotification = new PushNotification('device-token-123');

notifyUser(emailNotification, 'Your order has been shipped!'); // Sending email to user@example.com: Your order has been shipped!
notifyUser(smsNotification, 'Your order has been shipped!'); // Sending SMS to 123-456-7890: Your order has been shipped!
notifyUser(pushNotification, 'Your order has been shipped!'); // Sending push notification to device-token-123: Your order has been shipped!
Enter fullscreen mode Exit fullscreen mode

O Princípio de Substituição de Liskov (LSP) afirma que uma subclasse deve ser substituível por sua superclasse sem alterar o comportamento desejado do programa. Em outras palavras, objetos de uma classe derivada devem poder ser usados no lugar de objetos da classe base sem que o programa funcione de maneira incorreta.
Como o código respeita o LSP
Classe Base Abstrata BaseNotification:
Define o método abstrato send, que deve ser implementado por todas as subclasses.
Fornece um contrato comum para todas as notificações, garantindo que qualquer tipo de notificação possa ser enviada usando o método send.

Subclasses EmailNotification, SMSNotification e PushNotification:
Cada uma dessas classes herda de BaseNotification e implementa o método send de forma específica para o tipo de notificação.
Elas podem ser usadas de forma intercambiável no contexto de BaseNotification.

Função notifyUser:
Aceita um objeto de BaseNotification e uma mensagem, chamando o método send do objeto passado.
Como todas as subclasses de BaseNotification implementam send, notifyUser pode aceitar qualquer uma das subclasses sem precisar de modificações.

Justificativa de Conformidade com o LSP
Substituibilidade: A função notifyUser pode receber qualquer instância de BaseNotification, seja ela EmailNotification, SMSNotification ou PushNotification, e chamará o método send sem problemas. Isso demonstra que as subclasses são substituíveis pela superclasse sem alterar o comportamento da função.
Consistência de Comportamento: Cada subclasse implementa o método send de acordo com suas próprias necessidades, mas a interface de uso (BaseNotification) permanece consistente. Isso garante que o comportamento do programa não se altere ao substituir uma instância de BaseNotification por qualquer uma de suas subclasses.
Polimorfismo: O uso de polimorfismo é evidente, pois a função notifyUser não precisa saber qual tipo específico de notificação está sendo enviada. Ela apenas chama send no objeto BaseNotification, confiando que o comportamento adequado será executado pela implementação concreta da subclasse.

Adicionar uma nova forma de notificação, como SlackNotification, é simples e demonstra a conformidade com o LSP:

class SlackNotification extends BaseNotification {
  constructor(private slackChannel: string) {
    super();
  }

  send(message: string): void {
    console.log(`Sending Slack message to ${this.slackChannel}: ${message}`);
  }
}

const slackNotification = new SlackNotification('#general');
notifyUser(slackNotification, 'Your order has been shipped!'); // Sending Slack message to #general: Your order has been shipped!
Enter fullscreen mode Exit fullscreen mode

ISP: Princípio de Segregação de Interface (Interface Segregation Principle)

interface PaymentService {
  processPayment(amount: number): void;
}

class CreditCardPaymentService implements PaymentService {
  processPayment(amount: number): void {
    console.log(`Processando pagamento de R$${amount} com cartão de crédito...`);
  }
}

class BoletoPaymentService implements PaymentService {
  processPayment(amount: number): void {
    console.log(`Processando pagamento de R$${amount} com boleto bancário...`);
  }
}

class Purchase {
  private paymentService: PaymentService;

  constructor(paymentService: PaymentService) {
    this.paymentService = paymentService;
  }

  processPurchase(amount: number): void {
    console.log(`Processando compra de R$${amount}...`);
    this.paymentService.processPayment(amount);
    console.log('Compra concluída!');
  }
}

const creditCardPaymentService = new CreditCardPaymentService(); // Processando pagamento de R$100 com cartão de crédito...
const purchaseWithCreditCard = new Purchase(creditCardPaymentService); // Processando compra de R$100...
purchaseWithCreditCard.processPurchase(100);

const boletoPaymentService = new BoletoPaymentService(); // Processando pagamento de R$200 com boleto bancário...
const purchaseWithBoleto = new Purchase(boletoPaymentService); // Processando compra de R$200...
purchaseWithBoleto.processPurchase(200);
Enter fullscreen mode Exit fullscreen mode

O Princípio de Segregação de Interface (ISP) afirma que uma classe não deve ser obrigada a implementar interfaces que não utiliza. Isso significa que é melhor ter várias interfaces pequenas e específicas do que uma única interface grande e genérica, permitindo que as classes implementem apenas o que realmente precisam.
Como o código respeita o ISP
Interface PaymentService:
Define um contrato específico para o serviço de pagamento com o método processPayment. Esta interface é pequena e focada, contendo apenas o que é necessário para o processamento de pagamentos.
Como a interface é específica para pagamentos, as classes que a implementam não são forçadas a adicionar métodos desnecessários.

Classes CreditCardPaymentService e BoletoPaymentService:
Ambas implementam a interface PaymentService e fornecem sua própria implementação do método processPayment.
Cada classe se concentra exclusivamente em como processar um tipo específico de pagamento (cartão de crédito ou boleto), mantendo a interface e a implementação focadas e coesas.

Classe Purchase:
Recebe uma instância de PaymentService e usa o método processPayment para processar o pagamento.
Não precisa saber ou se preocupar com os detalhes de como o pagamento é processado; isso é abstraído pela interface PaymentService.

Justificativa de Conformidade com o ISP
Interfaces Pequenas e Específicas: A interface PaymentService é pequena e específica, focando apenas no que é necessário para o processamento de pagamentos. As classes CreditCardPaymentService e BoletoPaymentService implementam exatamente o que é exigido pela interface, sem adicionar métodos desnecessários.
Não Forçado a Implementar Métodos Não Usados: As classes que implementam PaymentService não são forçadas a adicionar métodos que não são relevantes para o tipo específico de pagamento que elas processam. Isso evita que as classes se tornem complexas e difíceis de manter.
Flexibilidade e Manutenção: O design permite adicionar novos tipos de serviços de pagamento no futuro (por exemplo, pagamentos via PayPal ou transferência bancária) simplesmente implementando a interface PaymentService. Não há necessidade de modificar a interface ou as classes existentes, mantendo o sistema flexível e fácil de manter.

Exemplo de Extensão
Se quisermos adicionar um novo método de pagamento, como PayPalPaymentService, podemos seguir o mesmo padrão:

class PayPalPaymentService implements PaymentService {
  processPayment(amount: number): void {
    console.log(`Processando pagamento de R$${amount} com PayPal...`);
  }
}

const payPalPaymentService = new PayPalPaymentService();
const purchaseWithPayPal = new Purchase(payPalPaymentService);
purchaseWithPayPal.processPurchase(300); // Processando compra de R$300... Processando pagamento de R$300 com PayPal... Compra concluída!
Enter fullscreen mode Exit fullscreen mode

DIP: Princípio da Inversão de Dependência (Dependency Inversion Principle)

import nodemailer from 'nodemailer';
import { EnvEmail } from './config/env-email';

interface EmailService {
  sendEmail(from: string, to: string, subject: string, body: string): void;
}

class NodemailerEmailService implements EmailService {
  private transporter: nodemailer.Transporter;

  constructor() {
    this.transporter = nodemailer.createTransport({
      service: EnvEmail.SMTP_HOST,
      auth: {
        user: EnvEmail.SMTP_USER,
        pass: EnvEmail.SMTP_PASS,
      },
    });
  }

  async sendEmail(
    from: string,
    to: string,
    subject: string,
    body: string
  ): Promise<void> {
    const mailOptions: nodemailer.SendMailOptions = {
      from,
      to,
      subject,
      text: body,
    };

    try {
      const info = await this.transporter.sendMail(mailOptions);
      console.log('Email sent:', info);
    } catch (error) {
      if (error instanceof Error) {
        console.error('Error sending email:', error.message);
        if (error.message.includes('Authentication')) {
          console.error(
            'Authentication error. Please check your SMTP credentials.'
          );
        } else {
          console.error('Non-critical error occurred. Retrying...');
        }
      } else {
        console.error('Unexpected error sending email:', error);
      }
      throw error;
    }
  }
}

class EmailSender {
  private emailService: EmailService;

  constructor(emailService: EmailService) {
    this.emailService = emailService;
  }

  sendEmail(from: string, to: string, subject: string, body: string): void {
    this.emailService.sendEmail(from, to, subject, body);
  }
}

const emailService = new NodemailerEmailService();
const emailSender = new EmailSender(emailService);

emailSender.sendEmail(
  EnvEmail.SMTP_USER,
  'williamkoller30@gmail.com',
  'Hello',
  'This is a test email'
);
Enter fullscreen mode Exit fullscreen mode

O Princípio da Inversão de Dependência (DIP) afirma que:
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.
Em termos simples, isso significa que as classes e módulos de alto nível (como lógica de negócios) devem depender de interfaces ou abstrações, e não de implementações concretas. As implementações concretas devem depender dessas abstrações, permitindo que as partes do sistema sejam mais flexíveis e menos acopladas.
Como o código respeita o DIP
Interface EmailService:
Define um contrato para o serviço de envio de emails com o método sendEmail. Isso permite que qualquer classe que implemente essa interface possa ser usada para enviar emails, sem que a classe EmailSender precise saber os detalhes da implementação.

Classe NodemailerEmailService:
Implementa a interface EmailService e usa a biblioteca nodemailer para enviar emails.
Contém detalhes específicos de como o email é enviado, como configuração do transporte e manipulação de erros, mas essas implementações não são conhecidas pela classe EmailSender.

Classe EmailSender:
Depende da abstração EmailService para enviar emails, não de uma implementação concreta. Isso permite que EmailSender use qualquer serviço que implemente EmailService sem modificar seu próprio código.
O construtor de EmailSender recebe uma instância de EmailService, permitindo a injeção de dependências. Isso facilita a troca de implementações concretas (por exemplo, substituindo NodemailerEmailService por um outro serviço de email) sem alterar EmailSender.

Justificativa de Conformidade com o DIP
Dependência de Abstrações: EmailSender depende da abstração EmailService, não de NodemailerEmailService. Isso permite que a classe EmailSender permaneça desacoplada da implementação específica de envio de email.
Flexibilidade e Manutenção: Como EmailSender usa a interface EmailService, podemos substituir NodemailerEmailService por qualquer outra implementação que também implemente EmailService, como um serviço de email baseado em API, sem precisar alterar a lógica de EmailSender.
Injeção de Dependências: A injeção de uma implementação concreta de EmailService no construtor de EmailSender segue o princípio da injeção de dependências, que promove um acoplamento mais solto e uma maior flexibilidade no design do sistema.

Exemplo de Extensão
Para adicionar um novo serviço de email, como SendGridEmailService, que implementa EmailService, seria necessário apenas criar a nova classe:

class SendGridEmailService implements EmailService {
  async sendEmail(
    from: string,
    to: string,
    subject: string,
    body: string
  ): Promise<void> {
    // Implementação usando SendGrid API
    console.log(`Sending email via SendGrid from ${from} to ${to}: ${subject}`);
    // Código para enviar email usando SendGrid API
  }
}

const sendGridEmailService = new SendGridEmailService();
const emailSenderWithSendGrid = new EmailSender(sendGridEmailService);
emailSenderWithSendGrid.sendEmail(
  EnvEmail.SMTP_USER,
  'williamkoller30@gmail.com',
  'Hello',
  'This is a test email via SendGrid'
);
Enter fullscreen mode Exit fullscreen mode

Neste exemplo, SendGridEmailService é uma nova implementação de EmailService, e EmailSender pode utilizá-la sem mudanças no seu código, exemplificando o Princípio da Inversão de Dependência (DIP).

Conclusão

Adotar esses princípios pode transformar significativamente a maneira como você projeta e desenvolve software. Eles não são apenas recomendações teóricas, mas práticas comprovadas que ajudam a criar sistemas que são mais fáceis de entender, modificar e expandir. Ao investir tempo em entender e aplicar SOLID, você não apenas melhora a qualidade do seu código, mas também prepara sua base de código para um futuro mais sustentável e adaptável.

Se você está buscando criar software que se destaca pela sua clareza e robustez, integrar os princípios SOLID em sua prática de desenvolvimento é um passo essencial. Com uma abordagem fundamentada nesses princípios, você estará bem posicionado para construir sistemas que não apenas atendem às necessidades atuais, mas que também se adaptam e evoluem com o tempo.

Referências tiradas do livro Arquitetura Limpa por Robert C. Martin

Repositorio no Github: https://github.com/williamkoller/ts-node-solid-examples

Top comments (0)