Executando verificação de segurança...
2

Tornando seu código mais SOLID - Guia com Pokémon!

Respondendo a primeira e mais importante pergunta, o que é SOLID? O SOLID são os 5 princípios de design de código voltados para orientação a objetos que auxiliam os desenvolvedores a escreverem um código para “serem humanos” não apenas para máquinas. O que significa que tornam o sistema criado mais fácil de se manter e também mais adaptável, facilitando alterações de escopo que podem surgir durante a evolução do projeto.

Estes princípios se mantiveram importantes desde que foram definidos por Robert C. Martin (Uncle Bob) e a necessidade de implementa-los começa a surgir quando se sente um “cheirinho estranho” no código, ou melhor dizendo, um code smells, pode ser definido como qualquer ponto no código que não pareça muito bem e possa indicar algum problema mais profundo. Neste artigo vou lhe mostrar como identificar alguns destes pontos de atenção.

Os exemplos utilizados neste artigo foram escritos em Java, mas podem ser replicados em qualquer linguagem! ☕


Princípios do SOLID

Vamos começar dizendo o que cada letra significa:

  • S — Single-responsibility Principle
  • O — Open-closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

Vamos começar pelo primeiro princípio e também, em minha opinião, o mais importante de todos eles, o princípio de:

Single-Responsibility Principle

No princípio da responsabilidade única, uma classe não deve ter mais do que um objetivo ou finalidade. Com cada parte do seu código responsável por um escopo bem definido, quando isso é feito da maneira certa é possível encontrar de forma fácil o que deseja modificar e também identificar qual o melhor local para implementar uma nova funcionalidade.

Outra vantagem é que o SRP evita de você ter que guardar todo o programa em sua cabeça, sendo que pequenos “módulos” do código são mais fáceis de lembrarmos no dia a dia. Em projetos grandes é comum criar confusão na hora de lembrar onde fica uma certa função ou qual método deve ser chamado para realizar uma ação.

Exemplo de uma classe que tem mais responsabilidades do que o necessário:

@Service public class PokedexService {

    public void calculateTotalSum() {/*<code>*/}
    public void getAllPokemon() {/*<code>*/}
    public void getPokemon() {/*<code>*/}
    public void addPokemon(String item) {/*<code>*/}
    public void deletePokemon(String item) {/*<code>*/}

    public void printReport() {/*<code>*/}
    public void showCatch() {/*<code>*/}

    public List<Pokemon> load() {/*<code>*/}
    public Pokemon save() {/*<code>*/}
    public Pokemon update() {/*<code>*/}
    public void delete(Long id) {/*<code>*/}
    
}

Este é um exemplo de uma God Class (Classe Deus), em POO damos esse nome quando uma classe “sabe demais”, ou seja, que implementa vários métodos e não tem um objetivo bem definido. Com o tempo a tendência é ela aumentar cada vez mais de tamanho. Um dos caminhos para evitar essa bola de neve que irá se formar é utilizar o princípio da responsabilidade única criando classes que são responsáveis por uma única tarefa:

@Service public class PokedexService {
    public void calculateTotalSum() {/*<code>*/}
    public List<Pokemon> getAllPokemon() {/*<code>*/}
    public Pokemon getPokemon() {/*<code>*/}
    public void addPokemon(String item) {/*<code>*/}
    public void deletePokemon(String item) {/*<code>*/}    
}

@Service public class ReportService {
    public void printReport() {/*<code>*/}
    public void showCatch() {/*<code>*/}
}

@Repository public interface PokemonRepository extends JpaRepository<Pokemon, Long> {
    List<Pokemon> load();
    Pokemon save();
    Pokemon update();
    void delete(Long id);
}

Neste novo exemplo fiz a separação das responsabilidades, agora temos o ReportService que cuida dos métodos de relatório e o PokedexService que é responsável pelo CRUD de Pokemon. O acesso ao banco de dados é feito por meio de um Repository. Agora ficou mais fácil para crescer a aplicação, com cada item com responsabiliades bem divididas.

Principais problemas quando não implementado:

  • Dificuldade em escrever testes, principalmente de unidade;
  • Falta de coesão no código;
  • Alto acoplamento, a dependência entre as partes de sua aplicação irá aumentar.

Open-closed Principle

