Como a adoção dos princípios SOLID pode salvar seu aplicativo Flutter de um desastre

Tempo de leitura: 12 minutes

Imagine que você está construindo uma casa. Você poderia juntar algumas paredes, colocar um telhado e pronto. É claro que ela pode ficar de pé por um tempo, mas o que acontece quando há uma tempestade? É aí que entram os princípios SOLID – eles são como o projeto arquitetônico do seu código, garantindo que ele não seja apenas resistente, mas também resiliente.

Pense nisso: você prefere ter uma casa construída sobre um alicerce instável, pronta para desmoronar à menor brisa, ou uma casa com uma base sólida, capaz de resistir a qualquer tempestade? O mesmo vale para seu código. Portanto, neste artigo, aprenderemos sobre os princípios SOLID que o ajudarão a fazer isso. É claro que seguir os cinco princípios SOLID pode exigir um pouco mais de tempo e esforço no início, mas, no final, você terá uma base de código flexível, adaptável e construída para durar para seus netos verem. Portanto, quer esteja construindo um arranha-céu ou criando seu aplicativo, lembre-se: fundações fortes levam a estruturas mais fortes. Com isso entendido, vamos entrar no assunto e aprender sobre o princípio S em SOLID, que significa Single Responsibility Principle (Princípio da Responsabilidade Única).

 

S – Princípio da responsabilidade única

O Princípio da Responsabilidade Única diz: “Uma classe deve ter apenas um motivo para mudar”. Você também poderia dizer: “Reúna as coisas que mudam pelos mesmos motivos. Separe as coisas que mudam por motivos diferentes”.

À primeira vista, essa definição parece simples, mas se você pensar bem, ela pode se tornar um pouco confusa. Isso ocorre porque “um motivo para mudar” pode ser diferente para pessoas diferentes.

Vamos usar o gerenciamento de usuários como exemplo:

class UserManager {
  // Lógica de autenticação
  bool authenticateUser(String username, String password) {
    // Lógica para autenticar o usuário
    return true; // Sucesso simulado para o exemplo
  }

  // Lógica de gerenciamento de perfis
  void updateUserProfile(String username, Map<String, dynamic> profileData) {
    // Lógica para atualizar o perfil do usuário
    print('Perfil do usuário atualizado para $username');
  }
}

Sem o Princípio da Responsabilidade Única, você pode pensar que esse código está completamente correto.

No entanto, isso é uma violação do SRP.

Por quê?

Essa classe combina autenticação e gerenciamento de perfil de usuário. Elas podem ser consideradas responsabilidades diferentes porque podem mudar por motivos diferentes:

  1. Lógica de autenticação: Pode mudar devido a atualizações de segurança, alterações nos protocolos de autenticação ou melhorias no processo de autenticação.
  2. Lógica de gerenciamento de perfil: Pode mudar devido a atualizações na estrutura do perfil do usuário, alterações nos mecanismos de armazenamento de dados ou melhorias no processo de gerenciamento de perfis.

Portanto, para escrever um código que siga o princípio de responsabilidade única, você pode fazer o seguinte:

// Classe do gerenciador de autenticação responsável pela lógica de autenticação
class AuthManager {
  bool authenticateUser(String username, String password) {
    // Lógica para autenticar o usuário
    return true; // Sucesso simulado para o exemplo
  }
}

// Classe de gerenciador de perfil responsável pela lógica de gerenciamento de perfil de usuário
class ProfileManager {
  void updateUserProfile(String username, Map<String, dynamic> profileData) {
    // Lógica para atualizar o perfil do usuário
    print('Perfil do usuário atualizado para $username');
  }
}

Lembre-se: são as pessoas que solicitam alterações. E você não quer confundir essas pessoas, nem a si mesmo, misturando o código com o qual muitas pessoas diferentes se importam por motivos diferentes. Tenha isso em mente antes de tomar qualquer decisão relacionada ao princípio da responsabilidade única.

Para entender completamente o padrão de responsabilidade única, vamos dar uma olhada em um segundo exemplo:

Considere uma classe que compila e imprime um relatório. Essa classe pode ser alterada por dois motivos. Primeiro, o conteúdo do relatório pode mudar. Segundo, o formato do relatório pode mudar. Essas duas coisas mudam por causas diferentes. O princípio da responsabilidade única diz que esses dois aspectos do problema são, na verdade, duas responsabilidades separadas e, portanto, devem estar em classes ou módulos separados. Seria um projeto ruim juntar duas coisas que mudam por motivos diferentes em momentos diferentes.

