S.O.L.I.D: Entendendo Open/Closed Principle (OCP) e aplicando no frontend!

Entidades de software (classes, módulos, funções) devem ser abertas para extensão, mas fechadas para modificação. - Bertrand Meyer.

S.O.L.I.D: Entendendo Open/Closed Principle (OCP) e aplicando no frontend!

SOLID é um conjunto de princípios. Eles são principalmente diretrizes para profissionais de desenvolvimento de software que se preocupam com a qualidade, legibilidade e na manutenção do código. Neste artigo vamos falar sobre o principio Aberto/Fechado, explorar com analogias, exemplos em código e trazer para o lado do front-end como podemos aplicar esse princípio tão importante.

Do que se trata este princípio?

Muitos acham que o primeiro a escrever sobre este princípio foi Robert C. Martin no livro arquitetura limpa.

Mas antes Bertrand Meyer já tinha comentado sobre, em 1988 em seu livro Object-Oriented Software Construction . Ele explicou o Princípio Aberto/Fechado como:

"Entidades de software (classes, módulos, funções) devem ser abertas para extensão, mas fechadas para modificação."

O que ele queria dizer com isso? Bertrand estava dizendo que artefatos dentro do software deveriam permitir adição de funcionalidades sem alterar o código existente. Podemos utilizar uma analogia para entender melhor.

Pense em uma casa que está sendo construída. Ela deve ter um alicerce solido, estável. Depois precisamos ter as paredes, vigas e assim por diante conforme a definição da arquitetura da casa. Não concorda que cada etapa da construção é vista como adição de uma nova funcionalidade? Uma casa tem janelas que podem abrir e fechar. Portas também fazem isso. Mas e se ao introduzir a funcionalidade porta em algum quarto da casa, o engenheiro tenha que modificar o piso pela necessidade de adicionar a porta? Por que é preciso alterar uma parte da casa que já está pronta, apenas para incluir uma nova peça, que no caso é a porta? Provavelmente alguém falhou ao construir e moldar aquela parte do piso, o que afetou a implementação da porta para aquele casa.

Vamos trazer o exemplo acima para o contexto de software. Se toda vez que vamos inserir uma nova funcionalidade na aplicação, precisamos alterar outras partes do código e outros componentes, que as vezes não tem relação direta com o que precisamos fazer, nós falhamos ao criar o design daquele software.

Mas note que estamos falando sobre alterar (modificação), extender é algo diferente. Vamos entender melhor isso.

Extensão dos artefatos

Essa é uma das chaves da orientação a objetos, quando um novo comportamento ou funcionalidade precisar ser adicionado é esperado que as funções que já existem sejam estendidas e não alteradas, assim o código original permanece intacto e confiável enquanto o novo código será apenas para extensão. Quando precisar estender o comportamento de um código, criamos código novo ao invés de alterar o código existente. Mas como podemos extender? Vamos ver alguns exemplos.

Utilizando abstrações

Podemos definir abstração como sendo:

" Separar o que é importante dentro de um determinado contexto. Simplificar."

Liskov e Guttag, descrevem abstração no desenvolvimento de software como:

" O processo de abstração pode ser visto como uma aplicação de mapeamento muitos para um. Ela nos permite esquecer informações e, consequentemente, tratar coisas que são diferentes, como se fossem a mesma coisa. Fazemos isso na esperança de simplificar nossa análise separando atributos que são relevantes daqueles que não são. É crucial lembrar-se que relevância frequentemente depende de contexto. "

Então vamos entender como podemos utilizar abstrações para resolver as violações do Open/Closed. Veja abaixo no código este exemplo clássico que você provavelmente vai encontrar em outros artigos:

public class Arquivo
{
}

public class ArquivoWord : Arquivo
{
    public void GerarDocX()
    {
        // codigo para geracao do arquivo
    }
}

public class ArquivoPdf : Arquivo
{
    public void GerarPdf()
    {
        // codigo para geracao do arquivo
    }
}

public class GeradorDeArquivos
{
   public void GerarArquivos(IList<Arquivo> arquivos)
   {
      foreach(var arquivo in arquivos)
      {
         if (arquivo is ArquivoWord)
            ((ArquivoWord)arquivo).GerarDocX();
         else if (arquivo is ArquivoPdf)
            ((ArquivoPdf)arquivo).GerarPdf();
      }
   }
}

