DEV Community

Gabriel Teixeira da Silva
Gabriel Teixeira da Silva

Posted on

Entendendo SOLID no Contexto do JavaScript: Conceitos e Exemplos

A introdução do paradigma de Programação Orientada a Objetos (POO) popularizou conceitos fundamentais da programação, como Herança, Polimorfismo, Abstração e Encapsulamento. Rapidamente, a POO se tornou um paradigma amplamente aceito, com implementações em diversas linguagens como Java, C++, C#, JavaScript, entre outras. Contudo, à medida que os sistemas orientados a objetos se tornaram mais complexos, muitos projetos enfrentaram desafios de manutenção e adaptação a mudanças.

Para melhorar a extensibilidade do software e reduzir a rigidez do código, Robert C. Martin (conhecido como Uncle Bob) apresentou, no início dos anos 2000, os princípios SOLID.

SOLID é um acrônimo que reúne cinco princípios — Princípio da Responsabilidade Única, Princípio do Aberto/Fechado, Princípio da Substituição de Liskov, Princípio da Segregação de Interfaces e Princípio da Inversão de Dependências. Esses princípios ajudam desenvolvedores a projetar e escrever códigos mais manuteníveis, escaláveis e flexíveis. O objetivo? Elevar a qualidade do software criado com base no paradigma de Programação Orientada a Objetos.

Neste artigo, exploraremos cada um dos princípios SOLID e mostraremos como aplicá-los no contexto de uma das linguagens mais populares da web: o JavaScript (apesar de o JavaScript implementar POO de uma maneira diferente, algo que exploraremos em outro artigo).

Princípio da Responsabilidade Única (SRP)

O Princípio da Responsabilidade Única é representado pela primeira letra de SOLID. Esse princípio sugere que uma classe ou módulo deve ter apenas uma responsabilidade, ou seja, apenas uma razão para ser modificada.

Simplificando, uma classe ou função deve desempenhar apenas um papel. Se uma classe lida com mais de uma funcionalidade, atualizar uma delas sem impactar as outras se torna complicado. Isso pode levar a falhas no desempenho do software ou até mesmo a bugs inesperados. Para evitar esses problemas, é importante escrever um código modular, onde as preocupações sejam separadas.

Se uma classe ou função tiver muitas responsabilidades, ela se torna difícil de modificar, testar e manter. Ao aplicar o SRP, conseguimos criar sistemas mais organizados, com menos chances de erros. Vamos ver um exemplo prático em JavaScript:

Exemplo de SRP no JavaScript

Código que viola o SRP:

Aqui temos uma classe que lida com várias responsabilidades ao mesmo tempo:

class Person {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  saveToDatabase() {
    console.log(`Salvando ${this.name} no banco de dados...`);
    // Lógica para salvar no banco de dados
  }

  sendWelcomeEmail() {
    console.log(`Enviando e-mail de boas-vindas para ${this.email}...`);
    // Lógica para enviar e-mail
  }
}

Enter fullscreen mode Exit fullscreen mode

Problema:

A classe Person está lidando tanto com o armazenamento de dados no banco quanto com o envio de e-mails, o que são responsabilidades diferentes. Se algo mudar na lógica de e-mails, a classe Person precisará ser alterada, o que pode causar problemas.

Código que aplica o SRP:

Vamos separar as responsabilidades em classes diferentes:

class Person {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class PersonRepository {
  save(person) {
    console.log(`Salvando ${person.name} no banco de dados...`);
    // Lógica para salvar no banco de dados
  }
}

class EmailService {
  sendWelcomeEmail(person) {
    console.log(`Enviando e-mail de boas-vindas para ${person.email}...`);
    // Lógica para enviar e-mail
  }
}

// Uso:
const person = new Person("Gabriel", "gabriel@example.com");
const repository = new PersonRepository();
const emailService = new EmailService();

repository.save(person);
emailService.sendWelcomeEmail(person);

Enter fullscreen mode Exit fullscreen mode

Por que é melhor?

Person é responsável apenas por representar uma pessoa.
PersonRepository lida exclusivamente com o armazenamento de dados no banco.
EmailService cuida apenas do envio de e-mails.
Essa separação de responsabilidades torna o código mais modular, fácil de testar e menos propenso a erros, além de simplificar futuras alterações.

Princípio Aberto/Fechado (OCP)

O Princípio Aberto/Fechado afirma que os componentes de software (classes, funções, módulos, etc.) devem estar abertos para extensão, mas fechados para modificação. Em um primeiro momento, isso pode parecer contraditório, mas o princípio sugere que o código deve ser projetado de forma a permitir extensões sem que o código existente precise ser alterado.

Esse princípio é essencial para a manutenção de bases de código grandes, pois permite a adição de novas funcionalidades com baixo risco de introduzir bugs. Em vez de modificar classes ou módulos existentes quando surgem novos requisitos, você deve estender as classes relevantes adicionando novos componentes.

No JavaScript, o OCP pode ser implementado utilizando recursos como herança (ES6+) ou composição. Vamos explorar isso com um exemplo:

Exemplo sem aplicar o OCP (violação do princípio):

Neste exemplo, temos uma classe Shape que calcula a área de diferentes formas geométricas. Para cada nova forma, precisamos modificar a classe principal.

class Shape {
  constructor(type, dimensions) {
    this.type = type;
    this.dimensions = dimensions;
  }

  calculateArea() {
    if (this.type === "circle") {
      return Math.PI * this.dimensions.radius ** 2;
    } else if (this.type === "rectangle") {
      return this.dimensions.width * this.dimensions.height;
    }
    // E assim por diante...
  }
}

const circle = new Shape("circle", { radius: 5 });
console.log(circle.calculateArea()); // Área do círculo

const rectangle = new Shape("rectangle", { width: 10, height: 20 });
console.log(rectangle.calculateArea()); // Área do retângulo

Enter fullscreen mode Exit fullscreen mode

Problema:

Sempre que uma nova forma for adicionada (como triângulo ou quadrado), precisamos modificar a classe Shape, o que viola o OCP.

Exemplo aplicando o OCP:

Vamos reestruturar o código para seguir o Princípio Aberto/Fechado usando classes e herança:

// Classe base
class Shape {
  calculateArea() {
    throw new Error("Este método deve ser implementado pelas subclasses.");
  }
}

// Subclasses
class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

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

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

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

// Uso:
const circle = new Circle(5);
console.log(circle.calculateArea()); // Saída: 78.54

const rectangle = new Rectangle(10, 20);
console.log(rectangle.calculateArea()); // Saída: 200

Enter fullscreen mode Exit fullscreen mode

Por que isso segue o OCP?

  • A classe base Shape está fechada para modificações, ou seja, não precisamos alterá-la para adicionar novas formas.

  • Para suportar uma nova forma, como um triângulo, basta criar uma nova subclasse que implemente o método calculateArea.

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

O Princípio da Substituição de Liskov afirma que uma classe derivada deve ser substituível pela sua classe base sem que o funcionamento do sistema seja comprometido. Em outras palavras, se um objeto de uma classe base pode ser usado, um objeto de uma subclasse dessa classe também deve poder ser usado no lugar sem causar problemas.

Este princípio é essencial para garantir que o código seja flexível e extensível sem introduzir comportamentos inesperados. Ele exige que as subclasses respeitem o contrato da classe base, garantindo que o comportamento esperado permaneça consistente. Na prática, isso significa que:

  1. Subclasses devem sobrescrever os métodos da classe pai sem quebrar o código. Ou seja, as implementações das subclasses devem manter a consistência do comportamento esperado.

  2. Subclasses não devem desviar do comportamento da classe pai. Subclasses podem adicionar funcionalidades, mas não devem alterar ou remover funcionalidades definidas na classe pai.