“Entidades de software (classes, módulos, funções e etc.) devem estar abertas para extensão, porém fechadas para modificação”

Este princípio determina que uma classe deve ser “fechada para alteração e aberta para extensão”. Okay, mas o que isso significa? 😕

Isso quer dizer que sempre que uma regra de negócio nova é adicionada não será necessário alterar um código já existente e sim adicionar uma nova implementação dele. Isso ficará muito mais claro depois de alguns exemplos:

@Service public class TrainingService {

    public PokemonDTO trainPokemon(PokemonDTO pokemon, TrainingDTO training) {
        if (pokemon.getLastWorkout() != null) {
            var hoursBetweenDates = ChronoUnit.HOURS.between(pokemon.getLastWorkout(), LocalDateTime.now());
            if (hoursBetweenDates < 8) {
                throw new InvalidTraining("Your pokemon is tired, wait 8 hours");
            }
        }
        
        if (pokemon.getAttack() > 1000) {
            throw new InvalidTraining("Pokemon attack cannot be greater than 1000");
        }
        
        int newAttack = Math.round(pokemon.getAttack() + (pokemon.getAttack() * (training.getIntension() / 100)));
        int newDefense = Math.round(pokemon.getDefense() + (pokemon.getDefense() * (training.getIntension() / 100)));
        pokemon.setAttack(newAttack);
        pokemon.setDefense(newDefense);
        pokemon.setLastWorkout(LocalDateTime.now());
        return pokemon;
    }

}

Aqui temos diversas validações sendo realizadas ao tentar treinar um Pokémon, sempre que uma nova validação precisar ser adicionada precisaremos modificar esse código adicionando uma condicional. Como solução podemos fazer a seguinte modificação:

@Service public class TrainingServiceImpl implements TrainingService {

    public List<TrainingValidation> validations;
  
    public TrainingServiceImpl(List<TrainingValidation> validations) {
        this.validations = validations;
    }

    public PokemonDTO trainPokemon(PokemonDTO pokemon, TrainingDTO training) {
        validations.forEach(v -> v.valid(pokemon, training));
        setNewAttributes(pokemon, training);
        return pokemon;
    }

    private void setNewAttributes(PokemonDTO pokemon, TrainingDTO training) { /*code*/ }

}

Agora nosso Service recebe uma lista de validações (linha 5) que implementa a interface TrainingValidation. Desta forma sempre que uma nova validação precisar ser adicionada ao nosso treinamento basta criar uma nova classe que implemente está interface.

public interface TrainingValidation {
    void valid(PokemonDTO pokemon, TrainingDTO training);
}

public class ValidatePokemonPowerLimit implements TrainingValidation {
    @Override
    public void valid(PokemonDTO pokemon, TrainingDTO training) {
        if (pokemon.getAttack() > 1000)
            throw new InvalidTraining("Pokemon attack cannot be greater than 1000");
    }
}

public class ValidateDateLastWorkout implements TrainingValidation {
    @Override
    public void valid(PokemonDTO pokemon, TrainingDTO training) {
        if (pokemon.getLastWorkout() != null) {
            var hoursBetweenDates = ChronoUnit.HOURS.between(pokemon.getLastWorkout(), training.getDate());
            if (hoursBetweenDates < 8)
                throw new InvalidTraining("Your pokemon is tired, wait 8 hours");
        }
    }
}

Obs: O OCP pode lembrar bastante o Design Pattern Strategy, é um tópico interessante para se aprofundar.

Principais problemas quando não implementado:

  • Métodos com várias condicionais
  • Modificações constantes em entidades de software que já existem
  • Aumento no número de bugs ocasionais ao alterar regras de negócio

Liskov Substitution Principle

“Se S é um subtipo de T, então os objetos do tipo T, em um programa, podem ser substituídos pelos objetos de tipo S sem que seja necessário alterar as propriedades deste programa” — Wikipedia

Vamos combinar que essa definição pode deixar nossa mente mais confusa do que explicar algo 😅, mas vamos por partes… Em outras palavras: Um novo objeto criado a partir de uma classe que possuí herança não pode quebrar o comportamento da classe ancestral.

Como exemplo estou utilizando a classe Item que é estendida para as classes filho PokeBall, MasterBall e UltraBall. Mas quando vemos a implementação podemos perceber que MasterBall não implementa o método “buy” porquê não é possível comprar esse tipo de item, apenas adquiri-lo em um lugar específico durante a sua jornada.

