DEV Community ūüĎ©‚ÄćūüíĽūüĎ®‚ÄćūüíĽ

Cover image for SOLID com Typescript: O resumo completo com exercícios
Leonardo Bonetti
Leonardo Bonetti

Posted on

SOLID com Typescript: O resumo completo com exercícios

O que aprenderemos hoje?

Neste artigo, você conhecerá um conjunto de 5 princípios (S.O.L.I.D) fundamentais para o desenvolvimento de um sistema de fácil manutenção, extensão, incorporação e principalmente, compreensão.

Requisitos necess√°rios

Para total aproveitamento do conte√ļdo a seguir, √© indicado que voc√™ conhe√ßa o paradigma de programa√ß√£o orientado a objetos e tenha no√ß√Ķes de heran√ßa e polimorfismo.

Antes de começarmos...

√Č muito importante entendermos primeiramente o que √© Arquitetura de software, para isso, gosto de fazer um paralelo com a Arquitetura Civil, j√° que est√° em nosso dia a dia desde sempre e podemos identificar de forma r√°pida ao entrarmos em um ambiente bem-planejado que este foi projetado por um arquiteto, nos impressionamos com cada canto bem aproveitado, a disposi√ß√£o das luzes e elementos pelas paredes, assim como a conveni√™ncia de diversos recursos como pontos de energia e ilumina√ß√£o em cada lugar que podem se apresentar necess√°rios. Ao viver em um ambiente como o descrito, percebemos que acima da beleza, o valor de sua obra est√° em sua utilidade; O mesmo acontece com sistemas computacionais, se voc√™ vai passar meses ou anos trabalhando em um determinado sistema, voc√™ vai querer que ele tenha sido em primeiro lugar bem projetado e como um bom desenvolvedor, seu papel √© mant√™-lo assim, independentemente do quanto ele cres√ßa.

Como qualquer met√°fora, descrever software por meio das lentes da arquitetura pode esconder tanto quanto pode revelar, pode prometer mais do que entregar e entregar mais do que o prometido.
O apelo √≥bvio da arquitetura √© a estrutura, que domina os paradigmas e discuss√Ķes sobre o desenvolvimento de software - Componentes, classes, fun√ß√Ķes, m√≥dulos, camadas e servi√ßos, micro ou macro. No entanto, muitas vezes, √© dif√≠cil confirmar ou compreender a estrutura bruta de v√°rios sistemas de software - esquemas corporativos ao estilo sovi√©tico em vias de se tornarem legado, improv√°veis torres de equil√≠brio se estendendo em dire√ß√£o a nuvem, camadas arqueol√≥gicas enterradas em um slide que parece uma imensa bola de lama. Pelo jeito, a estrutura de um software n√£o √© t√£o intuitiva quanto a de um pr√©dio.
(MARTIN, R.C. Arquitetura limpa: O guia do artes√£o para a estrutura e design de software. [S.l.]: ALTA BOOKS, 2019.)

Na arquitetura civil existem limita√ß√Ķes f√≠sicas que impedem os profissionais de realizarem certas a√ß√Ķes, por√©m no mundo virtual os limites podem n√£o ser t√£o claros, assim como Robert C. Martin descreve no trecho acima. Os paradigmas de programa√ß√£o v√™m ent√£o com mais uma proposta do que n√£o devemos fazer do que o que devemos fazer, escolher um paradigma √© escolher os limites que o mesmo imp√Ķe, se voc√™ escolher o paradigma orientado a objetos, o SOLID √© uma adi√ß√£o muito bem-vinda a esses limites.

Vamos aos SOLID

O SOLID foi apresentado por Robert C. Martin em um artigo dos anos 2000 com o t√≠tulo "Postulados de Projeto e Padr√Ķes de Projeto", mas onde ele realmente brilhou foi em seu livro "Arquitetura Limpa", por isso ser√° citado diversas vezes neste conte√ļdo.

SRP (Single Responsibility Principle)

Um corolário ativo da lei de Conway: A melhor estrutura para um sistema de software deve ser altamente influenciada pela estrutura social da organização que o utiliza, de modo que cada módulo de software tenha uma, e apenas uma, razão para mudar.
(MARTIN, Arquitetura limpa. p√°gina 102)

Você está desenvolvendo um sistema de lembretes para a Apple e se depara com a seguinte classe:

class Reminder {
    public content: string;
    public id: string;
    public date: Date;
    ...

    constructor(private readonly db: Database) {}

    ...
    public async getTags() {
        let tokens = this.content.split(" ")
        return tokens.filter(t => t[0] === "#")
    }
    public async store() {
        this.db.create({content: this.content, id: this.id, date: this.date})
    }
}
Enter fullscreen mode Exit fullscreen mode

Para olhos mal treinados, pode n√£o ter nada de errado aqui, nossa classe de lembrete tem uma funcionalidade para salvar em um banco de dados e outra para retornar as hashtags do content.
Retornar as tags √© uma responsabilidade √ļnica e exclusiva dos lembretes, mas salvar um conte√ļdo em um banco de dados n√£o √©.
Aqui temos o caso cl√°ssico de que se existe uma a√ß√£o dispon√≠vel a uma entidade, essa entidade deve implementar essa a√ß√£o, isso esta completamente errado, pois dezenas de outras entidades podem precisar salvar seus conte√ļdos no banco de dados e se por algum motivo nossa implementa√ß√£o do banco mudar da fun√ß√£o CREATE para SET, por exemplo, ter√≠amos de alterar todas essas entidades, ent√£o para corrigir isso, vamos ver o exemplo abaixo:


class Reminder {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public async getTags() {
        let tokens = this.content.split(" ")
        return tokens.filter(t => t[0] === "#")
    }
}

class Storage {
    constructor(private readonly db: Database) {}

    public async storeReminder(data: Reminder) {
        this.db.create(data)
    }
}
Enter fullscreen mode Exit fullscreen mode

Agora temos uma nova classe chamada Storage, ela √© a respons√°vel por salvar objetos no banco, caso dezenas de classes precisem utilizar este recurso √© a classe Storage quem vai lidar com isso, assim, o √ļnico motivo para Storage mudar √© se mudarmos a implementa√ß√£o do banco de dados e o √ļnico para Reminders mudar √© se mudarmos as regras de tags.

