Por que os princípios SOLID ainda são a base para a arquitetura de software moderna

Embora a computação tenha mudado muito nos 20 anos desde que os princípios SOLID foram concebidos, eles ainda são as melhores práticas para projetar software.

Os princípios SOLID são uma rubrica testada pelo tempo para a criação de software de qualidade. Mas em um mundo de programação multiparadigma e computação em nuvem, eles ainda se comparam? Vou explorar o que significa SOLID (literal e figurativamente), explicar por que ainda faz sentido e compartilhar alguns exemplos de como pode ser adaptado para a computação moderna.

 

O que é SOLID?

 

SOLID é um conjunto de princípios destilados dos escritos de Robert C. Martin no início dos anos 2000. Foi proposto como uma forma de pensar especificamente sobre a qualidade da programação orientada a objetos (OO). Como um todo, os princípios SOLID apresentam argumentos sobre como o código deve ser dividido, quais partes devem ser internas ou expostas e como o código deve usar outro código. Vou mergulhar em cada letra abaixo e explicar seu significado original, bem como um significado expandido que pode ser aplicado fora da programação OO.

 

O que mudou?

 

No início dos anos 2000, Java e C ++ eram os reis. Certamente, em minhas aulas na universidade, Java era nossa linguagem de escolha e a maioria de nossos exercícios e aulas a usavam. A popularidade do Java gerou uma indústria artesanal de livros, conferências, cursos e outros materiais para levar as pessoas a escreverem códigos bons .

 

Desde então, as mudanças na indústria de software foram profundas. Alguns notáveis:

 

Linguagens com tipos dinâmicos , como Python, Ruby e especialmente JavaScript, tornaram-se tão populares quanto Java - ultrapassando-o em alguns setores e tipos de empresas.

Paradigmas não orientados a objetos , principalmente programação funcional (FP), também são mais comuns nessas novas linguagens. Até o próprio Java introduziu lambdas! Técnicas como metaprogramação (adição e alteração de métodos e recursos de objetos) também ganharam popularidade. Existem também sabores OO “mais suaves”, como Go, que possui tipagem estática, mas não possui herança. Tudo isso significa que classes e herança são menos importantes no software moderno do que no passado.

O software de código aberto proliferou. Considerando que antes a prática mais comum seria escrever software compilado de código fechado para ser usado pelos clientes, hoje em dia é muito mais comum que suas dependências sejam de código aberto. Por causa disso, o tipo de lógica e ocultação de dados que costumava ser imperativo ao escrever uma biblioteca não é mais tão importante.

Microsserviços e software como serviço explodiram em cena. Em vez de implantar um aplicativo como um grande executável que vincula todas as suas dependências, é muito mais comum implantar um pequeno serviço que se comunica com outros serviços, sejam seus ou fornecidos por terceiros.

Consideradas como um todo, muitas das coisas com que o SOLID realmente se importava - como classes e interfaces, ocultação de dados e polimorfismo - não são mais coisas com as quais os programadores lidam todos os dias.

 

O que não mudou?

 

O setor está diferente em muitos aspectos agora, mas há algumas coisas que não mudaram e provavelmente não mudarão. Esses incluem:

 

O código é escrito e modificado por pessoas. O código é escrito uma vez e lido muitas e muitas vezes. Sempre haverá a necessidade de um código bem documentado, particularmente APIs bem documentadas, sejam internas ou externas.

O código é organizado em módulos . Em alguns idiomas, são classes. Em outros, eles podem ser arquivos de origem individuais. Em JavaScript, eles podem ser objetos exportados. Independentemente disso, existe alguma maneira de separar e organizar o código em unidades distintas e delimitadas. Portanto, sempre haverá a necessidade de decidir a melhor forma de agrupar o código.