  3. O código que funciona com instâncias da classe pai deve funcionar também com instâncias das subclasses, sem precisar saber que a classe mudou. Isso significa que o código deve ser agnóstico em relação à classe específica utilizada, respeitando o contrato estabelecido pela classe base.

Violação do LSP

Vamos começar com um exemplo onde o princípio é violado. Considere uma hierarquia de classes relacionada a retângulos:

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side) {
    super(side, side);
  }

  set width(value) {
    this._width = value;
    this._height = value;
  }

  set height(value) {
    this._width = value;
    this._height = value;
  }

  get width() {
    return this._width;
  }

  get height() {
    return this._height;
  }
}

// Uso:
const rectangle = new Rectangle(10, 20);
console.log(rectangle.getArea()); // Saída: 200

const square = new Square(10);
square.width = 15;
console.log(square.getArea()); // Saída esperada: 225, mas pode causar confusão

Enter fullscreen mode Exit fullscreen mode

Problema:

  • Embora Square seja uma subclasse de Rectangle, o comportamento de Square é inconsistente. Quando alteramos width ou height em Square, isso afeta ambas as dimensões, o que não é o caso para Rectangle.
  • Isso quebra o contrato implícito da classe base, violando o LSP.

Como corrigir o LSP

Para evitar essa violação, podemos separar Square e Rectangle em duas classes independentes que implementam uma interface comum (ou seguem um contrato comum no JavaScript):

class Shape {
  getArea() {
    throw new Error("Este método deve ser implementado pela subclasse.");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  getArea() {
    return this.side ** 2;
  }
}

// Uso:
const rectangle = new Rectangle(10, 20);
console.log(rectangle.getArea()); // Saída: 200

const square = new Square(10);
console.log(square.getArea()); // Saída: 100

Enter fullscreen mode Exit fullscreen mode

Por que isso segue o LSP?

  • Rectangle e Square agora são classes independentes, ambas respeitando o contrato da classe base Shape.
  • O comportamento de cada classe é consistente com sua definição, sem causar problemas quando substituídas.

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

O Princípio da Segregação de Interfaces (ISP) estabelece que nenhum cliente deve ser forçado a depender de métodos ou interfaces que não utiliza. Em termos práticos, isso significa que devemos criar interfaces menores e mais específicas, relevantes para os clientes que as consomem, ao invés de interfaces grandes e monolíticas que forçam os clientes a implementar métodos desnecessários.

Manter nossas interfaces compactas oferece diversas vantagens:

  • Facilita a manutenção, depuração e testes.
  • Evita dependências desnecessárias entre diferentes partes do sistema.
  • Minimiza o impacto de mudanças: alterações em uma parte da interface não devem forçar alterações em outras partes do código.

Sem o ISP, mudanças em interfaces grandes podem exigir refatorações extensas e complexas, especialmente em bases de código maiores.

Desafio no JavaScript

Diferentemente de linguagens baseadas em C, como Java ou C#, o JavaScript não possui suporte nativo a interfaces. Entretanto, podemos aplicar os conceitos de ISP utilizando objetos, classes e contratos implícitos.

Exemplo sem aplicar o ISP (violação do princípio)

Aqui temos uma interface implícita que força diferentes classes a implementar métodos que nem sempre fazem sentido:

class MultiFunctionPrinter {
  printDocument(doc) {
    console.log(`Imprimindo: ${doc}`);
  }

  scanDocument(doc) {
    console.log(`Escaneando: ${doc}`);
  }

  faxDocument(doc) {
    console.log(`Enviando fax: ${doc}`);
  }
}

// Impressora básica que não precisa de fax
class BasicPrinter extends MultiFunctionPrinter {
  faxDocument() {
    throw new Error("Fax não suportado nesta impressora.");
  }
}

// Uso:
const printer = new BasicPrinter();
printer.printDocument("Relatório");
printer.faxDocument("Relatório"); // Erro!
Enter fullscreen mode Exit fullscreen mode

Problema:

  • A classe BasicPrinter é forçada a implementar um método faxDocument, mesmo que não seja necessário.
  • Isso viola o ISP porque BasicPrinter está dependente de uma funcionalidade que não utiliza.

Corrigindo para aplicar o ISP

Podemos dividir a interface em partes menores, cada uma representando um conjunto de responsabilidades específicas:

// Interfaces menores representadas por classes ou funções
class Printer {
  printDocument(doc) {
    console.log(`Imprimindo: ${doc}`);
  }
}

class Scanner {
  scanDocument(doc) {
    console.log(`Escaneando: ${doc}`);
  }
}

class Fax {
  faxDocument(doc) {
    console.log(`Enviando fax: ${doc}`);
  }
}

// Classes específicas implementam apenas as interfaces necessárias
class BasicPrinter extends Printer {}

class AdvancedPrinter extends Printer {
  constructor() {
    super();
    this.scanner = new Scanner();
    this.fax = new Fax();
  }

  scanDocument(doc) {
    this.scanner.scanDocument(doc);
  }

  faxDocument(doc) {
    this.fax.faxDocument(doc);
  }
}

// Uso:
const basicPrinter = new BasicPrinter();
basicPrinter.printDocument("Relatório"); // Saída: Imprimindo: Relatório

const advancedPrinter = new AdvancedPrinter();
advancedPrinter.printDocument("Contrato"); // Saída: Imprimindo: Contrato
advancedPrinter.scanDocument("Contrato"); // Saída: Escaneando: Contrato
advancedPrinter.faxDocument("Contrato"); // Saída: Enviando fax: Contrato
Enter fullscreen mode Exit fullscreen mode

Por que agora segue o ISP?

  • Cada classe (ou "interface") está focada em uma responsabilidade específica (Printer, Scanner, Fax).
  • Classes concretas, como BasicPrinter e AdvancedPrinter, implementam apenas as funcionalidades que realmente utilizam.
  • O sistema agora é mais modular, fácil de estender e menos propenso a erros ao adicionar novas funcionalidades.

Benefícios do ISP

  1. Modularidade: As interfaces menores tornam o código mais fácil de entender e gerenciar.
  2. Redução de dependências: Classes e módulos dependem apenas das funcionalidades relevantes.
  3. Extensibilidade: Novas funcionalidades podem ser adicionadas sem impactar partes não relacionadas do sistema.

Aqui está uma versão adaptada para português da explicação do Princípio da Inversão de Dependência (Dependency Inversion Principle - DIP) com exemplos em JavaScript:

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

O Princípio da Inversão de Dependência (DIP) estabelece que módulos de alto nível (como lógica de negócios) devem depender de abstrações, e não de implementações concretas. Isso significa que, em vez de classes de alto nível se conectarem diretamente a classes de baixo nível, ambas devem depender de uma abstração comum.

Esse princípio ajuda a reduzir as dependências diretas no código, tornando-o mais modular, flexível e testável. Ele permite que desenvolvedores modifiquem e expandam a lógica de alto nível sem complicações, mesmo que os componentes de baixo nível mudem.

Por que o DIP favorece abstrações?

Ao introduzir abstrações, podemos:

  1. Reduzir impactos de mudanças: Alterar uma implementação concreta não afeta a lógica de alto nível.
  2. Melhorar a testabilidade: Podemos usar mocks ou stubs para simular abstrações durante os testes.
  3. Aumentar a flexibilidade: O sistema se torna mais modular e fácil de estender.

O DIP promove o acoplamento fraco (loose coupling) em vez do acoplamento forte (tight coupling). Isso significa que as partes do sistema são menos dependentes umas das outras, facilitando a manutenção e expansão.

Exemplo sem aplicar o DIP (violação do princípio)

Neste exemplo, uma classe de alto nível (OrderService) depende diretamente de uma classe de baixo nível (EmailService):

class EmailService {
  sendEmail(message) {
    console.log(`Enviando e-mail: ${message}`);
  }
}

class OrderService {
  constructor() {
    this.emailService = new EmailService();
  }