Existe uma confus√£o muito comum quando falamos do princ√≠pio da responsabilidade √ļnica, que muitos desenvolvedores acreditam que uma classe deve fazer apenas uma √ļnica coisa e isso tamb√©m est√° completamente errado, uma classe deve ter apenas um motivo para mudar e isso significa que deve atender a uma √ļnica regra de um determinado ator (ator √© quem interage com o sistema, podendo ser um usu√°rio, administrador, outro sistema, stakeholder ou at√© mesmo outras classes.)

Sintomas do descumprimento SRP

Duplicação Acidental

Digamos que a Apple te pediu para fazer um sistema de cálculo de horários, onde o RH e o financeiro serão os clientes, então você escreve o seguinte código:

class Admin {
    calculateWorkHours() {}
}
class RH extends Admin {}
class Financial extends Admin {}
Enter fullscreen mode Exit fullscreen mode

Depois de pronto, o sistema come√ßa a dar problemas, por que o RH considera horas trabalhadas o hor√°rio que o funcion√°rio entrou e saiu da empresa, enquanto o financeiro considera apenas o hor√°rio que ele ficou no computador, desconsiderando as pausas, se voc√™ alterar a classe admin, um dos dois times ser√° afetado. Para resolver esta quest√£o voc√™ precisa reimplementar as fun√ß√Ķes da seguinte forma:

class Admin {
    ...
}
class RH extends Admin {
    calculateWorkHours()
}
class Financial extends Admin {
    calculateWorkHours()
}
Enter fullscreen mode Exit fullscreen mode

Nesse caso, fizemos o oposto da classe de lembretes, j√° que o c√°lculo de horas √© de responsabilidade √ļnica de cada um dos atores

Fus√Ķes

Sabe quando você trabalhou dias em uma determinada funcionalidade para seu sistema e com o trabalho pronto, finalmente abre um pull request para anexá-lo a branch principal, mas recebe aquela mensagem super desagradável de um conflito que o impede?
Pois √©, na maioria dos casos isso acontece quando o SRP n√£o √© respeitado, sendo assim existe uma classe que atende a mais de um ator do sistema, e s√£o eles quem definem as tarefas, ou seja, mais de um desenvolvedor pode ser solicitado a realizar altera√ß√Ķes/adi√ß√Ķes nesse m√≥dulo, e quando isso acontece, na hora de anexar a atividade, se depara com este conflito.

O Princ√≠pio da Responsabilidade √önica visa simplesmente reduzir ao m√°ximo a necessidade de alterar uma determinada classe e que altera√ß√Ķes em terceiros n√£o reflitam em outras classes.

OCP (Open-Close Principle)

Bertrand Meyer popularizou este princípio na década de 1980. Em essência, para que os sistemas de software sejam fáceis de mudar, eles devem ser projetados de modo a permitirem que o comportamento desses sistemas mude pela adição de um novo código em vez da alteração do código existente. (MARTIN, Arquitetura limpa. página 103)

Vamos utilizar a resolução do SRP com a classe Reminder e Storage e adicionando uma nova classe para entendermos melhor esse conceito.


// Nova classe
class Note {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public Increment(data: string) {
        this.content += data
    }
}

class Reminder {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public async getTags() {
        let tokens = this.content.split(" ")
        return tokens.filter(t => t[0] === "#")
    }
}

class Storage {
    constructor(private readonly db: Database) {}

    public async storeReminder(data: Reminder) {
        this.db.create(data)
    }

    // Alteração necessária
    public async storeNote(data: Note) {
        this.db.create(data)
    }
}
Enter fullscreen mode Exit fullscreen mode

A Apple pediu que voc√™ adicionasse uma nova funcionalidade ao sistema de Lembretes, agora os usu√°rios podem criar notas que podem ser incrementadas a qualquer momento, por isso, a mesma possui uma fun√ß√£o chamada Increment, assim como Reminder, o conte√ļdo da classe Note tamb√©m pode ser armazenado no banco de dados, por isso, criamos mais uma fun√ß√£o na classe Storage para fazer tal tarefa.
Aqui criamos uma funcionalidade para nosso sistema (class Notes) e alteramos uma j√° existente (class Storage), vamos ver o que Martin tem a dizer sobre isso:

Um artefato de software deve ser aberto para extensão, mas fechado para a modificação. Em outras palavras, o comportamento de um atefato de software deve ser extensível sem isso modificar esse artefato. (MARTIN, Arquitetura limpa. Página 114)
Bom, agora est√° claro que o problema est√° na classe Storage, porque toda a vez que incrementarmos nosso sistema para alguma entidade que precise ser armazenada em um banco de dados, vamos precisar alterar o storage, e isso mostra a import√Ęncia desse princ√≠pio, voc√™ j√° deve ter visto ou ouvido falar de sistemas t√£o grandes e legados, que quanto mais ele cresce, mais os desenvolvedores demoram para implementar novas funcionalidades.
Evidentemente, essa √© a principal raz√£o de estudarmos arquitetura de software. De forma clara, quando extens√Ķes simples nos requisitos for√ßam mudan√ßas massivas no software, os arquitetos desse sistema de software est√£o em meio a um fracasso espetacular. (MARTIN, Arquitetura limpa. P√°gina 115)
Vamos tentar resolver esse problema:

// Nova interface
interface CanStore {
    getContent(): {id: string, content: string, date: Date}
}

class Note implements CanStore {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public Increment(data: string) {
        this.content += data
    }

    public getContent() {
        return {content: this.content, id: this.id, date: this.date}
    }
}

class Reminder implements CanStore {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public async getTags() {
        let tokens = this.content.split(" ")
        return tokens.filter(t => t[0] === "#")
    }

    public getContent() {
        return {content: this.content, id: this.id, date: this.date}
    }
}

class Storage {
    constructor(private readonly db: Database) {}

    public async store(data: CanStore) {
        this.db.create(data.getContent())
    }
}
Enter fullscreen mode Exit fullscreen mode