Temos classes que geram arquivos do Word e PDFs. E temos uma classe “GeradorDeArquivos” que recebe uma lista de arquivos e gera todos eles.

Mas e se precisarmos estender a aplicação para dar suporte a arquivos em outro formato, por exemplo, arquivos CSV e precisamos que o método “GerarArquivos” também gere arquivos no novo formato.

Seríamos obrigados a alterar o método GeradorDeArquivos() para atender a esse requisito. Talvez uma solução seria colocar mais um “else if”, checando pelo novo tipo (CSV) e chamando o método correspondente. Mas se sempre que precisarmos introduzir novos tipos alteramos o comportamento com mais um if-else a chance de introduzir um bug seria bem maior!

E por essa necessidade de sempre precisar alterar o método GeradorDeArquivos(), o exemplo acima viola o OCP, pois para mudanças do tipo “preciso de um novo formato de arquivo”, sempre precisamos alterar o método em si. Nunca conseguimos estender a funcionalidade, sempre precisamos adicionar uma nova condicional no método GeradorDeArquivos().

Vejamos como fica o código alterado para atender o OCP:

public abstract class Arquivo
{
    public abstract void Gerar();
}

public class ArquivoWord : Arquivo
{
    public override void Gerar()
    {
        // codigo para geracao do arquivo
    }
}

public class ArquivoPdf : Arquivo
{
    public override void Gerar()
    {
        // codigo para geracao do arquivo
    }
}

public class ArquivoCSV : Arquivo
{
    public override void Gerar()
    {
        // codigo para geracao do arquivo
    }
}

public class GeradorDeArquivos
{
   public void GerarArquivos(IList<Arquivo> arquivos)
   {
      foreach(var arquivo in arquivos)
        arquivo.Gerar();
   }
}

Com a refatoração Tornamos “Arquivo” uma classe abstrata, uma vez que não temos intenção de instanciá-la. Criamos um método abstrato para geração de arquivos na classe base. Fizemos com que as classes derivadas implementem o método “Gerar”. Introduzimos nosso novo requisito, ou seja a classe que é um novo tipo de arquivo (ArquivoCSV), o qual também herda de “Arquivo” e implementa “Gerar”. Eliminamos as checagens de tipo do método “GerarArquivos” e passamos a usar polimorfismo.

Então, sempre que surgir uma nova necessidade, nós conseguimos estender o comportamento de “GerarArquivos” (ele saberá gerar, por exemplo, qualquer outro novo arquivo) sem precisarmos alterar as implementações do método GeradorDeArquivos. Apenas criamos o arquivo novo e pronto.

Utilizando interfaces

Outra maneira de seguir o Open/Closed é utilizando interfaces e obrigando nossas classes a implementar as assinaturas. Mas note que a classe que vamos apenas estender nunca precisará sofrer modificações em código já existente. Vamos ver um exemplo:

interface LanguageInterface {
    sayHello(): string;
}

class French implements LanguageInterface {
    public sayHello(): string {
        return 'Bonjour';
    }
}

class Words {
    public say(lang: LanguageInterface): string {
        return lang.sayHello();
    }
}

let obj = new Words;
console.log(obj.say(new French));

No caso acima fica claro como é simples estender para outra lingua, por exemplo, Português ou Inglês, para dizer a palavra Olá. A classe Words não precisa implementar nenhuma interface, mas apenas receber por injeção de dependência LanguageInterface no método say(). E se desejar adicionar outra palavra e idioma? Veja como ficaria:

interface LanguageInterface {
    sayHello(): string;
    sayGoodbye(): string;
}

class French implements LanguageInterface {
   public sayGoodbye(): string {
   return 'Au revoir'
    }
    public sayHello(): string {
        return 'Bonjour';
    }
}

class Portugues implements LanguageInterface {
    public sayGoodbye(): string {
      return 'Tchau'
     }
    public sayHello(): string {
        return 'Olá';
    }
}

class Words {
    public hello(lang: LanguageInterface): string {
        return lang.sayHello();
    }

    public goodbye(lang: LanguageInterface): string {
      return lang.sayGoodbye();
    }
}

let obj = new Words;
console.log(obj.hello(new French));

