S.O.L.I.D

Matheus Kielkowski
9 min readOct 14, 2023

S.O.L.I.D é um acrônimo para cinco princípios de design de orientação a objetos, propostos por Robert C. Martin, que ajudam os desenvolvedores a criar sistemas mais compreensíveis, flexíveis e manuteníveis. Cada letra do acrônimo significa um princípio, que vamos explicar durante este artigo.

S. (Single Responsibility Principle)

O Princípio da Responsabilidade Única afirma que uma classe deve ter apenas um motivo para mudar. Em outras palavras, uma classe deve ter apenas uma responsabilidade ou tarefa. Quando um sistema é bem projetado com o SRP em mente, torna-se mais modular, mais fácil de manter e menos propenso a bugs.

Imagine, por exemplo, uma classe que gerencia as operações de um usuário, como salvar no banco de dados, validar os dados e também gerar uma representação visual para esse usuário. Se algo precisasse ser alterado na validação, você estaria modificando a mesma classe que gerencia a representação visual e a interação com o banco de dados. Isso pode introduzir bugs inesperados em partes do código que pareciam não estar relacionadas à alteração original.

Separando estas responsabilidades em classes diferentes, cada uma lidando apenas com uma tarefa específica, o código torna-se mais claro, mais reutilizável e menos propenso a efeitos colaterais inesperados quando mudanças são necessárias.

Vamos considerar um sistema simples de gerenciamento de usuários. Sem seguir o Princípio da Responsabilidade Única, você pode ter algo assim:

class Usuario {
nome: string
email: string

constructor(nome: string, email: string) {
this.nome = nome
this.email = email
}

mostrarDetalhes(): void {
console.log(`Nome: ${this.nome}, Email: ${this.email}`)
}

static validar(email: string): boolean {
return email.includes('@') && email.includes('.')
}

static salvar(usuario: Usuario): void {
// Implementação para salvar o usuário no banco de dados
}
}

A classe Usuario acima está fazendo várias coisas: validando e-mails, interagindo com um banco de dados e lidando com a exibição de informações na interface. É uma violação do Princípio da Responsabilidade Única.

Vamos refatorar isso:

  1. Criar uma classe para validação.
  2. Criar uma classe para interagir com o banco de dados.
  3. Manter a lógica de apresentação separada.
class Usuario {
nome: string
email: string

constructor(nome: string, email: string) {
this.nome = nome
this.email = email
}

mostrarDetalhes(): void {
console.log(`Nome: ${this.nome}, Email: ${this.email}`)
}
}

class ValidadorEmail {
static validar(email: string): boolean {
return email.includes('@') && email.includes('.')
}
}

class BancoDeDadosUsuario {
static salvar(usuario: Usuario): void {
// Implementação para salvar o usuário no banco de dados
}
}

Agora, cada classe tem apenas um motivo para mudar:

  • Usuario mudaria se a maneira como queremos representar um usuário muda.
  • ValidadorEmail mudaria se a lógica de validação do e-mail mudar.
  • BancoDeDadosUsuario mudaria se a maneira como interagimos com o banco de dados mudar.

Dessa forma, seguimos o Princípio da Responsabilidade Única, tornando o código mais organizado, flexível e fácil de manter.

Em resumo, o SRP sugere que segmentemos nosso software de modo que cada classe tenha um foco único, facilitando assim o entendimento, a manutenção e a extensibilidade do código.

O. (Open/Closed Principle)

A ideia central por trás deste princípio é promover a estabilidade do software. Imagine se cada vez que precisássemos adicionar uma nova funcionalidade fosse necessário modificar um módulo existente (adicionar uma nova regra de negócio), isso poderia introduzir bugs em partes do código que já foram testadas e validadas. Para isto então temos nosso segundo princípio:

  • Open para extensão: Isso significa que o comportamento de um módulo (pode ser uma classe, um método etc.) pode ser estendido. Ou seja, deveríamos ser capazes de adicionar novas funcionalidades ou comportamentos a ele.
  • Closed para modificação: Isso significa que, uma vez que um módulo foi desenvolvido e testado, ele não deve ser modificado para adicionar novos comportamentos ou funcionalidades. Em vez disso, deveríamos estender esse módulo, por exemplo, através da herança, da implementação de interfaces ou por meio de composição, para introduzir novos comportamentos.

Ao permitir a extensão sem a necessidade de modificação, reduzimos o risco de regressões e tornamos o software mais maleável e sustentável.

Vamos exemplificar de forma prática. Imagine que temos uma classe que representa formas geométricas e uma função que calcula a área total de uma lista de formas:

class Rectangle {
width: number;
height: number;

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

class AreaCalculator {
calculateTotalArea(rectangles: Rectangle[]): number {
let total = 0;

for (let rectangle of rectangles) {
total += rectangle.width * rectangle.height;
}

return total;
}
}

Se quisermos adicionar um novo tipo de forma, como um círculo, teríamos que modificar a classe AreaCalculator para acomodar essa nova forma. Isso viola o OCP. Então, seguindo o princípio podemos fazer da seguinte forma:

// Defina uma interface para a forma
interface Shape {
area(): number;
}

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

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

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

class Circle implements Shape {
radius: number;

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

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

class AreaCalculator {
calculateTotalArea(shapes: Shape[]): number {
let total = 0;

for (let shape of shapes) {
total += shape.area();
}

return total;
}
}

Agora, se quisermos adicionar novas formas, basta implementar a interface Shape e não precisamos modificar a classe AreaCalculator para acomodar novas formas. Dessa forma, a classe AreaCalculator está fechada para modificação, mas aberta para extensão, conforme o Open/Closed Principle.

Adotar o Open/Closed Principle, assim como os outros princípios do SOLID, ajuda a tornar o software mais modular, flexível e menos propenso a erros quando sujeito a mudanças.

L. (Liskov Substitution Principle)

O Princípio da Substituição de Liskov foi introduzido por Barbara Liskov em 1987. O LSP afirma que os objetos de uma superclasse devem ser capazes de ser substituídos por objetos de uma subclasse sem afetar a correção do programa. Em outras palavras, se uma classe S é uma subclasse de T, então uma instância de T deve ser substituível por uma instância de S (uma instância de S deve ser um substituto para uma instância de T) sem alterar as propriedades desejáveis do programa.

Para assegurar que o LSP seja respeitado, as subclasses:

  1. Não devem violar o contrato estabelecido pela superclasse. Isso significa que a subclasse deve respeitar a semântica da superclasse, garantindo que todos os métodos da subclasse mantenham o comportamento esperado.
  2. Não devem introduzir restrições nos dados ou no comportamento que não estejam presentes na superclasse.

Exemplificando na prática…

Muitos podem pensar que um Quadrado é um subtipo de Retângulo, porque um quadrado tem todos os atributos de um retângulo, certo? No entanto, se formos definir um retângulo e um quadrado em termos de programação, poderíamos esbarrar em problemas com o Princípio da Substituição de Liskov.

class Retangulo {
largura: number = 0;
altura: number = 0;

setLargura(largura: number) {
this.largura = largura;
}

setAltura(altura: number) {
this.altura = altura;
}

area(): number {
return this.largura * this.altura;
}
}

class Quadrado extends Retangulo {
setLargura(largura: number) {
this.largura = largura;
this.altura = largura;
}

setAltura(altura: number) {
this.largura = altura;
this.altura = altura;
}
}

function aumentaRetangulo(ret: Retangulo) {
ret.setLargura(5);
ret.setAltura(10);
// Esperamos que a área seja 50, mas se passarmos um Quadrado, não será.
console.log(`Área esperada: 50, Área real: ${ret.area()}`);
}

const quad = new Quadrado();
aumentaRetangulo(quad); // Área esperada: 50, Área real: 100

O problema aqui é que um Quadrado não é realmente um substituto para um Retangulo se usarmos a definição acima. Quando tentamos aumentar o retângulo usando o método aumentaRetangulo, o resultado é diferente do esperado se passarmos um quadrado.

Uma solução é separar as duas formas, talvez usando interfaces para definir seus comportamentos:

interface Forma {
area(): number;
}

class Retangulo implements Forma {
constructor(private largura: number, private altura: number) {}

area(): number {
return this.largura * this.altura;
}
}

class Quadrado implements Forma {
constructor(private lado: number) {}

area(): number {
return this.lado * this.lado;
}
}

function imprimirArea(forma: Forma) {
console.log(`Área: ${forma.area()}`);
}

const ret = new Retangulo(5, 10);
const quad = new Quadrado(5);
imprimirArea(ret); // Área: 50
imprimirArea(quad); // Área: 25

Neste exemplo, não estamos mais usando herança para relacionar um Quadrado e um Retangulo, pois eles têm diferentes maneiras de calcular sua área. Ao invés disso, ambos implementam uma interface comum Forma, e ambos são substituíveis quando se espera uma Forma.

O LSP ajuda a garantir que a utilização de herança e polimorfismo não introduza comportamentos inesperados ou bugs nos programas. Se um sistema foi projetado com o LSP em mente, os desenvolvedores podem ter mais confiança ao substituir ou estender classes sem causar problemas inesperados no comportamento do sistema.

I. (Interface Segregation Principle)

Este princípio declara que nenhuma classe deveria ser forçada a implementar interfaces das quais não usará. Em outras palavras, é mais benéfico ter interfaces específicas ao invés de uma única interface “faz-tudo”. Isso ajuda a manter o sistema mais modular, pois cada classe implementará apenas as interfaces que são relevantes para ela.

Imagine que você tem uma interface chamada Dispositivo:

interface Dispositivo {
imprimir(documento: string): void;
escanear(documento: string): void;
fax(documento: string): void;
}

class ImpressoraMultifuncional implements Dispositivo {
imprimir(documento: string): void {
// lógica de impressão
}

escanear(documento: string): void {
// lógica de escaneamento
}

fax(documento: string): void {
// lógica de fax
}
}

Isso força qualquer classe que implementa Dispositivo a implementar métodos para imprimir, escanear e enviar fax, mesmo que não precise de todas essas funcionalidades.

Agora, aplicando o Interface Segregation Principle, podemos dividir a interface Dispositivo em interfaces menores e mais específicas:

interface Impressora {
imprimir(documento: string): void;
}

interface Scanner {
escanear(documento: string): void;
}

interface Fax {
enviarFax(documento: string): void;
}

class ImpressoraSimples implements Impressora {
imprimir(documento: string): void {
// lógica de impressão
}
}

class ImpressoraMultifuncional implements Impressora, Scanner, Fax {
imprimir(documento: string): void {
// lógica de impressão
}

escanear(documento: string): void {
// lógica de escaneamento
}

enviarFax(documento: string): void {
// lógica de fax
}
}

Com a segregação das interfaces, agora é possível implementar apenas as funcionalidades realmente necessárias para cada classe. A classe ImpressoraSimples, por exemplo, só precisa implementar a interface Impressora, enquanto a classe ImpressoraMultifuncional pode implementar todas as três interfaces.

D. (Dependency Inversion Principle)

O Dependency Inversion Principle tem duas partes principais:

  1. Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Isto significa que a lógica de negócios mais abrangente (alto nível) de uma aplicação não deve depender diretamente das implementações detalhadas ou específicas (baixo nível) como acesso a banco de dados, frameworks específicos, entre outros. Em vez disso, eles devem se comunicar através de interfaces ou abstrações.
  2. Abstrações não devem depender de detalhes. Detalhes devem depender de abstrações. Em vez de ter abstrações (como interfaces ou classes abstratas) que são moldadas com base nas necessidades específicas das implementações que as usam, você deve desenhar suas abstrações para refletir as necessidades de alto nível do sistema, e então fazer com que as implementações de baixo nível se adaptem a estas abstrações.

Vamos usar um exemplo clássico envolvendo interações com um banco de dados para ilustrar o Dependency Inversion Principle (DIP). Imagine que temos um sistema de gerenciamento de usuários e queremos implementar funcionalidades para salvar e recuperar usuários do banco de dados:

class MySQLDatabase {
saveUser(user: any) {
// Lógica para salvar usuário no MySQL
}

getUser(id: number) {
// Lógica para obter usuário do MySQL
return { id, name: "John Doe" };
}
}

class UserManager {
db: MySQLDatabase;

constructor() {
this.db = new MySQLDatabase();
}

saveUser(user: any) {
this.db.saveUser(user);
}

getUser(id: number) {
return this.db.getUser(id);
}
}

Agora aplicando o DIP, oUserManager depende de uma abstração (interface Database) e não da implementação específica:

// Definindo a abstração
interface Database {
saveUser(user: any): void;
getUser(id: number): any;
}

class MySQLDatabase implements Database {
saveUser(user: any) {
// Lógica para salvar usuário no MySQL
}

getUser(id: number) {
return { id, name: "John Doe" };
}
}

class UserManager {
db: Database;

constructor(database: Database) {
this.db = database;
}

saveUser(user: any) {
this.db.saveUser(user);
}

getUser(id: number) {
return this.db.getUser(id);
}
}

// Uso
const mysqlDb = new MySQLDatabase();
const userManager = new UserManager(mysqlDb);

Agora, se quisermos mudar a fonte de dados, por exemplo, para um banco de dados MongoDB, podemos facilmente criar uma nova classe que implementa a interface Database sem ter que modificar o UserManager. O UserManager é agora flexível e não está acoplado a uma implementação específica de banco de dados.

O DIP pode ser um pouco contra-intuitivo à primeira vista porque, em muitos casos, tendemos a criar abstrações que refletem diretamente as necessidades das implementações que estamos usando. No entanto, ao inverter essa dependência e fazer com que os detalhes dependam das abstrações, acabamos com um sistema onde mudanças em detalhes específicos (como mudar a forma como acessamos o banco de dados) têm um impacto mínimo ou nulo sobre o código de alto nível.

O Dependency Inversion Principle é frequentemente implementado em conjunto com o uso de injeção de dependência, onde as dependências (geralmente representadas por interfaces) são “injetadas” nas classes, em vez de as classes criarem ou buscarem essas dependências diretamente.

Em resumo, o DIP ajuda a desacoplar o código, tornando os sistemas mais modularizados e, portanto, mais fáceis de modificar, testar e manter.

Concluindo…

Em resumo, o S.O.L.I.D proporciona uma fundação sólida para a construção de software. Ele age como uma bússola para os desenvolvedores, guiando-os na criação de sistemas robustos, escaláveis e de fácil manutenção. Ao adotar esses princípios, as equipes estão mais bem equipadas para enfrentar desafios futuros e adaptar-se às mudanças inevitáveis que ocorrem durante o ciclo de vida do software.

--

--

Matheus Kielkowski

Software Enginner in love with web technologies 👨‍💻