Criamos ent√£o uma interface que tem de ser respeitada por qualquer entidade que queira armazenar seus dados no banco (interface CanStore) - em muitas literaturas sobre SOLID voc√™ vai se deparar com o autor falando que peda√ßos de software t√™m de assumir um contrato, e por contrato no typescript estamos falando de interfaces e assumir o mesmo, estamos nos referindo a implement√°-las - que possui uma √ļnica fun√ß√£o chamada getContent com um retorno definido por um conte√ļdo que √© uma string, um identificador √ļnico que √© um ID e uma data que √© um Date, assim qualquer classe que queira ser compat√≠vel com Storage precisa apenas implementar essa interface e criar a fun√ß√£o de forma que o retorno seja compat√≠vel com o esperado, assim, qualquer nova implementa√ß√£o no sistema n√£o precisar√° alterar a classe Storage e pode crescer de maneira simples sem necessidade de altera√ß√Ķes em outros m√≥dulos.
Vamos ver se isso realmente funciona com mais um exemplo:
A Apple agora pediu para você criar uma funcionalidade onde o usuário pode adicionar links com comentários, então criamos a seguite funcionalidade:

interface CanStore {
    getContent(): {id: string, content: string, date: Date}
}

// Nova funcionalidade
class Links implements CanStore {
    public link: string;
    public coment: string;
    ...
    public getContent() {
        return {content: this.coment, id: this.link, date: new Date()}
    }
}

class Note implements CanStore {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public Increment(data: string) {
        this.content += data
    }

    public getContent() {
        return {content: this.content, id: this.id, date: this.date}
    }
}

class Reminder implements CanStore {
    public content: string;
    public id: string;
    public date: Date;

    ...
    public async getTags() {
        let tokens = this.content.split(" ")
        return tokens.filter(t => t[0] === "#")
    }

    public getContent() {
        return {content: this.content, id: this.id, date: this.date}
    }
}

class Storage {
    constructor(private readonly db: Database) {}

    public async store(data: CanStore) {
        this.db.create(data.getContent())
    }
}
Enter fullscreen mode Exit fullscreen mode

Percebam que adicionamos apenas a classe Links e formatamos o retorno da função getStorage, sem precisar mexer em mais nenhum lugar do sistema!

O OCP é uma das forças motrizes por trás da arquitetura de sistemas. Seu objetivo consiste em fazer com que o sistema seja fácil de estender sem que a mudança cause um alto impacto. Para concretizar esse objetivo, particionamos o sistema em componentes e organizamos esses componentes em uma hierarquia de dependência que proteja os componentes de nível mais alto das mudanças em componentes de nível mais baixo. (MARTIN, Arquitetura limpa. Página 115)

Adicionar a fun√ß√£o getContent em todas as classes n√£o fere o princ√≠pio SRP (de respons√°bildiade √ļnica)?
N√£o, pois armazenar o dado no banco continua sendo tarefa √ļnica e exclusiva da classe Storage, mas a formata√ß√£o dos dados para ir at√© o Storage √© de respons√°bilidade de cada classe que implementa CanStore, afinal de contas, cada uma ter√° um tipo de dado diferente que ser√° traduzido para o formato de getContent, como a tradu√ß√£o ser√° feita √© responsabilidade de cada classe.

O princípio do OCP visa simplesmente prevenir a criação de sistemas super dependentes onde no inicio é muito simples implementar uma feature, mas com o passar do tempo, por mais simples que seja, novas funcionalidades se tornam uma baita dor de cabeça.

Sintomas do descumprimento

O principal sintoma do descumprimento do OCP são sistemas de difícil manutenção e evolução, é uma regra tão comumente ignorada que não é raro encontrarmos empresas que sofrem com o orçamento e o tempo para evoluírem seus sistemas, isso cria uma necessidade constante de refatoração.

Um sistema que precisa ter módulos refatorados não é necessariamente um sistema com uma arquitetura ruim, mas se essa necessidade se torna constante, temos uma prova de que um dos principais princípios do SOLID foi ignorado.

Os resultados disso no ponto de vista comercial s√£o terr√≠veis, pois o dinamismo do mercado √© alto, sendo assim, a dire√ß√£o do neg√≥cio precisa ter um prazo √≥timo para acompanhar suas mudan√ßas e aproveitar oportunidades, se o cerne da empresa √© a tecnologia, essa carga √© passada diretamente a seus desenvolvedores e sem uma boa arquitetura fica cada vez mais invi√°vel realizar as implementa√ß√Ķes necess√°rias, at√© que a empresa colapsa por conta de o or√ßamento ter de ser constantemente expandido at√© o momento em que se torna mais caro realizar implementa√ß√Ķes no sistema do que o retorno que as mesmas trazem.

Para qualquer empresa que dependa de tecnologia, a arquitetura de sistemas é um tópico que pode colocá-la a frente de sua concorrência ou até mesmo acabar com elas, e como dito por Robert C. Martin "O OCP é uma das forças motrizes por trás da arquitetura de sistemas".

LSP (Liskov Substitution Principle)

Para cada objeto o1 de tipo S, houver um objeto o2 de tipo T, de modo que, para todos os programas P definidos em termos de T, o comportamento de P não seja modificado quando o1 for substituído por o2, então S é um subtipo de T. (MARTIN, Arquitetura limpa. Página 123)

Essa definição do LSP feita por sua criadora, Barbara Liskov, pode muitas vezes confundir mais do que esclarecer, então vamos tentar repensar da seguinte forma:
"Classes que herdam funcionalidades de outras classes de nível mais baixo, devem conseguir utilizarem as funcionalidades da de nível inferior sem necessariamente conhecer ou alterar seu comportamento."

Para entendermos a definição anterior, vamos olhar novamente nossa classe de contagem de horas:

class Admin {
    ...
}
class RH extends Admin {
    calculateWorkHours() {}
}
class Financial extends Admin {
    calculateWorkHours() {}
}
Enter fullscreen mode Exit fullscreen mode

As classes de RH e Financial tem suas pr√≥prias fun√ß√Ķes para calcular as horas de trabalho de cada funcion√°rio e isso est√° certo pelo princ√≠pio de Liskov, mas vamos imaginar que para solucionar aquele problema do c√°lculo diferir do RH para o Financeiro, o desenvolvedor respons√°vel decide fazer a seguinte implementa√ß√£o