// Versão ruim em que o conteúdo e o formato são tratados na mesma classe
class BadReport {
  String generateAndFormatReport() {
    // Versão ruim em que o conteúdo e o formato são tratados na mesma classe
    String content = 'Report Content';
    String formattedReport = 'Formatted Report: $content';

    return formattedReport;
  }

  void compileAndPrint() {
    String formattedReport = generateAndFormatReport();

    print(formattedReport);
  }
}

void main() {
  // Criar uma instância da classe de relatório ruim
  BadReport myBadReport = BadReport();

  // Compilar e imprimir o relatório
  myBadReport.compileAndPrint();
}

Esta é a aparência do código limpo:

// Classe de conteúdo responsável pela geração do conteúdo do relatório
class ReportContent {
  String generateContent() {
    // Lógica para gerar conteúdo de relatório
    return 'Report Content';
  }
// métodos adicionais úteis para gerar o conteúdo do relatório
}

// Classe de formato responsável pela formatação do relatório
class ReportFormat {
  String format(String content) {
    // Lógica para formatar o relatório
    return 'Formatted Report: $content';
  }
}
// métodos adicionais úteis para formatar o relatório

// Classe de relatório que compõe o conteúdo e o formato
class Report {
  final ReportContent _content;
  final ReportFormat _format;

  Report(this._content, this._format);

  String compile() {
    // Gerar conteúdo de relatório
    String content = _content.generateContent();

    // Formatar o relatório
    String formattedReport = _format.format(content);

    return formattedReport;
  }
}

void main() {
  // Criar instâncias de classes de conteúdo e formato
  ReportContent reportContent = ReportContent();
  ReportFormat reportFormat = ReportFormat();

  // Criar instâncias de classes de conteúdo e formato
  Report myReport = Report(reportContent, reportFormat);

  // Compilar o relatório
  String compiledReport = myReport.compile();

  // Imprimir o relatório
  print(compiledReport);
}

Agora… quais são os benefícios de usar o Princípio da Responsabilidade Única?

  1. Tornar seu código mais fácil de entender: Ao ter uma única responsabilidade para cada componente, você pode nomear seus componentes de forma mais clara e consistente, o que melhora a legibilidade e a comunicação. Por exemplo, em vez de ter uma classe chamada User que lida com autenticação, autorização, registro e gerenciamento de perfil, você pode ter classes separadas para cada uma dessas preocupações, como AuthService, Logger e ProfileService. Essas classes têm maior probabilidade de serem reutilizáveis em diferentes contextos, pois foram projetadas para executar uma tarefa específica e bem definida.
  2. Ótima capacidade de manutenção: Isso também ajuda a facilitar a manutenção. As alterações relacionadas a uma responsabilidade não afetam as outras.
  3. Mais fácil de testar: O código com uma única responsabilidade também costuma ser mais fácil de testar. Os testes de unidade podem se concentrar mais na funcionalidade específica sem precisar levar em conta preocupações não relacionadas.
  4. Reduzir o acoplamento entre diferentes partes: A separação das responsabilidades reduz o acoplamento entre as diferentes partes do código. As alterações em uma área têm menos probabilidade de afetar componentes não relacionados, promovendo um sistema mais modular e flexível.

Princípio aberto/fechado

O princípio aberto/fechado diz que as entidades de software (classes, módulos, funções etc.) devem ser abertas para extensão, mas fechadas para modificação.

Mas… como algo pode ser aberto para extensão (o que significa que podemos adicionar novos recursos), mas fechado para modificações? Isso é contraditório!

Como podemos adicionar novas funcionalidades a um software existente sem alterar o código existente?

Vamos usar a analogia da casa novamente:

Imagine que você está construindo uma casa e, de repente, precisa adicionar mais um andar. O Princípio Aberto/Fechado é como um projeto que permite adicionar esses andares extras sem derrubar paredes ou redirecionar o encanamento. Sua casa deve ser aberta para ampliação, mas fechada para modificações, o que significa que você pode adicionar novas funcionalidades sem mexer nos recursos existentes em sua casa. É o sonho arquitetônico: expansível sem ser um pesadelo renovar tudo.

Vamos dar uma olhada em um exemplo de código novamente:

// Classe de forma que representa diferentes formas
class Shape {
  String type;

  Shape(this.type);
}