O código pode ser interno ou externo. Algum código é escrito para ser usado por você ou sua equipe. Outro código é escrito para ser usado por outras equipes ou mesmo por clientes externos (por meio de uma API). Isso significa que deve haver alguma maneira de decidir qual código está "visível" e o que está "oculto".

 

SOLID “Moderno”

 

Nas seções a seguir, irei reafirmar cada um dos cinco princípios SOLID em uma declaração mais geral que pode ser aplicada à programação OO, FP ou multiparadigma e fornecer exemplos. Em muitos casos, esses princípios podem até mesmo se aplicar a serviços ou sistemas inteiros!

 

Observe que usarei o módulo de palavras nos parágrafos a seguir para me referir a um agrupamento de código. Pode ser uma classe, um módulo, um arquivo, etc.

 

Princípio de responsabilidade única

 

Definição original: “Nunca deve haver mais de um motivo para a mudança de classe.”

 

Se você escreve uma classe com muitas preocupações, ou “motivos para mudar”, então você precisa mudar o mesmo código sempre que qualquer uma dessas preocupações tiver que mudar. Isso aumenta a probabilidade de que uma alteração em um recurso interrompa acidentalmente um recurso diferente.

 

Por exemplo, aqui está uma classe Franken que nunca deve chegar à produção:

 

class Frankenclass {

   public void saveUserDetails(User user) {

       //...

   }

 

   public void performOrder(Order order) {

       //...

   }

 

   public void shipItem(Item item, String address) {

       // ...

   }

}

 

 

Nova definição: “Cada módulo deve fazer uma coisa e fazê-la bem.”

 

Este princípio está intimamente relacionado ao tópico de alta coesão . Essencialmente, seu código não deve misturar várias funções ou finalidades.

 

Aqui está uma versão FP deste mesmo exemplo usando JavaScript:

 

const saveUserDetails = (user) = { ... }

const performOrder = (order) = { ...}

const shipItem = (item, address) = { ... }

 

export { saveUserDetails, performOrder, shipItem };

 

// calling code

import { saveUserDetails, performOrder, shipItem } from "allActions";

 

 

Isso também pode se aplicar ao design de microsserviços; se você tem um único serviço que lida com todas essas três funções, ele está tentando fazer muito.

 

Princípio aberto-fechado

 

Definição original: “Entidades de software devem ser abertas para extensão, mas fechadas para modificação.”

 

Isso faz parte do design de linguagens como Java - você pode criar classes e estendê-las (criando uma subclasse), mas não pode modificar a classe original. 

 

Uma razão para tornar as coisas "abertas para extensão" é limitar a dependência do autor da classe - se você precisa de uma mudança na classe, você precisa constantemente pedir ao autor original para mudar para você, ou você ' precisaria mergulhar nele para mudá-lo sozinho. Além do mais, a classe começaria a incorporar muitas preocupações diferentes, o que quebra o princípio da responsabilidade única.

 

A razão para fechar classes para modificação é que não podemos confiar em nenhum e todos os consumidores downstream para entender todo o código “privado” que usamos para fazer nosso recurso funcionar, e queremos protegê-lo de mãos não qualificadas.

 

class Notifier {

   public void notify(String message) {

       // send an e-mail

   }

}

 

class LoggingNotifier extends Notifier {

   public void notify(String message) {

       super.notify(message); // keep parent behavior

       // also log the message

   }

}

 

 

Nova definição: “Você deve ser capaz de usar e adicionar a um módulo sem reescrevê-lo.”

 

Isso vem de graça no OO-land. Em um mundo FP, seu código deve definir “pontos de gancho” explícitos para permitir a modificação. Aqui está um exemplo em que não apenas os ganchos antes e depois são permitidos, mas até mesmo o comportamento básico pode ser substituído passando uma função para sua função:

 

// library code

 

const saveRecord = (record, save, beforeSave, afterSave) = {

  const defaultSave = (record) = {

   // default save functionality

  }

 

  if (beforeSave) beforeSave(record);

  if (save) {

    save(record);

  }

  else {

    defaultSave(record);

  }

  if (afterSave) afterSave(record);

}

 