class Admin {
    ...
    calculateWorkHours() {}
}
class RH extends Admin {}

class Financial extends Admin {
    calculateWorkHours() {}
}
Enter fullscreen mode Exit fullscreen mode

No caso, o m√©todo original definido na classe Admin atendia ao RH, mas quando foi necess√°rio que o financeiro fizesse uma conta diferente, ele simplesmente herdou a classe Admin e reescreveu a fun√ß√£o de calcular horas, fazendo o famoso overriding super class method. Isso fere a defini√ß√£o acima, pois a classe Financial est√° estendendo Admin e n√£o utilizando o √ļnico m√©todo √ļtil para ele (calculateWorkHours), ent√£o literalmente n√£o faz sentido estender Admin, pois estamos alterando sua l√≥gica.
Voltando a nossa defini√ß√£o, o caso correto de se estender Admin est√° na classe RH, pois ela n√£o precisa conhecer a l√≥gica usada em calculateWorkHours do Admin, ela simplesmente sabe que o m√©todo retorna um n√ļmero e isso √© suficiente para ela, ou seja, RH √© um subtipo correto de Admin.

Então segundo o princípio de Liskov, sempre está errado sobrescrever um método da classe que você estende?
N√£o, pois se Financial estivesse utilizando mais de um m√©todo de Admin da forma correta e o √ļnico caso que ele precise mudar est√° na fun√ß√£o calculateWorkHours, ent√£o, tudo bem segundo Liskov, mas isso vai ferir outro princ√≠pio (ISP) que veremos a seguir.

Sintomas do descumprimento

Para entendermos ainda melhor o princ√≠pio de Liskov, vamos a um exemplo um pouco mais pr√≥ximo ao que Robert C. Martin prop√Ķe:

Digamos que voc√™ criou um gateway de pagamentos sensacional, onde qualquer um que tenha um sistema que necessite receber notifica√ß√Ķes sobre transa√ß√Ķes em sua conta realiza o cadastro em seu sistema e adiciona um link para receber as notifica√ß√Ķes de movimenta√ß√£o em sua conta, como um WebHook.
Um dia, um investidor da sua empresa chamado Alan contrata uma equipe de desenvolvedores para criar um sistema que se integra ao seu. Após finalizada a integração eles alegam que o sistema não funciona e explorando melhor o caso, você percebe que não leram sua documentação e o sistema do cliente espera receber campos na requisição com nomes diferentes do que o seu sistema está programado.

Para ajudar, voc√™ decide criar uma classe especial apenas para disparar requisi√ß√Ķes a esse cliente em espec√≠fico:

  • A classe Dispatch que faz as requisi√ß√Ķes
  • Voc√™ cria uma classe DispatchToAlan que √© um subtipo de Dispatch porem altera o nome dos campos de envio. Bom, isso se resolve facilmente com um if certo? Sim, se resolve apenas com uma simples condicional, mas imagine que sua empresa comece a atrair cada vez mais investidores e de novo esse problema se repete, como voc√™ abriu um precedente com o desenvolvedor anterior, come√ßa a criar cada vez mais subtipos de Dispatch para atender as necessidades de novos clientes.

Aqui podemos tirar algumas conclus√Ķes, a primeira √© que sua documenta√ß√£o deve estar horr√≠vel para que ningu√©m entenda e programe os campos com os nomes corretos para o recebimento, mas pior ainda, √© que nenhum dos Subtipos de Dispatch que voc√™ est√° criando √© v√°lido, pois todos eles precisam alterar a l√≥gica da classe original para atender aos clientes.

Isso fere gravemente o princípio do OCP, pois como esses subtipos de Dispatch alteram sua lógica em algum nível, se Dispatch for alterado por alguma razão, você pode precisar alterar todos esses subtipos, o que também fere o SRP, pois essas classes passam a ter mais de uma razão para mudar (Sendo elas a primeira que é válida, caso o seu cliente mude o tipo de recebimento e a segunda inválida, caso você mude o comportamento da classe original).

O LSP pode, e deve ser estendido ao nível da arquitetura. Uma simples violação da capacidade de substituição pode contaminar a arquitetura do sistema com uma quantidade significante de mecanismos extras. (MARTIN, Arquitetura limpa. Página 123)

ISP (Interface Segregation Principle)

Este princípio orienta que os projetistas de software evitem depender de coisas que não usam. (MARTIN, Arquitetura limpa. Página 103)

Sempre √© melhor criarmos v√°rias interfaces para diferentes implementa√ß√Ķes do que apenas uma generalista.

interface Admin {
    payEmployees(): void
    updateInfo(): void
    getLatestPayments(): Payments[]
    calculateWorkHours(): number
}
class RH implements Admin {
    payEmployees() {
        throw new Error("Function not available for this actor");
    }
    updateInfo() {
        // realiza a tarefa
    }
    getLatestPayments() {
        throw new Error("Function not available for this actor");
    }
    calculateWorkHours() {
        // realiza a tarefa
    }

}
class Financial implements Admin {
    payEmployees() {
        // realiza a tarefa
    }
    updateInfo() {
        throw new Error("Function not available for this actor");
    }
    getLatestPayments() {
        // realiza a tarefa
    }
    calculateWorkHours() {
        // realiza a tarefa
    }
}
Enter fullscreen mode Exit fullscreen mode

O que era nossa classe Admin se tornou uma interface a ser implementada, e essa interface declara que Administradores podem pagar funcion√°rios, atualizar suas informa√ß√Ķes, buscar os √ļltimos pagamentos e calcular suas horas de trabalho.
O problema aqui √© que nem todos os Administradores podem realizar todas as tarefas, ent√£o no caso do RH, que n√£o pode pagar funcion√°rios ou buscar os √ļltimos pagamentos, temos que disparar um erro caso a fun√ß√£o seja chamada, o mesmo para o Financeiro que n√£o pode atualizar as informa√ß√Ķes do profissional.
√Č sempre muito prejudicial depender de m√≥dulos que contenham mais do que voc√™ precisa, isso pode ocasionar bugs s√©rios em seus sistemas por uma falta de aten√ß√£o de algum desenvolvedor que n√£o sobrescreveu, al√©m de problemas de seguran√ßa e muitas vezes ferir principalmente os princ√≠pios do OCP e SRP, por que, afinal de contas, se mais classes implementam Admin e voc√™ precisa alterar o Admin, vai precisar alterar as classes (que fere o OCP), ter muita coisa embarcada no Admin vai fazer com que ele tenha mais de um motivo para ser alterado (J√° que v√°rios atores o implementam e isso fere o SRP).

