EXEMPLOS DE PADRÕES DE PROJETO

Para exemplificar as principais vantagens de utilizar Padrões de Projetos, foram escolhidos os padrões Simple Factory e Strategy, devido a simplicidade de utilização e por resolverem problemas comuns do dia-a-dia. Nos capítulos seguinte vamos mostrar um exemplo de código que pode se beneficiar dos padrões, mostrando como ficaria o resultado final e quais suas vantagens.

Ao estudar aplicações de padrões, o mais importante não é saber como aplicar o padrão ou qual implementação utilizar. Refatorar um código para aplciar um padrão não é tão difícil quanto decidir quando aplicá-lo. Ao ver exemplos, atente para as melhorias de design que precisam acontecer e em como indentificá-las.

O PADRÃO SIMPLE FACTORY

Criar objetos geralmente é uma tarefa simples, passamos valores para o construtor e pronto, temos um objeto instanciado e pronto para uso. No entanto, algumas vezes a criação não é tão direta assim e alguma manipulação com os dados pode ser necessária. Em outros casos é preciso configurar o objeto, mesmo depois da execução do construtor.

Quando instanciar objetos não for uma tarefa tão simples, os padrões de criação oferecem boas soluções. Como vimos antes, os padrões documentados pela Gangue dos Quatro foram classificados em grupos de acordo com o tipo de problema que resolvem. Padrões que facilitam criar novas instâncias de objetos são classificados como padrões de criação.

Uma versão simples e que pode ser utilizada mais facilmente, é o Simple Factory. Nele vamos extrair a lógica que cria objeto para uma classe especializada, deixando que a manipulação dos dados não atrapalhe o restante da lógica de negócio. Alguns autores nem consideram o Simple Factory como um padrão, devido a sua simplicidade.

Exemplo de Aplicação

Na Listagem 1 temos um exemplo de código que pode se beneficiar do uso do Simple Factory. Nele, o método buscarProdutosPreferenciais recebe um usuário consulta um serviço externo para obter os produtos que o usuário costuma comprar. Ao final, os produtos são validados e filtrados para que apenas aqueles com estoque sejam retornados.

No entanto, antes de chegar na lógica que realmente faz a busca dos produtos preferenciais do usuário, é preciso configurar a chamada ao serviço externo. Essa responsabilidade extra aumenta o tamanho e a complexidade do método.

Listagem 1 - Método que busca os produtos preferenciais de um usuário
public List<Produto> buscarProdutosPreferenciais(Usuario usuario) {
  ConfiguracoesServicos config = new ConfiguracoesServicoProdutosPreferenciais();
  config.setRecurso(/produtos/preferenciais/);
  config.setIdUsuario(usuario.getId());

  if (getAmbienteAtual() == Ambiente.LOCAL) {
    config.setEndpoint(localhost:1234);
    config.setXid(gerarUuid());
    config.setVersaoApi(2.1);
  } else {
    if (toggleApiNova.habilitado()) {
      config.setVersaoApi(2.1);
      config.setXid(gerarUuid());
    } else {
      config.setVersaoApi(1.9);
      config.setXid(“”);
    }
    config.setEndpoint(https://intra.” + getAmbienteAtual().toString() + “.mega\
corp.com/);
  }

  ServicoRest servico = new ServicoRest(config);
  List<Produto> produtos = criarProdutos(servico.executarGet())

  List<Produto> produtosValidos = new ArrayList<Produto>();
  for (Produto produto : produtos) {
    if (produto.temEstoque()) {
      produtosValidos.add(produto);
    }
  }
  return produtosValidos;
}

Note quantas linhas apenas a criação das configurações toma. Isso acaba desviando a atenção da responsabilidade principal do método, que é buscar os produtos preferenciais do usuário.

No exemplo anterior existem dois tipos de configurações, uma para o ambiente local e outra para os demais ambientes. A configuração dos ambientes remotos precisa lidar ainda com um feature toggle, para determinar qual versão da API deve ser utilizada.

A criação de configurações locais pode ser extraída como mostrado na Listagem 2. A identificação do recurso é comum a todos as configurações, portanto pode ser definida já no construtor da fábrica, assim como o id do usuário. As demais configurações são específicas ao ambiente local, ficando portanto dentro do método criarConfiguracaoLocal.

Listagem 2 - Classe fábrica para criar configurações dos serviços
class FabricaConfiguracaoServicos {

  private ConfiguracoesServicos config;

