DEV Community

Cover image for Reflexões sobre SOLID - A Letra "S"
Mauricio Paulino
Mauricio Paulino

Posted on • Updated on

Reflexões sobre SOLID - A Letra "S"

Continuando minha série de reflexões, assim como a maioria das pessoas que começa a se aprofundar em SOLID, gostaria de começar com a letra "S". Trabalharei com TypeScript nos exemplos.


Neste artigo

Single Responsibility Principle
Exemplo abstrato
Exemplo técnico (Front-End)
Exemplo técnico (Back-End)
Exemplo pessoal
Exemplo funcional
As aplicabilidades
Reflexões finais


Single Responsibility Principle

O Single Responsibility Principle (ou Princípio da Responsabilidade Única) determina que uma classe (ou entidade, como preferir) deve ter somente uma única responsabilidade, e um único motivo para ser alterada. Essa última parte é a chave para o entendimento profundo do princípio.

Ou seja, o grande propósito por trás desta ideia é garantir que não tenhamos classes com propósitos misturados. Tendo uma classe A que foi desenvolvida para o propósito X, caso uma nova funcionalidade Y surja, é necessário uma análise para identificar se faz sentido Y ser implementado na classe A, ou se faria mais sentido em uma nova classe B.

E como fazer esse análise? Quais os critérios? Bom, vamos aos exemplos.


Exemplo abstrato

Vamos utilizar uma biblioteca como exemplo. Suponhamos que eu desejo controlar os livros que existem em uma biblioteca, e também pesquisá-los. Nossas funcionalidades poderiam ser descritas da seguinte forma:

  • Cadastrar livros. Permitir adicionar livros com o título e o autor.
  • Remover livros. Permitir remover livros pelo número de identificação.
  • Listar todos os livros.
  • Buscar livros por autor.
  • Buscar livros por título.

🔴 Implementação Incorreta

// Apenas a interface dos nossos livros.
// Interface simples para o livro, utilizando constructor shorthand.
class Book {
  constructor(public id: string, public title: string, public author: string) {}
}

class Library {
  public books: Book[];

  constructor() {}

  // Método para adicionar um livro na biblioteca.
  public addBook(book: Book) {
    this.books.push(book);
  }

  // Método para remover um livro da biblioteca.
  // Ignore a falta de validação e uso puro do id - é para ser mais didático.
  public removeBook(id: string): void {
    const index = this.books.findIndex((book) => (book.id = id));
    this.books.splice(index, 1);
  }

  // Método para listar os livros existentes na biblioteca.
  public listBooks(): void {
    this.books.forEach((book) => {
      console.log(`Livro "${book.title}", autor "${book.author}"`);
    });
  }

  // Método para buscar por título do livro.
  // VIOLAÇÃO DO PRINCÍPIO: A classe tem a responsabilidade de controlar o cadastro de livros.
  // Com esse método, ela passa a controlar também as buscas e algoritmos vinculados a sua manipulação.
  // Portanto, ela passa a ter mais de um motivo para ser modificada.
  public findBookByTitle(titleToSearch: string): Book[] {
    // Supondo que o algoritmo de busca abaixo seja modificado ou evoluído, ocasionaria uma alteração em uma classe
    // que, originalmente, deveria se encarregar somente de controlar o cadastro de livros.
    return this.books.filter((book) => book.title.includes(titleToSearch));
  }

  // Método para buscar por autor do livro.
  // VIOLAÇÃO DO PRINCÍPIO: Mesmo de cima.
  public findBookByAuthor(authorToSearch: string): Book[] {
    return this.books.filter((book) => book.author.includes(authorToSearch));
  }
}

// Criando objeto do primeiro livro.
const viagemAoCentroDaTerra = new Book(
  "123",
  "Viagem ao Centro da Terra",
  "Julio Verne"
);

// Criando objeto do segundo livro.
const aMaquinaDoTempo = new Book(
  "456",
  "A Máquina do Tempo",
  "Herbert George Wells"
);

// Criando nossa biblioteca.
const library = new Library();

// Adicionando os livros.
library.addBook(viagemAoCentroDaTerra);
library.addBook(aMaquinaDoTempo);

// Listando os livros.
library.listBooks();
// OUTPUT:
// Livro "Viagem ao Centro da Terra", autor "Julio Verne"
// Livro "A Máquina do Tempo", autor "Herbert George Wells"

// Buscando por autor.
const search = library.findBookByAuthor('Julio');
console.log(search);
// OUTPUT:
// [ Book { id: '123', title: 'Viagem ao Centro da Terra', author: 'Julio Verne' } ]
Enter fullscreen mode Exit fullscreen mode

🟢 Implementação Correta

class Book {
  constructor(public id: string, public title: string, public author: string) {}
}

// Removemos a lógica de pesquisa dessa classe.
class Library {
  public books: Book[];

  constructor() {}

  public addBook(book: Book) {
    this.books.push(book);
  }

  public removeBook(id: string): void {
    const index = this.books.findIndex((book) => (book.id = id));
    this.books.splice(index, 1);
  }