A solução para isso é a Segregação de interfaces, como diz o próprio nome do princípio:

    interface CanPay {
        payEmployees(): void
    }
    interface CanUpdate {
        updateInfo(): void
    }
    interface CanSearchPayments {
        getLatestPayments(): Payments[]
    }
    interface CanCalculate {
        calculateWorkHours(): number
    }

    class RH implements CanUpdate, CanCalculate {
        updateInfo() {
            // realiza a tarefa
        }
        calculateWorkHours() {
            // realiza a tarefa
        }
    }

    class Financial implements CanPay, CanSearchPayments, CanCalculate {
        payEmployees() {
            // realiza a tarefa
        }
        getLatestPayments() {
            // realiza a tarefa
        }
        calculateWorkHours() {
            // realiza a tarefa
        }
    }
Enter fullscreen mode Exit fullscreen mode

Observem que dessa forma cada classe implementa apenas o que conv√©m a ela, e al√©m de tornar nosso c√≥digo muito mais leg√≠vel e intuitivo, o torna mais seguro, previne erros inesperados e que quebremos os princ√≠pios de OCP (n√£o temos de alterar protocolos ou classes a cada nova implementa√ß√£o), SRP (cada classe implementa apenas o que √© de sua √ļnica responsabilidade), e LSP (por n√£o termos de sobrescrever funcionalidades).

Depender de algo que contém itens desnecessários pode causar problemas inesperados.

Sintomas do descumprimento

A dificuldade de compreensão de um sistema é um grave problema, pois o tempo de evolução do mesmo se torna muito maior e a curva de aprendizagem de novos desenvolvedores cresce com a complexidade desnecessária.
Depender de modulos desnecessários abre uma brexa de segurança muito grave, como podemos ver na citação abaixo:

Considere um arquiteto que está trabalhando em um sistema chamado S, em que deseja incluir o framework F. Agora, suponha que os próprios autores ligaram F a um banco de dados D específico. Então S depende de F que depende de D.
Agora, suponha que D contenha recursos que F não usa e que, portanto, não são essenciais a S. Qualquer mudança nesses recursos de D pode muito bem forçar a reimplantação de F e, por extensão, a reimplantação de S.
Pior ainda, uma falha em um dos recursos de D pode causar falhas em F e S. (MARTIN, Arquitetura limpa. P√°gina 132)

DIP (Dependency Inversion Principle)

Segundo o princ√≠pio da invers√£o de depend√™ncia DIP, os sistemas mais flex√≠veis s√£o aqueles em que as depend√™ncias de c√≥digo-fonte se referem apenas a abstra√ß√Ķes e n√£o a itens concretos (MARTIN, Arquitetura limpa. P√°gina 135)

Acredito que a definição de Martin para este princípio seja a mais direta e compreensível de todas, ainda mais para nós que trabalhamos com Typescript, e dado que já conhecemos todos os outros princípios, este será mais fácil, vamos ver o exemplo a seguir:


    class Storage {
        constructor(private readonly seq: Sequelize) {}

        public async store(data: CanStore) {
            this.seq.query(data.getContent())
    }
}
Enter fullscreen mode Exit fullscreen mode

image

Aqui vemos que Storage √© inicializado com Sequelize, que por sua vez √© uma implementa√ß√£o concreta de intera√ß√Ķes com o banco de dados, que n√£o est√° em nosso controle mudar, ou prevenir que a mesma mude.
Storage está em nossa regra de negócio (Domain), e o Sequelize está em nossa Infraestrutura (Infra), percebam que a Infra está apontando para Domain, e isso significa que nossa regra de negócio depende da nossa infraestrutura, isso é terrível, pois, a Domain é definido pelo cliente do sistema, é nossa regra base, se a tecnologia que usamos influencia nisso, então quer dizer que mudanças nessa tecnologia podem mudar nosso negócio, idealmente é nosso negócio que deve determinar as tecnologias a serem usadas e não depender delas.
O DIP nos ajuda a solucionar esse problema da seguinte forma:

image

Veja a codificação da figura acima:


    class DatabaseAdapter implements Database {
        constructor(private readonly seq: Sequelize) {}
        async set(data: Data): Promise<void> {
            // insere dados no banco
        }
        async get(id: string): Promise<DbReturn> {
            // busca dado pelo id
        }
    }

    interface Database {
        set(data: Data): Promise<void>
        get(id: string): Promise<DbReturn>
    }

    class Storage {
        constructor(private readonly db: Database) {}

        public async store(data: CanStore) {
            this.db.set(data.getContent())
    }
}
Enter fullscreen mode Exit fullscreen mode

Percebam que agora temos um protocolo (Interface Database) para como devemos lidar com o banco de dados em si, e nossa classe Storage aponta para este protocolo, a qual é uma abstração, também criamos a classe DatabaseAdapter que implementa este protocolo, isso tudo significa que caso seja necessário trocar o ORM que estamos utilizando, quem deve fazer isso é a classe DatabaseAdapter que respeita o contrato definido em Database, mas nenhuma alteração será necessária em Storage.

Ah, mas se a tecnologia de banco de dados mudar, vamos precisar alterar a classe DatabaseAdapter, e isso fere o princípio OCP, não?
N√£o, por conta do SRP, pois a classe DatabaseAdapter s√≥ tem uma √ļnica raz√£o para mudar, o banco de dados que estamos utilizando.

Extra:
Para sistemas que aderem ao DIP, é muito comum vermos Factories para construir as classes, do tipo:

const makeStorage = (): Storage {
    const db = new DatabaseAdapter(Sequelize)
    return new Storage(db)
}
Enter fullscreen mode Exit fullscreen mode