  public FabricaConfiguracaoServicoProdutosPreferenciais(String idUsuario) {
    config = new ConfiguracoesServicoProdutosPreferenciais();
    config.setRecurso(/produtos/preferenciais/);
    config.setIdUsuario(idUsuario);
  }

  public ConfiguracoesServicos criarConfiguracaoLocal() {
    config.setEndpoint(localhost:1234);
    config.setXid(gerarUuid());
    config.setVersaoApi(2.1);
    return config;
  }
}

A lógica para criar as configurações de serviços para outros ambientes depende do feature toggle. Nesse caso vamos passar a informação se o toggle está ativo e o ambiente atual como parâmetros. A implementação pode ficar como na Listagem 3.

Listagem 3 - Lógica específica para configuração de serviços remotos
class FabricaConfiguracaoServicos {

  public ConfiguracoesServicos criarConfiguracaoRemota(boolean usarApiNova, Ambi\
ente ambiente) {
    if (usarApiNova) {
      config.setVersaoApi(2.1);
      config.setXid(gerarUuid());
    } else {
      config.setVersaoApi(1.9);
      config.setXid(“”);
    }
    config.setEndpoint(https://intra.” + ambiente.toString() + “.megacorp.com”);
  }
}

Para utilizar o Simple Factory vamos simplesmente substituir o código dentro do if pela chamada ao método fábrica apropriado. Vamos também extrair essa parte em um novo método, para facilitar a leitura, como mostrado na Listagem 4:

Listagem 4 - Método para buscar produtos aplicando o padrão Simple Strategy
public List<Produto> buscarProdutosPreferenciais(Usuario usuario) {
  ConfiguracoesServicos config = criarConfiguracaoDoAmbiente(usuario)

  ServicoRest servico = new ServicoRest(config);
  List<Produto> produtos = criarProdutos(servico.executarGet())

  List<Produto> produtosValidos = new ArrayList<Produto>();
  for (Produto produto : produtos) {
    if (produto.temEstoque()) {
      produtosValidos.add(produto);
    }
  }
  return produtosValidos;
}

public ConfiguracoesServicos criarConfiguracaoDoAmbiente(Usuario usuario) {
  FabricaConfiguracaoServicos fabrica = new FabricaConfiguracaoServicos(usuario.\
getId());

  if (getAmbienteAtual() == Ambiente.LOCAL) {
    return fabrica.criarConfiguracaoLocal();
  } else {
    return fabrica.criarConfiguracaoRemota(toggleApiNova.habilitado(), getAmbien\
teAtual());
  }
}

Apesar de simples, o ganho com a separação das responsabilidades é bem grande, principalmente na legibilidade do código.

Um Pouco de Teoria

Como mencionado antes, todos os padrões ajudam o seu código a seguir os princípios de design Orientado a Objetos. No caso do Simple Factory, o maior benefício é a divisão de responsabilidades seguindo o Princípio da Responsabilidade Única.

Quem lê o código do método buscarProdutosPreferenciais pode focar apenas em entender sua lógica, sem ter que se preocupar em como as configurações são criadas. Como passamos muito mais tempo lendo código do que escrevendo, essa é uma grande vantagem.

Outro benefício da separação dessas responsabilidades é que a maneira como as configurações do serviço são criadas vai mudar menos do que lógica definida em buscarProdutosPreferenciais. Ao lidar com dependências entre classes é sempre bom que uma classe dependa de outras menos prováveis de mudar.

Os testes do método buscarProdutosPreferencias também estão validando a criação das configurações de serviços, mesmo que indiretamente. Criando uma classe especializada para isso, podemos limitar o escopo dos testes e focar na lógica de negócio, testando a criação de configurações em outros testes isolados.

O Simple Factory é um bom ponto de início para separar a criação de objetos do seu uso. Como vimos no exemplo anterior, poucas classes são criadas e a estrutura do padrão é bem simples. Se o seu contexto permite isolar a maneira como objetos são criados e você tiver que lidar apenas com um tipo de objeto, o Simple Factory é uma excelente maneira de resolver o problema.

Quando Não Usar

Apesar de simples, existem situações onde utilizar o padrão Simple Factory não ajuda muito. Um sinal bem claro de que o padrão não está sendo efetivo é quando a classe fábrica começa a crescer e ter vários métodos para criar os mesmos produtos de maneiras diferentes. Essa talvez seja uma boa hora para aplicar outros padrões fábrica.

Nas seções seguintes vamos comparar como evoluir do Simple Factory para o Factory Method ou para o Builder, dependendo de qual a necessidade do seu contexto. Não vamos entrar em detalhes sobre estes padrões, mas outros recursos com mais detalhes serão indicados ao final do artigo (veja a seção de Referências Externas).

Evoluindo o Simple Factory para Factory Method

Se existem vários métodos que podem ser agrupados com suas próprias lógicas para criar objetos, provavelmente nomeados com um sufixo ou prefixo em comum, talvez seja melhor utilizar o Factory Method e separar esses grupos em outras classes fábrica.

Voltando ao exemplo anterior, imagine que será necessário criar configurações para outros serviços. Ao invés de colocar tudo em um única classe, é melhor criar fábricas especializadas em construir as configuração de cada serviço. Refatorar o Simple Factory para o Factory Method vai ajudar a evitar que a classe fábrica cresça.

Com o Factory Method, continuaremos criando objetos do tipo ConfiguracoesServicos, mas as regras deles serão divididas em classes separadas. Essas classes fábricas seguem uma mesma interface, garantindo que elas possam ser trocadas facilmente. Ao fim, evitamos ter uma classe fábrica com muitas responsabilidades e, como elas são intercambiáveis, conseguimos ter flexibilidade no código que as utiliza.

Evoluindo o Simple Factory para Builder

Se ao rever os métodos você perceber que existe pouca variação na lógica entre eles, apenas mudam-se os valores que são atribuídos ou quais atributos são utilizados, será necessário criar muitos métodos fábrica, com muita duplicação entre si.

No exemplo anterior, imagine que várias configurações de serviço devem ser criadas, mas a diferença entre os métodos é o valor de alguns atributos. Nesse caso podemos utilizar o Builder para expor a configuração dos atributos mas esconder a criação dos objetos.

Com o Builder, ao invés de definirmos o processo de como o objeto será construído, oferecemos métodos para que a classe cliente consiga configurar, de maneira simples, o produto final. Assim evitamos criar vários métodos fábrica para cada possível cenário, sem perder a separação da responsabilidade de criação.

O PADRÃO STRATEGY

Ao codificar um programa, seguimos um conjunto de regras de negócio e, muitas vezes, o fluxo precisa ser dividido em vários caminhos. Quando o código cresce com várias regras diferentes, podemos recorrer aos padrões comportamentais para melhorar a flexibilidade e facilitar a manutenção.

Padrões comportamentais vão dividir as responsabilidades para resolver problemas de complexidade do código. O Strategy é um exemplo de padrão comportamental que busca isolar os vários caminhos que o algoritmo pode seguir, facilitando escolher um fluxo específico sem precisar se preocupar com os outros.

Exemplo de Uso

Na Listagem 5 temos a implementação do código que deve calcular os pontos que um passageiro ganhou em voos pela companhia aérea. As regras variam de acordo com a distância voada, o status do passageiro e o tipo de bilhete comprado.

Dado um passageiro, o algoritmo busca a lista de voos que ainda não foram computados e, para cada um deles aplica as regras de pontuação. Caso o bilhete seja de primeira classe e o passageiro tenha a categoria diamante, a distância total do voo será convertida em pontos. Caso o passageiro não seja diamante, ele receberá metade da distância em voos de primeira classe, um quarto em voos econômicos e um décimo em voos promocionais.

Listagem 5 - Método para calcular a pontuação de um passageiro
public int calcularPontuacao(Passageiro passageiro) {
  int totalDePontos = 0;
  List<Voo> voosPendentes = passageiro.getVoosComPontuacaoPendente();

  for (Voo voo : voosPendentes) {
    Bilhete bilhete = passageiro.getBilhete(voo.getNumero());

    if (bilhete.isPrimeiraClasse() && passageiro.isDiamante()) {
      totalDePontos += voo.getDistancia();
    } else if (bilhete.isPrimeiraClasse()) {
      totalDePontos += voo.getDistancia()/2;
    } else if (bilhete.isClasseEconomica()) {
      totalDePontos += voo.getDistancia()/4;
    } else {
      // bilhete promocional
      totalDePontos += voo.getDistancia()/10;
    }
  }

  passageiro.atualizarPontuacao(totalDePontos);
  passageiro.marcarVoosComoProcessados();

  return totalDePontos;
}

Separando os fluxos do algoritmo, conseguimos extrair 4 estratégias: 1) voo de um passageiro diamante na primeira classe, 2) voo na primeira classe, 3) voo na classe econômica e 4) voo promocional.