  public listBooks(): void {
    this.books.forEach((book) => {
      console.log(`Livro "${book.title}", autor "${book.author}"`);
    });
  }
}

// E criamos uma nova, que se responsabiliza pelas pesquisas.
class LibrarySearcher {
  // Instanciamos com o objeto da biblioteca.
  constructor(private library: Library) {}

  // E, por fim, fazemos nossas pesquisas.
  public findBookByTitle(titleToSearch: string): Book[] {
    return this.library.books.filter((book) => book.title.includes(titleToSearch));
  }

  // Isso até nos abre a oportunidade de otimizarmos o código repetido entre as pesquisas.
  public findBookByAuthor(authorToSearch: string): Book[] {
    return this.library.books.filter((book) => book.author.includes(authorToSearch));
  }
}

const viagemAoCentroDaTerra = new Book(
  "123",
  "Viagem ao Centro da Terra",
  "Julio Verne"
);

const aMaquinaDoTempo = new Book(
  "456",
  "A Máquina do Tempo",
  "Herbert George Wells"
);

const library = new Library();

library.addBook(viagemAoCentroDaTerra);
library.addBook(aMaquinaDoTempo);

// Instanciando o objeto de pesquisa.
const searcher = new LibrarySearcher(library);

// Buscando por autor.
const search = searcher.findBookByAuthor("Julio");
console.log(search);
// OUTPUT:
// [ Book { id: '123', title: 'Viagem ao Centro da Terra', author: 'Julio Verne' } ]
Enter fullscreen mode Exit fullscreen mode

Exemplo técnico (Front-End)

Vamos imaginar que temos uma classe de componente, e que queremos ter o seguinte comportamento:

  • Exibir o componente. Esse componente exibirá um texto.
  • Envelopar o componente com outro componente pai. Apenas um container simples.

🔴 Implementação Incorreta

// Imaginamos, de forma um pouco abstrata, que temos uma classe de componente.
class Component {
  // Em seu construtor, passamos um texto, e um indicador de envelopamento.
  constructor(private text: string, private wrapper?: boolean) {}