  placeOrder(order) {
    console.log("Pedido realizado:", order);
    this.emailService.sendEmail("Seu pedido foi recebido.");
  }
}

// Uso:
const orderService = new OrderService();
orderService.placeOrder("Pedido #123");
Enter fullscreen mode Exit fullscreen mode

Problema:

  • A classe OrderService está fortemente acoplada à implementação de EmailService. Se quisermos trocar EmailService por outro serviço (como SMSService), teremos que modificar OrderService, violando o DIP.

Exemplo aplicando o DIP

Agora vamos introduzir uma abstração para desacoplar OrderService da implementação concreta de EmailService:

// Abstração
class NotificationService {
  sendNotification(message) {
    throw new Error("Este método deve ser implementado por subclasses.");
  }
}

// Implementação concreta
class EmailService extends NotificationService {
  sendNotification(message) {
    console.log(`Enviando e-mail: ${message}`);
  }
}

class SMSService extends NotificationService {
  sendNotification(message) {
    console.log(`Enviando SMS: ${message}`);
  }
}

// Classe de alto nível
class OrderService {
  constructor(notificationService) {
    this.notificationService = notificationService; // Injeção de dependência
  }

  placeOrder(order) {
    console.log("Pedido realizado:", order);
    this.notificationService.sendNotification("Seu pedido foi recebido.");
  }
}

// Uso:
const emailService = new EmailService();
const smsService = new SMSService();

const orderServiceWithEmail = new OrderService(emailService);
orderServiceWithEmail.placeOrder("Pedido #123"); // Saída: Enviando e-mail: Seu pedido foi recebido.

const orderServiceWithSMS = new OrderService(smsService);
orderServiceWithSMS.placeOrder("Pedido #124"); // Saída: Enviando SMS: Seu pedido foi recebido.
Enter fullscreen mode Exit fullscreen mode

Por que agora segue o DIP?

  1. Desacoplamento:

    • OrderService depende da abstração NotificationService, e não de implementações concretas como EmailService ou SMSService.
    • Podemos alterar ou substituir a implementação concreta sem modificar OrderService.
  2. Injeção de dependência:

    • O OrderService recebe a dependência (notificationService) como parâmetro no construtor. Isso é conhecido como injeção de dependência (dependency injection).
  3. Flexibilidade:

    • Podemos adicionar novas implementações de NotificationService (como PushNotificationService) sem alterar a lógica de alto nível em OrderService.

Vantagens do DIP

  • Facilidade de manutenção: Alterar componentes de baixo nível não afeta módulos de alto nível.
  • Modularidade: As partes do sistema são mais independentes.
  • Testabilidade: É fácil criar mocks para testar módulos de alto nível sem depender de implementações reais.
  • Extensibilidade: Novas funcionalidades podem ser adicionadas sem causar grandes alterações no sistema.

Conclusão

Os princípios SOLID são fundamentais para criar sistemas mais organizados, flexíveis e fáceis de manter. Aplicá-los no contexto do JavaScript pode parecer desafiador inicialmente, especialmente por ser uma linguagem dinâmica e sem suporte nativo a alguns conceitos como interfaces. No entanto, como vimos, com abordagens práticas como herança, abstrações e injeção de dependências, é possível incorporar esses princípios e alcançar um código mais modular e resiliente.

Ao adotar SOLID no dia a dia, você não apenas melhora a qualidade do seu código, mas também reduz o risco de bugs e torna o desenvolvimento mais eficiente a longo prazo. Que tal começar a aplicar esses conceitos no seu próximo projeto?

Top comments (0)