E ao ter muitas classes que dependem de v√°rios adaptadores, pode ficar muito chato montar essas factories, por isso recomendo que voc√™ de uma olhada no Inversify, existe uma curva de aprendizado para usar a ferramenta, mas com o conhecimento que voc√™ adquiriu durante este conte√ļdo, pode ser uma √≥tima adi√ß√£o a sua Stack.

Sintomas do descumprimento

Altera√ß√Ķes em modulos n√£o essenciais necessitam de altera√ß√Ķes em m√≥dulos essenciais.
O que quero dizer é que seu negócio se torna dependente de suas ferramentas, sobre o seu negócio você tem controle, sobre as ferramentas que você utiliza, não, então depender delas é literalmente um tiro no pé.

Finalmente terminamos de ver todos os princípios do SOLID, e agora no final fica claro como os mesmos se complementam, mas para realmente fixarmos esse conhecimento, vamos praticar um pouco.

Exercícios

1) Um novo departamento é criado dentro da Apple e dois desenvolvedores são encarregados de criar funcionalidades que atendam a este novo departamento. As demandas dependem da integração de um banco de dados não relacional e do sistema de autenticação que já existe, então eles criam uma interface que define as funcionalidades desse novo elemento na infraestrutura, programam uma classe que atenda essa interface, então criam a classe que atende, de fato, o novo departamento, esta, é inicializada com a classe abstrata do novo banco de dados.
Eles percebem que vão precisar alterar a classe de autenticação para que possam utilizar seus recursos junto a classe do novo departamento.
Os desenvolvedores est√£o enfrentando este problema por que algum principio do SOLID foi ignorado, qual foi?

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP Ver resposta OCP

2) Uma empresa percebe que está gastando muito com uma ferramenta que faz buscas em seu banco de dados não relacional, então decidem mudar para outra mais barata, porém, os desenvolvedores informam que vão precisar reestruturar a regra de negócio do sistema por causa disso, qual princípio do SOLID foi violado nesse caso?

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP
Ver resposta DIP

3) Um hacker invadiu o sistema da Apple conseguindo acesso a uma conta de RH. Ele fez movimenta√ß√Ķes financeiras que n√£o deveriam ser permitidas ao RH, isso s√≥ foi possivel porque um desenvolvedor extendeu a classe Administrativa que permitia essas opera√ß√Ķes e n√£o sobrescreveu as funcionalidades de movimenta√ß√£o financeira para o RH, deixando a brecha que resultou nesse prejuizo, qual princ√≠pio do SOLID teria prevenido o ocorrido?

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP
Ver resposta ISP

4) Você está passando por uma terrível dor de cabeça, pois toda vez que uma nova demanda de funcionalidade é destinada ao sistema em que trabalha é necessário mudar a classe responsável por registrar os logs do sistema, isso acontece por que a classe de Logs não está respeitando qual princípio?

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP
Ver resposta SRP

5) Sobrescrever todas as funcionalidades de uma classe que você está estendendo não faz sentido, mas isso acontece em diversos sistemas onde os arquitetos não fazem um bom trabalho na hora de relacionar suas entidades, para estes arquitetos, qual principio do SOLID está sendo violado?

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP
Ver resposta LSP

SOLID na pr√°tica

Conhecer os princ√≠pios do SOLID √© o primeiro passo para se tornar um bom arquiteto de sistemas, mas para o total aproveitamento destes fundamentos, voc√™ deve conseguir identificar suas aplica√ß√Ķes de forma r√°pida e intuitiva, para que isso aconte√ßa, precisamos praticar.
Aqui proponho uma linha de raciocínio que pode facilitar muito seu trabalho.

Defina seus Atores

Como vimos durante o SRP, os atores são as chaves da nossa construção, o sistema é desenvolvido para eles e os módulos de alto nível que desenvolvermos devem ter a propriedade de só serem modificados mediante a necessidade de um e apenas um destes atores.
Ou seja, nunca comece a desenvolver um sistema perguntando o que ele vai fazer, mas sim quem vai utiliz√°-lo

Vamos ent√£o imaginar o desenvolvimento de um sistema para um e-commerce.

Quem vai utiliz√°-lo?

  • Consumidor
  • Operador
  • Gerente

Nesse cen√°rio, quais s√£o as atribui√ß√Ķes de cada um destes atores?

Consumidor

Quer acessar o sistema para olhar cat√°logos e comprar itens.

Operador

Insere novos produtos no sistema e pode atualizar seus pre√ßos e informa√ß√Ķes.

Gerente

Existe para controlar as propriedades do sistema, pode adicionar novos operadores ou removê-los e alterar as taxas globais para compra de produtos.

Dessa forma, já estabelecemos nossas entidades mais primitivas e o que podem fazer em nosso sistema, traduzindo para o código:

    interface Customer {
        getProducts(start: number, end: number): Promise<Products[]>
        buy(productId: string): Promise<boolean>
    }

    interface Operator {
        addProduct(product: Product): Promise<boolean>
        updateProduct(product: Product): Promise<boolean>
    }

    interface Manager {
        addOperator(userId: string): Promise<boolean>
        removeOperator(userId: string): Promise<boolean>
        changeFees(fee: number): Promise<boolean>
    }
Enter fullscreen mode Exit fullscreen mode

Aqui começamos a rascunhar o sistema e como ele deve se comportar, mas ainda faltam requisitos, principalmente os de caso base, por exemplo, como cadastrar um gerente na plataforma? Já que dependemos dele para cadastrar operadores e os clientes dependem dos operadores para ter produtos a serem visualizados e comprados.

Uma coisa podemos dizer que os 3 atores tem em comum, todos s√£o usu√°rios, que precisam se cadastrar, trocar suas senhas, caso necess√°rio, verificarem seus perfis, etc.
Vamos desenhar uma interface para usu√°rio:

    interface User {
        register(name: string, email: string, password: string): Promise<string>
        changePassword(userId: string, newPassword: string): Promise<boolean>
    }
Enter fullscreen mode Exit fullscreen mode

Estaria correto dizer que Customer, Operator e Manager s√£o subtipos v√°lidos de User?