class AreaCalculator {
  double calculateArea(Shape shape) {
    if (shape.type == 'circle') {
      return 3.14 * 3.14;
    } else if (shape.type == 'rectangle') {
      return 4 * 5;
    }
    return 0;
  }
}

Neste exemplo, o AreaCalculator viola o Princípio Aberto/Fechado porque toda vez que uma nova forma é introduzida, é necessário modificar a classe AreaCalculator existente. Essa abordagem não é dimensionável e pode levar a um pesadelo de manutenção.

Veja como seria o código que segue o princípio Open/Closed:

abstract interface class Shape {
  double calculateArea();
}

// Classe de círculo que implementa a interface Shape
class Circle implements Shape {
  double radius;

  Circle(this.radius);

  @override
  double calculateArea() {
    return 3.14 * radius * radius;
  }
}

// Classe de retângulo que implementa a interface Shape
class Rectangle implements Shape {
  double width;
  double height;

  Rectangle(this.width, this.height);

  @override
  double calculateArea() {
    return width * height;
  }
}

// A classe AreaCalculator agora aceita formas que implementam a interface Shape
class AreaCalculator {
  double calculateArea(Shape shape) {
    return shape.calculateArea();
  }
}

Uma observação rápida: há uma diferença importante entre classes abstratas e classes de interface. As classes abstratas são usadas para fornecer uma classe base da qual as subclasses concretas podem herdar, enquanto as interfaces são usadas para definir um conjunto de métodos que uma classe deve implementar

Vamos dar uma olhada nos benefícios de usar o princípio aberto/fechado em seu código:

  • Fácil extensão: O princípio aberto/fechado permite que os sistemas sejam facilmente ampliados com novas funcionalidades sem modificar o código existente. Isso promove a escalabilidade do sistema, pois os novos requisitos podem ser acomodados por meio de extensão em vez de modificação.
  • Reduzir bugs: Ao evitar alterações no código existente, o OCP reduz o risco de introdução de bugs ou efeitos colaterais não intencionais.
  • Redução do acoplamento: A OCP incentiva o acoplamento frouxo entre os diferentes componentes do sistema. As alterações em um módulo podem ser isoladas, reduzindo o efeito cascata em outras partes do sistema.
  • Promover padrões de design: O OCP é um princípio fundamental em muitos padrões de design, como os padrões Strategy, Decorator e Factory. O uso do OCP incentiva o uso desses padrões, resultando em projetos de software mais robustos e flexíveis.
  • Incentiva a abstração: Seguir o OCP geralmente leva à criação de interfaces abstratas ou classes básicas que definem o contrato para extensão. Isso incentiva a abstração e ajuda a definir limites claros entre as diferentes partes do sistema.

 

L – Substituição de Liskov

Vamos dar uma olhada na terceira letra e princípio: A Substituição de Liskov. Ela afirma o seguinte: “Seja Φ(x) uma propriedade provável sobre objetos x do tipo T. Então Φ(y) deve ser verdadeira para objetos y do tipo S, onde S é um subtipo de T.”

Uau. Isso é um pouco complexo demais (pelo menos para mim). Vamos explicar em termos mais simples:

O Princípio de Substituição de Liskov (LSP) é um princípio que afirma que os objetos de uma superclasse devem ser substituídos por objetos de suas subclasses sem afetar a correção do programa

Se Lion é um subtipo de Animal, então os objetos do tipo Animal podem ser substituídos por objetos do tipo Lion sem alterar o comportamento desejado do programa.

abstract class Animal {
  void makeSound();
}

class Lion extends Animal {
  @override
  void makeSound() {
    print("Roar!");
  }
}

void makeAnimalSound(Animal animal) {
  animal.makeSound();
}

void main() {
  Animal animal = Lion();
  makeAnimalSound(animal);

  Lion lion = Lion();
  makeAnimalSound(lion);
}

Isso mostra o Princípio de Substituição de Liskov, pois os objetos do tipo Animal (a superclasse) podem ser substituídos por objetos do tipo Lion (a subclasse) sem alterar o comportamento desejado do programa.

Mas isso se parece mais com o princípio da herança, certo?

Embora o Princípio de Substituição de Liskov esteja intimamente relacionado à herança, o LSP nos diz mais sobre como a herança deve ser usada para manter a correção dos programas e garantir a substituibilidade dos objetos.

Isso significa que simplesmente herdar de uma superclasse não garante automaticamente que nosso código esteja seguindo o Princípio de Substituição de Liskov. Você também precisa garantir que a subclasse não viole os contratos estabelecidos pela superclasse e não altere o comportamento de maneiras inesperadas ao substituir objetos. Vejamos um exemplo de violação:

class Animal {
  void run() {
    print('The animal is running.');
  }
}

class Lion extends Animal {
  @override
  void run() {
    super.run();
    print('The lion is roaring while running.');
  }
}

void main() {
  Animal animal = Lion(); // Usando o Leão como um animal
  animal.run(); // Saída: O animal está correndo. O leão está rugindo enquanto corre.

}

Violamos o Princípio de Substituição de Liskov porque substituir um objeto Lion por um objeto Animal leva a um comportamento inesperado: embora ele se comporte como um Animal na maioria dos aspectos, a adição do comportamento de rugir significa que ele não adere totalmente ao comportamento esperado de um Animal.

Ao usar o LSP, você tem dois benefícios principais:

  1. Cada classe pode ser desenvolvida e testada de forma independente: Quando usamos o LSP para criar um conjunto de classes relacionadas, cada classe pode ser tratada como um módulo autônomo, o que significa que pode ser desenvolvida e testada independentemente de outras classes. Isso promove a modularidade, o que significa que nos permite dividir um sistema complexo em partes menores e mais gerenciáveis.
  2. Melhora a qualidade do código: Quando seguimos o LSP, podemos ter certeza de que todas as classes do grupo se comportam de forma consistente. Isso significa que cada classe implementará o mesmo conjunto de métodos e terá o mesmo comportamento que a classe principal. Essa consistência pode ajudar a reduzir as chances de detectar bugs e comportamentos inesperados, melhorando assim a qualidade geral do nosso código.

 

I – Segregação de interface

Segregação de interface significa que “os clientes não devem ser forçados a depender de interfaces que não usam”

O Princípio da Segregação de Interfaces (ISP) enfatiza a importância de manter as interfaces enxutas e relevantes para os clientes que as implementam. É por isso que ele pode parecer muito semelhante ao Princípio da Responsabilidade Única. Cada classe ou interface serve a um único propósito também nesse princípio.

Vamos considerar um exemplo envolvendo trabalhadores que têm funções diferentes. Suponhamos que tenhamos uma interface Worker com os métodos work() e eat(). Entretanto, nem todos os trabalhadores têm os mesmos recursos. Alguns funcionários, como um desenvolvedor, só precisam trabalhar, enquanto outros, como um garçom, só precisam comer (vamos supor que eles tenham um intervalo para comer). Essa configuração viola o ISP porque obriga todos os funcionários a implementar ambos os métodos, mesmo que sejam irrelevantes para sua função.

Aqui está a implementação incorreta inicial:

abstract interface class Worker {
  void work();
  void eat();
}

class Developer implements Worker {
  @override
  void work() {
    print('Developer is working.');
  }

  @override
  void eat() {
    print('Developer is eating.');
  }
}

class Waiter implements Worker {
  @override
  void work() {
    print('Waiter is working.');
  }

  @override
  void eat() {
    print('Waiter is eating.');
  }
}

void main() {
  var developer = Developer();
  developer.work();
  developer.eat();

  var waiter = Waiter();
  waiter.work();
  waiter.eat();
}

Nessa implementação, tanto a classe Developer quanto a Waiter são forçadas a implementar o método eat(), mesmo que ele não seja relevante para suas funções.

Para resolver esse problema, podemos separar a interface Worker em interfaces mais específicas, cada uma representando uma função diferente, e fazer com que as classes implementem apenas as interfaces que são relevantes para elas.

Aqui está a implementação corrigida:

abstract interface class Worker {
  void work();
}

abstract interface class Eater {
  void eat();
}

class Developer implements Worker {
  @override
  void work() {
    print('Developer is working.');
  }
}

class Waiter implements Worker, Eater {
  @override
  void work() {
    print('Waiter is working.');
  }

  @override
  void eat() {
    print('Waiter is eating.');
  }
}

void main() {
  var developer = Developer();
  developer.work();

  var waiter = Waiter();
  waiter.work();
  waiter.eat();
}

Nessa implementação corrigida, a interface Worker foi segregada em interfaces Worker e Eater. Agora, cada classe implementa apenas as interfaces que são relevantes para sua função. Isso segue o Princípio de Segregação de Interface, pois as classes não são forçadas a depender de interfaces que não usam, resultando em um design mais flexível e de fácil manutenção.

