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.
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:
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.