  // Temos um método para renderizar.
  public render() {
    // VIOLAÇÃO DO PRINCÍPIO: Agora, o componente não se responsabiliza somente pela exibição
    // do texto, mas também pelo envelopador. Se ele mudar, a classe também precisa sofrer alteração.
    if (this.wrapper) {
      return <div><p>{this.text}</p></div>
    }

    return <p>{this.text}</p>
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Implementação Correta

class Component {
  constructor(private text: string) {}

  // Agora, a renderização do Component se preocupa somente com o componente de texto em si.
  public render() {
    return <p>{this.text}</p>
  }
}

// E criamos uma classe Wrapper que se encarregará de envelopar o componente.
class Wrapper {
  constructor(private component: Component) {}

  public wrap() {
    return <div>{this.component}</div>
  }
}

// Não precisamos mais do boolean "wrapper", pois o `new Wrapper(...)` é opcional.
const myText = new Component('Hello!');
const myWrapper = new Wrapper(myText);
Enter fullscreen mode Exit fullscreen mode

Exemplo técnico (Back-End)

Considere que temos uma classe para controlar o acesso ao Banco de Dados, e que queremos ter as seguintes funcionalidades:

  • Conectar com o banco de dados.
  • Obter dados de um usuário. Busca específica de um domínio.

🔴 Implementação Incorreta

// Vamos simular uma classe que controla o uso do banco de dados.
class Database {
  constructor() {}

  // Temos um método que se conecta com o banco de dados.
  public connect() {
    console.log('Connected to the database!');
  }

  // VIOLAÇÃO DO PRINCÍPIO: A classe deveria se preocupar somente com o uso do banco de dados,
  // e não com a obtenção de dados de uma domínio tão específico.
  // Se o provedor do banco de dados mudar, a classe seria alterada. Se a forma de obtenção do usuário mudar, também.
  public getUser(id: string) {
    this.connect();
    console.log(`Got user ${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Implementação Correta

// A classe Database agora se preocupa somente com o que tem relação com o banco de dados em si.
// Isso pode ser a conexão, os encapsulamentos, as transactions, e tudo mais.
class Database {
  constructor() {}

  public connect() {
    console.log("Connected to the database!");
  }
}

// Já a classe UserDatabase tem um viés mais voltado para um domínio de negócio específico.
// Além de estar agnóstica ao provedor do Database, tem um único motivo para ser alterada - o que tem relação ao user.
class UserDatabase {
  constructor(private database: Database) {}

  public getUser(id: string) {
    this.database.connect();
    console.log(`Got user ${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Exemplo pessoal

Vamos brincar um pouco com um exemplo mais pessoal. Como comentei na primeira parte, gosto de pensar em alguns hobbies para interpretar alguns princípios. Imaginemos um jogo como Super Mario 64, onde eu quero:

  • Coletar moedas. Salvar a quantidade obtida.
  • Coletar estrelas. Salvar a quantidade obtida.
  • Movimentar o jogador. Atualizar conforme necessidade.

🔴 Implementação Incorreta

// Vamos criar a classe do jogo como um todo.
class SuperMario64 {
  // Neste jogo, controlamos as quantidades de moedas, estrelas e posição do jogador (x, y, z).
  private coinCount: number = 0;
  private starCount: number = 0;
  private playerPosition = "0,0,0";

  constructor() {}

  // E então, podemos jogar o jogo!
  public playGame() {
    console.log(
      `Coins: ${this.coinCount}, Stars: ${this.starCount}, Position: ${this.playerPosition}`
    );
  }

  // VIOLAÇÃO DO PRINCÍPIO: Essa classe deveria se preocupar somente em consumir o status do jogo, e não
  // em atualizar algo específico.
  public getCoin() {
    this.coinCount++;
  }

  // VIOLAÇÃO DO PRINCÍPIO: Mesmo que o de cima.
  public getStar() {
    this.starCount++;
  }

  // VIOLAÇÃO DO PRINCÍPIO: Mesmo que o de cima.
  public updatePlayerPosition(newPosition: string) {
    this.playerPosition = newPosition;
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Implementação Correta

// Separamos o jogador em uma classe própria.
class Player {
  constructor(public playerPosition: string) {}

  updatePlayerPosition(newPlayerPosition: string) {
    this.playerPosition = newPlayerPosition;
  }
}

// Identificamos que estrelas e moedas funcionam da mesma forma, e criamos uma classe própria.
class Collectible {
  private count: number;

  constructor() {}

  public add() {
    this.count++;
  }

  public getCurrent() {
    return this.count;
  }
}

// Agora, o jogo consome o que precisa, ao invés de se responsabilizar por isso.
class SuperMario64 {
  constructor(
    public player: Player,
    public coins: Collectible,
    public stars: Collectible
  ) {}

  public playGame() {
    const playerPosition = this.player.playerPosition;
    const coinCount = this.coins.getCurrent();
    const starCount = this.stars.getCurrent();

    console.log(
      `Coins: ${coinCount}, Stars: ${starCount}, Position: ${playerPosition}`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Exemplo funcional

Também na parte inicial dessa série de artigos, mencionei que os paradigmas de SOLID costumam se aplicar ao padrão de onde nasceram - Object-Oriented Programming. Apenas para provar o conceito, gostaria de apresentar uma ideia utilizando a visão de Functional Programming.

🔴 Implementação Incorreta

function readFileAndMap(fileName: string) {
  // Suponha que vamos ler um arquivo de forma síncrona aqui.
  const file = fileSystem.read(fileName);

  // VIOLAÇÃO DO PRINCÍPIO: Estamos tratando o dado lido ao invés de só lê-lo.
  const mappedContent = file.replace(/alguma-coisa/gm, 'outra-coisa');

  return mappedContent;
}

const content = readFileAndMap('file-name');
Enter fullscreen mode Exit fullscreen mode

🟢 Implementação Correta

function readFile(fileName: string) {
  const file = fileSystem.read(fileName);
  return file;
}

function mapContent(fileContent: string) {
  const mappedContent = fileContent.replace(/alguma-coisa/gm, "outra-coisa");

  return mappedContent;
}

// Aqui separamos o momento de leitura do arquivo do momento de mapeamento.
// Assim, para modificar a lógica de mapeamento, não alteraremos a função
// responsável somente por ler arquivos, e poderemos reaproveitá-la para outros
// cenários com outros mapeamentos.
const fileContent = readFile("file-name");
const content = mapContent(fileContent);
Enter fullscreen mode Exit fullscreen mode

As aplicabilidades

Com os exemplos descritos, fica a dúvida: "Como posso aplicar esse conhecimento no meu dia-a-dia?"

O principal ponto é analisar as entidades envolvidas e buscar as responsabilidades do processo. A classe que você está manipulando já possui uma grande complexidade? Ou ela já possui uma responsabilidade muito bem definida? Então, talvez será necessário delegar a sua lógica para uma classe separada.

Comece a observar com mais atenção as entidades com as quais você interage, e busque identificar o propósito de cada uma delas. Você pode até mesmo diagramar, descrever ou estudar as implementações para ter uma visão mais concreta da classe e dos seus relacionamentos.


Reflexões finais

É muito provável que você se depare com diversos projetos com responsabilidades misturadas no dia-a-dia, visto que nem todo mundo está acostumado a observar o desenvolvimento com esse viés na prática. Em momentos assim, tome cuidado para não cair na armadilha do refactoring exagerado. A vontade de aplicar os princípios pode ser grande, mas você deverá ter um posicionamento mais analítico e cuidadoso, pois uma refatoração sem planejamento pode levar a resultados inesperados (um assunto para outro artigo).

Lembre-se que vale muito a experimentação para entender o que dá certo e o que não dá, sem exagerar. Precisamos tomar muito cuidado com aquilo que chamamos over-engineering, a aplicação de conceitos exageradamente e de forma extremamente minuciosa, especialmente em contextos simples.

O Princípio da Responsabilidade Única é um excelente ponto de partida para conhecer o SOLID, e é provavelmente o que a maioria das pessoas estão familiarizadas. Espero que esses descritivos e exemplos tenham ajudado para visualizá-lo de forma mais prática.

Top comments (0)