Isso quem nos responde é Barbara Liskov com o LSP, pois o comportamento de User não depende de maneira alguma do comportamento de nenhum dos 3 atores, o que significa que os mesmos não precisam sobrescrevê-los e sim, são subtipos válidos.

Agora temos então 4 módulos de sistema que respeitam o SRP, pois para o caso dos 3 atores apenas eles podem alterar seus comportamentos e para o caso do User, o comportamento só seria alterado se e apenas se a regra de usuários geral do sistema fosse alterada.

Ainda falta uma pergunta, se adicionarmos mais um Ator a este sistema, como um Coordenador, por exemplo, algum outro módulo teria de ser alterado para que o mesmo fosse feito? Ou se alterassemos o User, seria necessária uma alteração de seus subtipos?
Não, um Coordenador estenderia o User, e para todos os Atores que até o momento implementam User, não precisam sobrescrevê-lo, então estamos respeitando o OCP até o momento.

Apenas nesse pequeno exercício já resolvemos 3 princípios: SRP, OCP e LSP.

Entenda suas dependências

Cuidado com essa etapa, entender as dependências de um sistema não é escolher as ferramentas dele, mas sim o que essas ferramentas precisam ter para nos atender.

A dependência mais intuitiva que podemos ver aqui é um lugar para registrarmos nossos dados, então precisamos criar um tipo para definir o que utilizaremos de um banco de dados, vamos analisar ator por ator e entender suas necessidades:

  • Customer: Leitura e Escrita (Afinal de contas, a compra tem de ser registrada em algum lugar)
  • Operator: Escrita e Atualiza√ß√£o
  • Manager: Escrita e Atualiza√ß√£o

Ou seja, as funcionalidades necessárias de nosso banco são: Leitura, Escrita e Atualização. Percebam que não precisamos deletar dados em nenhum momento com nossas entidades, assim, partir do pré-suposto de que um CRUD é o mínimo necessário para uma implementação de banco de dados está errado.

Vamos escrever nossas interfaces para essa dependência:

    interface CanWrite {
        create(data: any): Promise<void>
    }
    interface CanChange {
        update(data: any): Promise<void>
    }
    interface CanRead {
        read(id?: string, startAt?: number, limit?: number): Promise<any[]>
    }
Enter fullscreen mode Exit fullscreen mode

Para respeitar o ISP, tivemos de criar um protocolo para cada necessidade, pois o Customer precisa Ler e Escrever, o Operator e o Manager precisam Atualizar e Escrever, ou seja, se tivéssemos unido a função Update e Create em uma mesma interface, o Customer que só precisa Escrever teria a dependência da Atualização também, o que vai contra nosso princípio e já falamos anteriormente sobre os sérios problemas disso.

Nosso sistema também precisa ser exposto de alguma forma, seja como uma biblioteca ou aplicativo, mas aqui vamos escolher uma API, então vamos fazer o mesmo exercício do banco de dados para essa dependência.

  • Customer: GET e POST
  • Operator: POST e PUT
  • Manager: POST e PUT

Viram como caímos no exato mesmo modelo do banco de dados? Vamos criar as interfaces:

    interface CanGet {
        get(func: any): Promise<Controller>
    }
    interface CanPost {
        post(func: any): Promise<Controller>
    }
    interface CanPut {
        put(func: any): Promise<Controller>
    }
Enter fullscreen mode Exit fullscreen mode

E pronto, temos nossas dependências estabelecidas, respeitando o ISP e finalmente vamos às ferramentas:

Defina suas ferramentas

Já sabemos o que precisamos de nossas ferramentas, então para o banco de dados, vamos escolher o Sequelize como nosso ORM (Dica: sempre tente tirar o máximo proveito que puder do typescript, escolha ferramentas que tenham tipos declarados ou que possuam alguma lib para tal, como no caso do Firestore (Google) que não possui as tipagens de modelo, mas temos o firestore-wrapper que lhe auxilia em tal tarefa) e o Express como o runtime de nossa aplicação.

A interface dos nossos adaptadores nós já temos, basta agora codificar suas classes com a ferramenta escolhida:

    class ReadAdapter implements CanRead {
        constructor (private readonly seq: Sequelize) {}
        async read(id?: string, startAt?: number, limit?: number): Promise<any[]> {
            // lógica da função
        }
    }
Enter fullscreen mode Exit fullscreen mode

Como os m√≥dulos de nosso sistema estar√£o apontando para interfaces abstratas que s√£o posteriormente implementadas por adaptadores que ser√£o injetados nos m√≥dulos, estamos aqui respeitando o √ļltimo princ√≠pio DIP.

Bom, at√© aqui acredito que voc√™ j√° tenha entendido a linha de racioc√≠nio para se planejar um sistema com os princ√≠pios do SOLID em mente, que tal realmente colocarmos a m√£o na massa e criar um sistema simples com todas essas implementa√ß√Ķes e testes para cada um de nossos m√≥dulos a fim de garantirmos o comportamento esperado?
Isso vai ficar para o próximo artigo, enquanto isso, vamos praticar mais um pouco.

Exercícios

1) Analise o programa definido a seguir:


    class Circle {
        constructor(public readonly radius: number) {}
    }

    class Rectangle {
        constructor(public readonly width: number, public readonly height: number) {}
    }

    class CalculateArea {
        public circleCalculator(circle: Circle): number {
            return Math.PI * (circle.radius * circle.radius);
        }

        public rectangleCalculator(rectangle: Rectangle): number {
            return rectangle.width * rectangle.height;
        }
    }

Enter fullscreen mode Exit fullscreen mode

Aqui temos um caso cl√°ssico de uma classe que calcula a √°rea de diferentes Formas, temos o C√≠rculo e o Ret√Ęngulo, por√©m, este programa est√° violando um princ√≠pio, qual √© este princ√≠pio?

Dica
Tente adicionar mais uma forma, como o Tri√Ęngulo, por exemplo.



Ver resposta
OCP
Enter fullscreen mode Exit fullscreen mode

1.1) Escreva um programa que corrija essa falha.

Ver resposta

interface Shape {
    calculateArea(): number;
}