A interface comum das estratégias é bem simples e precisa apenas oferecer um método que retorna a pontuação do passageiro, dado a distância do voo. Uma Interface poderia ser implementada conforme a Listagem a seguir:

Listagem 6 - Interface simples para estratégias de cálculo de pontuação
interface EstrategiaDePontuacao {
  public int calcularPontuacao(int distanciaDeVoo);
}

A implementação das estratégias também é bem simples e pequena, pois as regras separadas são simples. Veja como ficaria o código na Listagem 7:

Listagem 7 - Classes com implementação de estratégias seguindo a interface
class EstrategiaPrimeiraClasseDiamante implements EstrategiaDePontuacao {
  public int calcularPontuacao(int distanciaDeVoo){
    return distanciaDeVoo;
  }
}

class EstrategiaPrimeiraClasse implements EstrategiaDePontuacao {
  public int calcularPontuacao(int distanciaDeVoo){
    return distanciaDeVoo/2;
  }
}

class EstrategiaClasseEconomica implements EstrategiaDePontuacao {
  public int calcularPontuacao(int distanciaDeVoo){
    return distanciaDeVoo/4;
  }
}

class EstrategiaPromocional implements EstrategiaDePontuacao {
  public int calcularPontuacao(int distanciaDeVoo){
    return distanciaDeVoo/10;
  }
}