// calling code

 

const customSave = (record) = { ... }

saveRecord(myRecord, customSave);

 

 

Princípio de substituição de Liskov

 

Definição original: “Se S é um subtipo de T, então os objetos do tipo T podem ser substituídos por objetos do tipo S sem alterar nenhuma das propriedades desejáveis ​​do programa.”

 

Este é um atributo básico das linguagens OO. Isso significa que você deve ser capaz de usar qualquer subclasse no lugar de sua classe pai. Isso permite a confiança em seu contrato - você pode depender com segurança de qualquer objeto que "seja um" tipo Tpara continuar a se comportar como um T. Aqui está na prática:

 

class Vehicle {

   public int getNumberOfWheels() {

       return 4;

   }

}

 

class Bicycle extends Vehicle {

   public int getNumberOfWheels() {

       return 2;

   }

}

 

// calling code

public static int COST_PER_TIRE = 50;

public int tireCost(Vehicle vehicle) {

    return COST_PER_TIRE * vehicle.getNumberOfWheels();

}   

  

Bicycle bicycle = new Bicycle();

System.out.println(tireCost(bicycle)); // 100

 

 

Nova definição: você deve ser capaz de substituir uma coisa por outra se for declarado que essas coisas se comportam da mesma maneira.

 

Em linguagens dinâmicas, o importante a tirar disso é que se o seu programa “promete” fazer algo (como implementar uma interface ou uma função), você precisa cumprir sua promessa e não surpreender seus clientes.

 

Muitas linguagens dinâmicas usam digitação duck para fazer isso. Essencialmente, sua função, formal ou informalmente, declara que espera que sua entrada se comporte de uma maneira particular e prossegue com essa suposição.

 

Aqui está um exemplo usando Ruby:

 

# @param input [#to_s]