class Circle implements Shape {
    constructor(public readonly radius: number) {}

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

class Rectangle implements Shape {
    constructor(public readonly width: number, public readonly height: number) {}

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

class CalculateArea {
    public calculate(shape: Shape): number {
        return shape.calculateArea();
    }
}
Enter fullscreen mode Exit fullscreen mode

2) Continuando com nosso programa que calcula formas geométricas, vamos replicar um caso definido por Martin sobre um dos princípios, analise o código a seguir:


    class Rectangle {
        constructor(public readonly width: number, public readonly height: number) {}
    }

    class Square extends Rectangle {

        constructor(public side: number) {
            super(side, side);
        }
    }
Enter fullscreen mode Exit fullscreen mode

Qual princípio do SOLID está sendo violado? Por quê?

Dica
Square est√° alterando o comportamento de Rectangle



Ver resposta
LSP, pois a classe Square est√° estendendo Rectangle, mas os lados de um quadrado devem ter sempre o mesmo valor, enquanto o retangulo pode ter altura e largura indpendentes, tornando-se assim, um subtipo incoerente.
Enter fullscreen mode Exit fullscreen mode

2.1) Escreva um programa que corrija essa falha.

Ver resposta

class Rectangle {
    constructor(public readonly width: number, public readonly height: number) {}
}

class Square {
    constructor(public readonly side: number) {}
}
Enter fullscreen mode Exit fullscreen mode

3) Considere o programa abaixo:

    interface ShapeOperations {
        calculateArea(): number
        countVertex(): number
        calcDiameter(): number
    }

    class Square implements ShapeOperations {
        constructor(public readonly side: number) {}
        calculateArea(): number {
            return this.side * this.side;
        }
        countVertex(): number {
            return 4
        }
        calcDiameter(): number {
            return this.side * Math.sqrt(2);
        }
    }

    class Circle implements ShapeOperations {
        constructor(public readonly radius: number) {}
        calculateArea(): number {
            return Math.PI * (this.radius * this.radius);
        }
        countVertex(): number {
            throw new Error("Circle has no vertex");
        }
        calcDiameter(): number {
            return this.radius * 2;
        }
    }
Enter fullscreen mode Exit fullscreen mode

Qual princípio do SOLID está sendo violado? Por quê?

Dica
Circle não pode implementar um contador de vértices



Ver resposta
ISP, se o circulo não pode implementar um contador de vértices, ele não deveria estender essa interface
Enter fullscreen mode Exit fullscreen mode

3.1) Escreva um programa que corrija essa falha.

Ver resposta

interface HasArea {
    calculateArea(): number
}

interface HasVertex {
    countVertex(): number
}

interface HasDiameter {
    calcDiameter(): number
}

class Square implements HasArea, HasVertex, HasDiameter {
    constructor(public readonly side: number) {}
    calculateArea(): number {
        return this.side * this.side;
    }
    countVertex(): number {
        return 4
    }
    calcDiameter(): number {
        return this.side * Math.sqrt(2);
    }
}

class Circle implements HasArea, HasDiameter {
    constructor(public readonly radius: number) {}
    calculateArea(): number {
        return Math.PI * (this.radius * this.radius);
    }

    calcDiameter(): number {
        return this.radius * 2;
    }
}
Enter fullscreen mode Exit fullscreen mode

Próximos passos

  • Leia o livro base para a constru√ß√£o desse conte√ļdo: Arquitetura Limpa por Robert C. Martin.

Com os conhecimento adquiridos nesse conte√ļdo, o livro do famoso Uncle Bob ser√° muito mais palat√°vel, mas n√£o substitui a leitura do mesmo, que vai te apresentar outros exemplos com abordagens diferentes.

  • Tenha meios de planejar seus sistemas fora a escrita de interfaces.

UML √© uma ferramenta muito poderosa para te ajudar no processo de planejamento, nesse conte√ļdo n√£o falamos sobre isso, pois o foco aqui √© o SOLID com Typescript, por√©m os diagramas s√£o uma forma muito mais eficiente de entender como e o que ser√° construido do que sair definindo interfaces, eles nos ajudam principalmente a entendermos como ser√£o as rela√ß√Ķes entre nossas classes e entidades, quais comportamentos s√£o esperados de cada uma e principalmente, nos ajudam a identificar os erros e incoer√™ncias dos programas antes de colocarmos a m√£o na massa. Um bom programador passa mais tempo no papel que no c√≥digo.

Existem muitos artigos sobre o tema, como O do professor Marcelo Linder, que de uma forma simples te apresenta os conceitos b√°sicos, mas se voc√™ quiser um conte√ļdo mais completo, minha recomenda√ß√£o √© o livro UML 2 - Uma Abordagem Pr√°tica.

  • Estude DDD.

Neste conte√ļdo eu propus uma linha de raciocionio para voc√™ come√ßar a formular seus sistemas, por√©m, √© importante que voc√™ entenda sobre os processos mais aceitos de design de software.
Proposto por Eric Evans em seu livro Domain-Driven Design: Atacando as complexidades no coração do software em 2003, o DDD é fundamental para que engenheiros de software consigam se comunicar bem com os experts do negócio e traduzir as necessidades de maneira efetiva para o sistema.
O livro é minha principal recomendação, mas caso queira uma introdução um pouco mais direta, o artigo da FullCycle sobre o assunto é bem interessante.

  • Saiba trabalhar em equipe.

Depois de se tornar um especialista em arquitetura de sistemas, precisa trabalhar com outros desenvolvedores de maneira eficiente, para que depois de tudo bem planejado, cada um ataque um domínio do sistema a ser desenvolvido, e para que vocês não caiam no famoso ditado: O que um desenvolvedor faz em uma semana, dois fazem em duas.

Com um sistema bem desenhado e planejado, esse trabalho se torna algo muito fácil e para ajudar ainda mais, recomendo que estude sobre GitFlow e utilize sempre o Conventional Commits para ajudar seus colegas a entenderem o que você está fazendo.

Top comments (0)

DEV

Thank you.

 
Thanks for visiting DEV, we’ve worked really hard to cultivate this great community and would love to have you join us. If you’d like to create an account, you can sign up here.