Um ponto de atenção ao aplicar o padrão Strategy é encontrar onde deve ser decidido qual estratégia utilizar. Para determinar a regra de pontuação precisamos de informações de um passageiro e do bilhete. Vamos então criar um método na classe Bilhete que retorna qual a estratégia de pontuação deve ser utilizada. Veja a implementação na Listagem seguinte:

Listagem 8 - Método que decide qual estratégia deve ser utilizada
class Bilhete {
  public EstrategiaDePontuacao getEstrategiaDePontuacao(boolean isPassageiroDiam\
ante){
    if (bilhete.isPrimeiraClasse() && isPassageiroDiamante) {
      return new EstrategiaPrimeiraClasseDiamante();
    } else if (bilhete.isPrimeiraClasse()) {
      return new EstrategiaPrimeiraClasse();
    } else if (bilhete.isClasseEconomica()) {
      return new EstrategiaClasseEconomica();
    } else {
      return new EstrategiaPromocional();
    }
  }
}

Agora basta que o método calcularPontuacao pegue a estratégia do bilhete para definir quantos pontos devem ser dados ao passageiro. A utilização ficaria como mostrado na Listagem 8.

Listagem 9 - Cálculo de pontuação utilizando as estratégias implementadas
public int calcularPontuacao(Passageiro passageiro) {
  int totalDePontos = 0;
  List<Voo> voosPendentes = passageiro.getVoosComPontuacaoPendente();

  for (Voo voo : voosPendentes) {
    Bilhete bilhete = passageiro.getBilhete(voo.getNumero());
    EstrategiaDePontuacao estrategia = bilhete.getEstrategiaDePontuacao(passagei\
ro.isDiamante());
    totalDePontos += estrategia.calcularPontuacao(voo.getDistancia());
  }

  passageiro.atualizarPontuacao(totalDePontos);
  passageiro.marcarVoosComoProcessados();

  return totalDePontos;
}

Ao quebrar o fluxo do algoritmo em estratégias conseguimos simplificar o método calcularPontuacao e dividir a responsabilidade de saber qual regra utilizar com a classe Bilhete. Por sua vez, a lógica de cálculo da pontuação fica definida dentro de cada uma das estratégias.

No final ganhamos maior coesão ao separar as responsabilidades, facilitando modificar e até mesmo adicionar novas regras de pontuação.

Um Pouco de Teoria

Assim como no caso do padrão Simple Factory, ao aplicar o Strategy fica bem claro a divisão das responsabilidades, seguindo o Princípio da Responsabilidade Única. A lógica para calcular a pontuação fica mais simples uma vez que está separada.

Após distribuir os fluxos do algoritmo nas classes estratégias, podemos também mover os testes e fazê-los validar apenas uma parte do código. Considerando que testes são a melhor forma de documentação, uma nova pessoa na equipe conseguirá ver o funcionamento de um fluxo de cada vez, ao invés de todo o algoritmo.

Como a estrutura de estratégias define uma interface comum para todos, também é fácil notar o Princípio da Substituição de Liskov. Trocar as estratégias, ou até mesmo adicionar novas, não vai ter nenhum impacto pois o código que as utiliza continuará lidando com a mesma interface.