Isso pode parecer simples de seguir, mas quando o código cresce, pode ser difícil seguir esse princípio. Como regra geral, sempre que suas implementações fornecerem métodos vazios, o Princípio da Segregação de Interface não será seguido.

Os benefícios do ISP são os mesmos que os do SRP, pois são princípios muito semelhantes.

D – Inversão de dependência

Por último, mas não menos importante, está o Princípio da Inversão de Dependência (DIP). O DIP é “A estratégia de depender de interfaces ou funções e classes abstratas em vez de depender de funções e classes concretas”.

Quando os componentes de nosso sistema dependem uns dos outros, não queremos injetar diretamente a dependência de um componente em outro. Em vez disso, devemos usar um nível de abstração entre eles.

Vamos usar novamente a analogia da casa para entender melhor:

O Princípio da Inversão de Dependência sugere que, em vez de projetar sua casa para depender diretamente de um sistema elétrico específico, você deve projetá-la para depender de uma interface ou de uma representação abstrata de um sistema elétrico. Dessa forma, qualquer sistema elétrico que se encaixe nessa interface pode ser usado, facilitando a troca de sistemas sem a necessidade de reprojetar a casa. Essa abordagem torna o projeto de sua casa mais flexível e adaptável a mudanças.

Aqui está um exemplo de uma violação do DIP:

// Módulo de baixo nível
class IncandescentBulb {
  void turnOn() {
    print("Incandescent bulb turned on");
  }

  void turnOff() {
    print("Incandescent bulb turned off");
  }
}

// Módulo de alto nível
class Room {
  IncandescentBulb bulb;

  Room(this.bulb);

  void switchLightOn() {
    bulb.turnOn();
  }

  void switchLightOff() {
    bulb.turnOff();
  }
}

Nesse exemplo, o Room depende diretamente do IncandescentBulb, um módulo específico de baixo nível. Isso viola o DIP porque o Room não é flexível a mudanças no tipo de lâmpada usada.

Agora, como seguimos o DIP nesse exemplo?

Para seguir o Princípio de Inversão de Dependência, introduzimos uma abstração (interface) para a lâmpada, da qual o Room dependerá, e depois implementamos essa interface em qualquer classe de lâmpada específica:

// Abstração
abstract interface class Bulb {
  void turnOn();
  void turnOff();
}

// Módulo de baixo nível
class IncandescentBulb implements Bulb {
  @override
  void turnOn() {
    print("Incandescent bulb turned on");
  }

  @override
  void turnOff() {
    print("Incandescent bulb turned off");
  }
}

// Outro módulo de baixo nível
class LedBulb implements Bulb {
  @override
  void turnOn() {
    print("LED bulb turned on");
  }

  @override
  void turnOff() {
    print("LED bulb turned off");
  }
}

// Outro módulo de baixo nível
class Room {
  Bulb bulb;

  Room(this.bulb);

  void switchLightOn() {
    bulb.turnOn();
  }

  void switchLightOff() {
    bulb.turnOff();
  }
}

No exemplo revisado, o Room agora depende da interface Bulb, e não de um tipo específico de lâmpada. Isso segue o princípio de inversão de dependência, tornando o Room flexível a alterações no tipo de lâmpada. Você pode mudar facilmente de uma lâmpada incandescente para uma lâmpada de LED sem alterar o código do Room, obtendo assim o desacoplamento entre módulos de alto nível e módulos de baixo nível.

A vantagem do DIP é que, por depender de abstrações em vez de classes concretas, os módulos de alto nível não estão vinculados aos detalhes dos módulos de baixo nível. Essa separação permite que os desenvolvedores alterem o comportamento do sistema modificando ou substituindo módulos de baixo nível sem afetar os módulos de alto nível.

 

Conclusão

Em resumo, os princípios SOLID equipam os desenvolvedores com um plano para a criação de software resiliente e adaptável, semelhante à construção de uma casa robusta capaz de resistir a tempestades. Ao seguir esses princípios, os desenvolvedores garantem que seu código seja flexível, passível de manutenção e escalonável, estabelecendo uma base sólida para melhorias futuras. Adotar o SOLID significa criar software que não apenas atenda às necessidades atuais, mas que também esteja pronto para evoluir com o cenário tecnológico em constante mudança, garantindo longevidade e relevância no mundo acelerado do desenvolvimento de software.

Trabalhei muito neste artigo. Se você gostar, eu ficaria muito grato se pudesse aplaudir este artigo! Para você, é apenas um clique, mas, para mim, significa muito mais do que você imagina!