public abstract class Item {
    private BigDecimal value;
 
    public String buyItem() { /*code*/ }
    public String sellItem() { /*code*/ }
}

public class MasterBall extends Item {
    @Override
    public String buyItem() {
        throw new InvalidBusinessTransaction("It's impossible buy this item");
    }
    @Override
    public String sellItem() { /*code*/ }
    public String getLocation() { /*code*/ }
}

public class Pokeball extends Item {
    @Override
    public String buyItem() { /*code*/ }
    @Override
    public String sellItem() { /*code*/ }
}

Por conta disso temos esse efeito inesperado quando tentamos vender um item MasterBall.

Exemplificação de como a herança feriu o LSP

Para seguirmos o princípio LSP a nossa MasterBall não deve herdar da classe “Item” e sim da nova Classe “ItemRare” que tem os métodos que se encaixam melhor no escopo desejado.

public abstract class ItemRare {
    private BigDecimal value;
    private String location;
  
    public String getLocation() { /*code*/ }
    public String sellItem() { /*code*/ }
}

public class MasterBall extends ItemRare {
    @Override
    public String buyItem() { /*code*/ }
    @Override
    public String sellItem() { /*code*/ }
    @Override
    public String getLocation() { /*code*/ }
}

Com essa nova implementação as classes filho podem substituir facilmente a classe ancestral.

Principais problemas quando não implementado:

  • Métodos que lançam exceções inesperadas
  • Valores de tipos diferentes da classe base
  • Implementar um método que não faz nada
  • Muita lógica condicional espalhada pela aplicação

Interface Segregation Principle

Uma classe não deve implementar métodos que não irá utilizar. Por conta disso uma segmentação maior acaba sendo melhor para a organização do código. Ao desenvolver um software devemos preferir ter mais interfaces específicas, em vez de uma única interface grande e de uso geral, o que tem relação com o princípio de responsabilidade única (SRP), de acordo com essa ideia vejamos a seguinte implementação. Essa é a interface de Payment onde temos os métodos relacionados a um processo de transação dentro da aplicação:

public interface Payment {
    void initiatePayments(List<Pokeball> items);
    boolean status();
    List<Pokeball> getItems();
    void intiateLoanSettlement(List<Pokeball> items);
    List<Pokeball> initiateRePayment();
}

Agora vamos ver a implementação desta interfacenas Classes WalletService e LoanService.

//Service Wallet implementando a interface de Pagamento
public class WalletService implements Payment {
    private List<Pokeball> items;

    @Override
    public void initiatePayments(List<Pokeball> items) { /* code */ }
    @Override
    public boolean status() { /* code */ }
    @Override
    public List<Pokeball> getItems() { /* code */ }

    @Override
    public void initiateLoanSettlement(List<Pokeball> items) {
        throw new UnsupportedOperation("This is not a loan payment");
    }

    @Override
    public List<Pokeball> initiateRePayment() {
        throw new UnsupportedOperation("This is not a loan payment");
    }

}

O service de Wallet precisa implementar todos os métodos da interface, o que acaba causando comportamentos inesperados quando métodos relacionados a empréstimo como o initiateLoanSettlement são chamados, nesse exemplo a classe Wallet não tem como iniciar um empréstimo assim como a classe de LoanService não tem como iniciar um pagamento.

// Service Loan implementando a interface e Pagamento
public class LoanService implements Payment {
    private List<Pokeball> items;

    @Override
    public void initiatePayments(List<Pokeball> items) {
        throw new UnsupportedOperation("This is not a wallet payment");
    }

    @Override
    public boolean status() { /* code */ }
    @Override
    public List<Pokeball> getItems() { /* code */ }
    @Override
    public void initiateLoanSettlement(List<Pokeball> items) { /* code */ }
    @Override
    public List<Pokeball> initiateRePayment() { /* code */ }
}

Como solução para este problema segue um exemplo de implementação de interfaces mais específicas que atendem melhor a necessidade de nossa aplicação:

Diagrama de Classe — Exemplo ISP

Neste novo desenho cada Service implementa de uma interface com os métodos que pertence ao seu domínio. Como dá para perceber o número de arquivos aumentou, mas isso não é um problema porquê o foco é manter as classes implementando apenas os métodos necessários.