O Princípio da Inversão de Dependência também fica claro pois o cliente não usa as estratégias concretas diretamente, apenas uma interface. Assim, cada implementação pode ter suas próprias regras sem interferir na estrutura do código.

Outro ponto importante é que devido ao baixo acoplamento entre a classe, fica mais fácil evoluir os códigos separadamente. Como as estratégias tendem a mudar menos do que o código do cliente, também seguimos a ideia de que uma classe deve depender de outras menos prováveis de mudar.

Quando Não Usar

Semelhante ao padrão Simple Factory, o Strategy é uma excelente maneira de começar a refatorar seu código. Mas, devido a sua simplicidade, eventualmente pode ser necessário partir para soluções mais robustas e evitar que as estratégias cresçam sem limite.

Como em qualquer padrão, é importante entender o contexto do problema para identificar a melhor solução. Se o contexto muda, a solução provavelmente mudará. Existem duas grandes necessidades de contexto para aplicar o Strategy de maneira efetiva: 1) os fluxos do algoritmo podem ser separados de maneira independente e 2) uma vez que sabemos qual caminho seguir, ele não muda até o final da execução do algoritmo.

Nas seções a seguir vamos comparar o Strategy com os padrões Template Method e State, que podem ajudar quando um dos contextos detalhados anteriormente não puder ser cumprido. Não vamos entrar em detalhes sobre a implementação destes padrões, mas recursos com mais detalhes serão indicados ao final do artigo (veja a seção de Referências Externas).

Evoluindo o Strategy para Template Method

Garantir que o fluxo do algoritmo possa ser separado nem sempre é possível. A vezes as regras que você precisa aplicar não vão permitir que o algoritmo seja quebrado em fluxos diferentes. Uma ideia para começar e validar essa possibilidade é tentar duplicar o código entre as estratégias.

Se o algoritmo separado precisar de muita duplicação, talvez a separação em estratégias não seja a melhor opção. Os ganhos obtidos com a flexibilidade da solução não vão compensar os gastos com a manutenção de código repetido. Nesses casos, outros padrões podem ajudar a resolver o problema, como o Template Method.

Ao aplicar o Template Method definimos uma estrutura base, que será comum a todas as variações de fluxos, além de vários pontos gancho onde podemos variar a implementação. Dessa forma os pontos comuns ficam centralizados na classe mãe e as classes filhas podem implementar sua própria lógica se beneficiando do algoritmo base.

Caso fosse necessário aplicar uma mesma regra em todas as diferentes estratégias (por exemplo, incluir um bônus de 10% para primeiras compras), essa regra precisaria ser duplicada. Poderíamos definir um template que calcula a pontuação baseado na distância, regra que seria definida pelas classes filhas, e sempre adiciona essa bonificação extra ao final.

Evoluindo o Strategy para State

Com relação a segunda necessidade de contexto, precisamos garantir que uma vez que a estratégia é definida ela não mudará, já que a principal vantagem de utilizar o Strategy é diminuir a quantidade de decisões que precisam ser tomadas. Se for preciso fazer várias verificações em pontos diferentes para descobrir qual estratégia utilizar, ou modificar a estratégia atual, a aplicação do padrão não ajudará muito.

Na maioria das vezes é preciso revisitar todo o algoritmo para tentar fazer com que essa decisão só aconteça uma vez. No entanto, caso as regras do algoritmo realmente precisem que o fluxo mude no meio do caminho, pode ser melhor aplicar o padrão State.

Assim como no padrão Strategy, o State também sugere dividir os fluxos do algoritmo mas, ao invés de escolher uma estratégia a ser seguida, criamos estados contendo suas informações e regras.

Esses estados serão facilmente trocados, conforme necessário, pois cada um deles saberá qual deve ser o próximo estado a ser chamado, bem semelhante a uma Máquina de Estados Finita. Assim não precisamos nos preocupar em escolher qual estado utilizar, basta configurar o ponto de partida.

Poderíamos evoluir o código do exemplo anterior para o State caso os status do passageiro tivessem uma maior influência (por exemplo, se para cada status um multiplicador de pontuação diferente fosse aplicado). Ao invés de fazer com que cada estratégia adicione vários ifs para saber o que fazer dependendo do passageiro, cada status seria mapeado para um estado que saberia aplicar suas regras bem como quando o passageiro evoluiu para um novo estado.