Pode ser que você esteja pensando: Tenho que sempre implementar todas as assinaturas, mesmo se eu não for utilizar?

O que pode ser feito neste caso é segregar as interfaces, outro principio do SOLID. Então poderíamos ter duas interfaces que teriam assinaturas especificas para sayHello e sayGoodbye. Tudo vai depender do contexto do objeto que está manipulando. No nosso exemplo se na classe French não queremos que exista o método sayGoodbye, você poderia apenas implementar a interface especifica para o Hello:

interface HelloInterface {
    sayHello(): string;
}

interface GoodbyeInterface {
  sayGoodbye(): string;
}

class French implements HelloInterface {
    public sayHello(): string {
        return 'Bonjour';
    }
}

class Portugues implements HelloInterface, GoodbyeInterface {
    public sayGoodbye(): string {
      return 'Tchau'
     }
    public sayHello(): string {
        return 'Olá';
    }
}

class Words {
    public hello(lang: HelloInterface): string {
        return lang.sayHello();
    }

    public goodbye(lang: GoodbyeInterface): string {
      return lang.sayGoodbye();
    }
}

let obj = new Words;
console.log(obj.hello(new French));

Agora que entendemos melhor as bases do Principio Aberto/Fechado. Vamos explorar alguns exemplos no front-end.

Componentes no Frontend

Qualquer engenheiro de software que já trabalhou no frontend com qualquer que seja o framework provavelmente já passou ou vai passar pela situação que vamos comentar.

Imagine que você está trabalhando em um site e os stakeholders solicitam que você crie um widget que irá exibir algumas informações sobre algumas promoções que serão disponibilizadas. No momento você cria um componente apenas para isso:

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-widget',
  template: `
    <div class="header">
      <h1>Promoções</h1>
      <button mat-stroked-button (click)="promoOperation()">
        Confira essas promoções!
      </button>
    </div>
    <mat-divider></mat-divider>
    <ng-container *ngIf="widget === 'Phones'">
      <h5>Smartphones</h5>
      <section class="ex-widget">
      <mat-icon>stay_primary_portrait</mat-icon>
        <div class="value">25 por cento de desconto!</div>
      </section>
    </ng-container>
    <ng-container *ngIf="widget === 'TV'">
      <h5>TV's</h5>
      <section class="ex-widget">
      <mat-icon>tv</mat-icon>
      <div class="value">10 por cento de desconto!</div>
      </section>
    </ng-container>
  `,
  styles: [
    `
      :host {
        display: block;
        border: #f0ebeb solid 1px;
        border-radius: 5px;
        padding: 15px;
        background-color: #fafafa;
        width: 400px;
        margin-left: 20px;
      }
      .ex-widget {
        display: block;
        text-align: center;
        position: relative;
        min-width: 190px;
      }
      .header {
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
      .value {
        font-size: 24px;
        opacity: 0.7;
      }
    `,
  ],
})
export class WidgetComponent {
  @Input()
  widget: 'Phones' | 'TV' = 'Phones';
  promoOperation() {
    if(this.widget === 'Phones'){
      console.log('Lógica de redirecionamento para Phones...');
    } else {
      console.log('Lógica de redirecionamento para TV...');
    }
  }
}

Legal temos os dois widget's prontos. Tudo está em um componente só por enquanto e você conseguiu entregar rápido a tarefa. Mas depois de um ou dois dias os stakeholders voltam a solicitar alterações. Agora pedem que mais um widget para video games seja adicionado. Você adiciona mais um tipo e praticamente repete o código. Mas começa a perceber que o HTML está ficando extenso e que sempre precisa alterar o código já existente no componente para adicionar um novo requisito, ou seja, o componente está aberto para extensão mas para que isso ocorra é necessário modificar o código já existente! Então este componente viola o princípio OCP. Qual seria uma solução adequada?

Podemos isolar o componente widget e com base nele encapsular qualquer outro componente que precise exibir um conteúdo único. O método de redirecionamento agora pode se tornar abstrato ou até mesmo uma interface, assim as classes podem implementar e realizar a lógica de redirecionamento para as páginas que estão relacionadas com seu contexto. Vamos ver na prática.

Primeiro vamos criar uma interface que será utilizada no novos componentes de exibição:

export default interface PromoDetails {
 promoDetailRedirect(): string
}