def split_lines(input)

 input.to_s.split("\")

end

 

Nesse caso, a função não se importa com o tipo input- apenas que ela tem uma to_sfunção que se comporta da maneira que todas as to_sfunções deveriam se comportar, ou seja, ela transforma a entrada em uma string. Muitas linguagens dinâmicas não têm como forçar esse comportamento, então isso se torna mais uma questão de disciplina do que uma técnica formalizada.

 

Aqui está um exemplo de FP usando TypeScript. Nesse caso, uma função de ordem superior assume uma função de filtro que espera uma única entrada numérica e retorna um valor booleano:

 

const isEven = (x: number) : boolean = x % 2 == 0;

const isOdd = (x: number) : boolean = x % 2 == 1;

 

const printFiltered = (arr: number[], filterFunc: (int) = boolean) = {

 arr.forEach((item) = {

   if (filterFunc(item)) {

     console.log(item);

   }

 })

}

 

const array = [1, 2, 3, 4, 5, 6];

printFiltered(array, isEven);

printFiltered(array, isOdd);

 

 

Princípio de segregação de interface

 

Definição Original: “Muitas interfaces específicas do cliente são melhores do que uma interface de uso geral.”

 

Em OO, você pode pensar nisso como fornecer uma “visão” de sua classe. Em vez de fornecer sua implementação completa a todos os seus clientes, você cria interfaces em cima deles apenas com os métodos relevantes para aquele cliente e pede a seus clientes que usem essas interfaces. 

 

Como acontece com o princípio de responsabilidade única, isso diminui o acoplamento entre os sistemas e garante que um cliente não precise saber ou depender de recursos que não tem a intenção de usar.

 

Aqui está um exemplo que passa no teste SRP:

 

class PrintRequest {

   public void createRequest() {}

   public void deleteRequest() {}

   public void workOnRequest() {}

}

 

Em geral, esse código terá apenas um “motivo para mudar” - tudo relacionado a solicitações de impressão, que fazem parte do mesmo domínio, e todos os três métodos provavelmente mudarão do mesmo estado. No entanto, não é provável que o mesmo cliente que está criando solicitações seja aquele que está trabalhando nas solicitações. Faz mais sentido separar essas interfaces:

 

 

interface PrintRequestModifier {

   public void createRequest();

   public void deleteRequest();

}

 

interface PrintRequestWorker {

   public void workOnRequest()

}

 

class PrintRequest implements PrintRequestModifier, PrintRequestWorker {

   public void createRequest() {}

   public void deleteRequest() {}

   public void workOnRequest() {}

}

 

 

Nova definição: “Não mostre aos seus clientes mais do que eles precisam ver”.

 

Documente apenas o que seu cliente precisa saber. Isso pode significar usar geradores de documentação para produzir apenas funções ou rotas "públicas" e deixar as "privadas" sem emissão.

 

No mundo dos microsserviços, você pode usar a documentação ou a verdadeira separação para garantir a clareza. Por exemplo, seus clientes externos só podem fazer login como um usuário, mas seus serviços internos podem precisar obter listas de usuários ou atributos adicionais. Você pode criar um serviço de usuário “externo apenas” separado que chama seu serviço principal ou pode gerar documentação específica apenas para usuários externos que esconde as rotas internas. 

 

Princípio de inversão de dependência

 

Definição original: “Depende de abstrações, não de concreções”.

 

Em OO, isso significa que os clientes devem depender de interfaces, em vez de classes concretas, tanto quanto possível. Isso garante que o código esteja contando com a menor área de superfície possível - na verdade, não depende de código, apenas um contrato que define como esse código deve se comportar. Tal como acontece com outros princípios, isso reduz o risco de quebra em um lugar, causando quebras em outro lugar acidentalmente. Aqui está um exemplo simplificado:

 

interface Logger {

   public void write(String message);

}

 

class FileLogger implements Logger {

   public void write(String message) {

       // write to file

   }

}

 

class StandardOutLogger implements Logger {

   public void write(String message) {

       // write to standard out

   }

}

 

public void doStuff(Logger logger) {

   // do stuff

   logger.write("some message")

}

 

 

 

Se você está escrevendo um código que precisa de um logger, não quer se limitar a escrever em arquivos, porque não se importa. Você apenas chama o writemétodo e deixa a classe concreta resolvê-lo.

 

Nova definição: “Depende de abstrações, não de concreções”.

 

Sim, neste caso eu deixaria a definição como está! A ideia de manter as coisas abstratas onde possível ainda é importante, mesmo que o mecanismo de abstração no código moderno não seja tão forte quanto em um mundo OO estrito.

 

Praticamente, isso é quase idêntico ao princípio de substituição de Liskov discutido acima. A principal diferença é que aqui não há implementação padrão. Por causa disso, a discussão envolvendo tipos de pato e funções de gancho nessa seção também se aplica à inversão de dependência.

 

Você também pode aplicar abstração ao mundo do microsserviço. Por exemplo, você pode substituir a comunicação direta entre os serviços por um barramento de mensagens ou plataforma de fila, como Kafka ou RabbitMQ. Isso permite que os serviços enviem mensagens para um único local genérico, sem se preocupar com qual serviço específico irá coletar essas mensagens e realizar sua tarefa.

 

Conclusão

 

Para reafirmar "SOLID moderno" mais uma vez:

 

Não surpreenda as pessoas que lêem seu código.

Não surpreenda as pessoas que usam seu código.

Não sobrecarregue as pessoas que lêem seu código.

Use limites sensatos para o seu código.

Use o nível certo de acoplamento - mantenha juntas as coisas que pertencem uma à outra e mantenha-as separadas se elas pertencerem.

Bom código é bom código - isso não vai mudar, e SOLID é uma base sólida para praticar isso!


Strong

5178 Blog indlæg

Kommentarer