Resumindo este princípio, caso uma interface comece a ganhar muitas responsabilidades ela deve ser dividida em interfaces menores, as quais serão implementadas pelos clientes (entidades de software). Lembrando que os clientes não devem implementar métodos que não utilizam.

Principais problemas quando não implementado:

  • Comportamentos inesperados quando chamar uma função
  • Utilização dos conceitos de herança de forma errada
  • Ferir o primeiro princípio da responsabilidade única

Dependency Inversion Principle

O princípio de inversão de dependência é sobre como classes devem depender de abstrações, não de implementações específicas dessas abstrações. Quando fazemos isso evitamos que detalhes ditem como devemos implementar uma solução. Se você realiza a chamada de uma classe dentro de outra está classe irá depender da que foi chamada. Isso resulta em um grande acoplamento e dificulta na alteração de módulos externos e também na escrita de testes. Busque então incorporar essa noção em seu código caso queira torna-lo mais flexível, ágil e reutilizável.

Neste exemplo a classe PokedexService está instanciando o modelMapper e também a Conexão do banco de dados dentro de seu construtor, da forma que está implementada temos uma dependência destes dois atributos. No caso temos acesso até as informações de URL, USER e PASSWORD referentes a conexão ao banco de dados, o que sai totalmente do escopo original da Pokedex.

@Service
public class PokedexService {

    private ModelMapper modelMapper;
    Connection connection;

    public PokedexService() {
        modelMapper = new ModelMapper();
        connection = DriverManager.getConnection(DATABASE_URL, USER, PASSWORD);
    }
}

Para resolver esses problemas transformamos a conexão em uma interface de Repository, que é a abstração que comentei a cima, onde ela pode ser trocada facilmente por outros clientes de banco de dados. Além disso ambos Repository e ModelMapper são trazidos para classe de Service por meio da passagem da informação pelo construtor (Dependency Injection) sendo possível uma maior flexibilidade.

@Service
public class PokedexServiceImpl implements PokedexService {

    private PokemonRepository pokemonRepository;
    private ModelMapper modelMapper;

    public PokedexServiceImpl(PokemonRepository pokemonRepository,
                              ModelMapper modelMapper) {
        this.pokemonRepository = pokemonRepository;
        this.modelMapper = modelMapper;
    }
}

Podemos utilizar este conceito também para estruturar melhor nossa aplicação, como exemplo no projeto criei uma interface por serviço, desta forma tenho uma visão melhor de quais são os métodos realmente necessários e também tenho uma facilidade maior para trocar uma implementação caso necessário.

Estrutura de arquivos no projeto de exemplo

Principais problemas quando não implementado:

  • Alto acoplamento entre classes
  • Aumento da complexidade para trocar um sistema externo (adapter)
  • Aumento da complexidade na escrita de testes

Recomendações

Conheci os princípios do SOLID por meio do livro Clean Code. É uma leitura que recomendo para aqueles que buscam melhorar a forma que escrevem código. Poderia recomendar também diversos vídeos e artigos como este, contudo, o mais importante é implementar o que viu aqui em seus projetos, só assim estes conceitos ficaram mais enraizados em sua mente.

Para ajudar você a por a mão na massa segue um link que pode ser bem útil:

Repositório GitHub
Aqui está o link para o repositório no GitHub onde coloquei os exemplos de código escritos em Java: https://github.com/jjeanjacques10/solid

Conclusão

Quando implementamos estes conceitos em nossos projetos temos um código melhor organizado, mais robusto e de fácil manutenção. Que é basicamente o sonho de todo desenvolvedor. O SOLID sozinho não vai fazer milagres, mas ajuda aqueles que trabalham com programação orientada a objetos a terem uma visão mais voltada para o reaproveitamento e boas práticas.

No começo pode ser um pouco complicado entender todos estes princípios, mas o que me ajuda a compreender cada dia mais o SOLID é buscar implementa-los todos os dias nos códigos que desenvolvo. Se quer aprender então coloque a mão na massa!


Caso tenha alguma crítica, sugestão ou dúvida fique a vontade para me enviar uma mensagem. Até a próxima!

LinkedIn: https://www.linkedin.com/in/jjean-jacques10/


Referências

Carregando publicação patrocinada...