Agora podemos iniciar a criação da classe/componente:

import { Component, OnInit } from '@angular/core';
import PromoDetails from './interfaces/promo-detail.inteface';

@Component({
  selector: 'app-smartphone-content',
  template: `
      <div class="component-adjustment">
      <button mat-stroked-button (click)="promoDetailRedirect()">
        Não perca tempo!
      </button>
    </div>
  <h5>Smartphones</h5>
    <section class="widget-content">
        <mat-icon>stay_primary_portrait</mat-icon>
        <div class="value">25 por cento de desconto!</div>
    </section>`,
  styleUrls: ['./widget-content.scss'],
})
export class SmartPhonesContentComponent implements OnInit, PromoDetails {
  constructor() { }
  promoDetailRedirect(): string {
    // lógica de redirecionamento
    console.log(`Redirecionando para SmartPhones`)

    return `Redirecionando para SmartPhones`
  }

  ngOnInit(): void { }
}

O mesmo se aplica para o componente que precisa ser criado para exibir o conteúdo das ofertas de TV e permitir o redirecionamento para a lista de Televisões com desconto:

import { Component, OnInit } from '@angular/core';
import PromoDetails from './interfaces/promo-detail.inteface';

@Component({
  selector: 'app-tv-content',
  template: ` 
      <div  class="component-adjustment">
      <button mat-stroked-button (click)="promoDetailRedirect()">
       Confira!
      </button>
    </div>
  <h5>TV</h5>
    <section class="widget-content">
      <mat-icon>tv</mat-icon>
      <div class="value">10 por cento de desconto!</div>
    </section>`,
  styleUrls: ['./widget-content.scss'],
})
export class TvContentComponent implements OnInit, PromoDetails {
  constructor() {}
  promoDetailRedirect(): string {
    console.log(`Lógica de redireciomanto para ofertas de TV`)
    return `Redirecionando para Ofertas de Televisões`
  }

  ngOnInit(): void {}
}

E como fica nosso componente widget? Veja como ele ficou:

import { Component } from '@angular/core';

@Component({
  selector: 'app-widget',
  template: `
    <div class="header">
      <h1>Promoções Imperdiveis</h1>
    </div>
    <mat-divider></mat-divider>
    <ng-content></ng-content>
  `,
  styles: [
    `
      :host {
        display: block;
        border: #f0ebeb solid 1px;
        border-radius: 5px;
        padding: 15px;
        background-color: #fafafa;
        width: 400px;
        margin-left: 20px;
      }
      .header {
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
    `,
  ],
})
export class WidgetComponent { }

Podemos declarar o HTML e CSS em arquivos separados, na verdade essa é uma forte recomendação, mas para que o artigo não se prolongue muito achei mais viável colocar tudo na classe. Legal agora podemos juntar tudo em um componente e encapsular os componentes de TV e SmartPhones com o Widget:

@Component({
  selector: 'app-root',
  template: `
    <mat-toolbar color="primary">
      <span>My App</span>
    </mat-toolbar>
    <main class="content">
      <app-widget>
        <app-tv-content></app-tv-content>
      </app-widget>
      <app-widget>
        <app-smartphone-content></app-smartphone-content>
      </app-widget>
      <app-widget>
        <p>Content is comming...</p>
      </app-widget>
    </main>
  `,
  styles: [
    `
      .content {
        background-color: #fff;
        padding: 2rem;
        height: calc(100vh - 64px);
        display: flex;
        box-sizing: border-box;
        justify-content: center;
        align-items: center;
      }
    `,
  ],
})
export class AppComponent {}

Agora toda vez que aquela equipe precisar adicionar algum tipo de widget novo basta apenas criar o componente que necessita exibir algum conteúdo dentro do widget, assim se evita ter que modificar WidgetComponent. O mesmo podemos dizer para interface PromoDetails, ela é implementada nas classes TvContentComponent e SmartphoneContentComponent e apenas adequamos a lógica para o contexto de cada componente e não precisamos realizar isso dentro de WidgetComponent como era feito antes! Veja visualmente os componentes:

Captura de Tela 2022-07-28 às 18.48.32.png

Caso tenha ficado alguma dúvida sobre essa implementação, fique à vontade para perguntar nos comentários! Fico por aqui neste artigo e agradeço muito por ler até o final.