Desde que eu decidi seguir minha carreira em TI como desenvolvedor, eu tenho me deparado com inúmeros acrônimos mágicos voltados especificamente para regrar a forma com que eu escrevo meu código. E tem para todos os gostos, desde o começo do projeto até a concepção de uma pequena rotina, coisas como DDD, DRY, BDD, SOLID, YAGNI, KISS, TDD, GOF, OOP, etc.
OCP
Acontece que, durante toda a minha jornada profissional, e lá se foram uns 8 bons anos, uma dessas letrinhas sempre foi um desafio pra mim. Estou falando do O do SOLID, o princípio Aberto-Fechado (do inglês Open-Closed principle). SOLID é um acrônimo que reúne cinco princípios de design orientado a objetos, que visam melhorar a qualidade, a manutenibilidade e a extensibilidade do código. Alguns desses princípios já existem implementados implicitamente na linguagem de programação que eu uso, o C#, enquanto outros são menos óbvios de serem notados.
Recentemente eu tive uma iluminação e consegui perceber e aplicar um caso de uso para o OCP. Tudo começa pelo fato de que eu não gosto muito de seguir regras, mas só as de programação, principalmente porque a maioria delas vieram de um outro contexto, eventualmente totalmente diferente daquele em que o meu código está contido. Então, eu dou preferência a entender essas regras, princípios ou normas, incluindo entender as motivações que levaram cada uma delas a existir para só então aplicá-las no meu trabalho. No final, a tendência é que meu código acabe não parecendo o que uma pessoa costuma ver num tutorial do dia-a-dia no YouTube, mas atinge belamente todos os objetivos esperados.
A definição original do OCP, de Bertrand Meyer em 1988, é a seguinte:
Um módulo será dito aberto se ele ainda estiver disponível para extensão. Por exemplo, é possível adicionar campos aos dados registrados por um módulo ou novos elementos ao conjunto de funções que ele executa. Um módulo será dito fechado se ele estiver disponível para uso por outros módulos. Isso pressupõe que ao módulo tenha sido dada uma descrição bem definida e estável (interface) que não esteja sujeita a mudanças.
No meu contexto de hoje, com as metodologias ágeis, a velocidade com que uma mudança de regra de negócio ocorre e a forma com que os requisitos dos sistemas são gerados, costuma-se ser muito mais simples adicionar um campo em uma classe e aplicar um tratamento específico para ele nos momentos que se fazem necessários do que criar outra classe com herança ou composição. Então, pela definição, podemos considerar que essas classes podem até ter uma descrição bem definida, mas são bem instáveis. E você pode me perguntar: mas de onde é que saem essas mudanças? Uma hora é da cabeça de algum usuário, outrora é de um grupo de políticos que resolve mexer numa lei, tem de tudo. E é por isso que o OCP sempre foi um desafio pra mim, ele nunca fez muito sentido, até que...
Caos
Imagine um sistema de pagamentos que contenha uma lógica gigantesca para que os pagamentos sejam processados. Mas, em nome da simplicidade almejada no blog, teremos apenas as informações relevantes para o OCP. Esta abaixo é uma classe de contexto, um montinho de informações que podem ou não ser necessários para a nossa rotina.
public class PaymentContext
{
public BankAccount BankAccount { get; set; }
public PaymentType PaymentType { get; set; }
public Bill[] Bills { get; set; }
public PaymentContext(BankAccount bankAccount,
Bill[] bills,
PaymentType paymentType)
{
BankAccount = bankAccount;
Bills = bills;
PaymentType = paymentType;
}
}
E agora o nosso processador de pagamentos. De novo, em nome da simplicidade, os métodos resultam apenas em um bool para atestar o sucesso da operação. Mas, na vida real, cada retorno desse requer lógicas ordens de magnitude maior que essa.
internal class PaymentProcessor : IPaymentProcessor
{
public bool ProcessPayment(PaymentContext paymentContext)
{
switch (paymentContext.BankAccount.Bank)
{
case Bank.BankA:
return ProcessPaymentForBankA(paymentContext);
case Bank.BankB:
return ProcessPaymentForBankB(paymentContext);
default: return false;
}
}
private static bool ProcessPaymentForBankA(PaymentContext paymentContext)
{
switch (paymentContext.PaymentType)
{
case PaymentType.InstantTransfer:
return false;
case PaymentType.SlowTransfer:
return true;
case PaymentType.Invoice:
return true;
case PaymentType.FastTransfer:
return false;
case PaymentType.Card:
return true;
default: return false;
}
}
private static bool ProcessPaymentForBankB(PaymentContext paymentContext)
{
switch (paymentContext.PaymentType)
{
case PaymentType.InstantTransfer:
return false;
case PaymentType.SlowTransfer:
return true;
case PaymentType.Invoice:
return true;
case PaymentType.FastTransfer:
return false;
case PaymentType.Card:
return true;
default: return false;
}
}
}
Ao bater o olho nesse código já é possível notar algumas peculiaridades. A primeira delas é a quantidade imensa de possibilidades de processos, pois uma conta bancária pode pertencer a um de muitos bancos ao mesmo tempo em que o pagamento se refere a um tipo específico. A outra peculiaridade remete ao OCP: é complicado "fechar" essa classe se a qualquer momento algo novo pode surgir, como por exemplo surgiu o PIX no Brasil, sem que as implementações se tornem um caos na equipe. Esse é um dos casos em que não basta apenas adicionar um parâmetro e tudo ficará bem no final. Mas as soluções estão justamente na sopa de letrinhas dos acrônimos mencionados anteriormente adicionadas de algumas mágicas do C#.
Vamos à conversão de sem-OCP para full-OCP.
Responsabilidades
Partindo do princípio da responsabilidade única (S do SOLID), podemos definir que o nosso processador de pagamentos na verdade se trata de um tipo de direcionador ou seletor. Sua responsabilidade é descobrir qual banco e forma de pagamento do contexto em voga e enviá-lo para o processador correto. E assim temos que um processador de verdade é responsável por processar um pagamento de um banco com uma forma de pagamento específica. As interfaces abaixo refletem isso ao mesmo tempo que mantêm a interface original intocada a fim de evitar quebrar alguma outra parte que possa estar consumindo-a (e sim, eu sei que no exemplo aqui não tem outro consumidor, mas prefiro manter o garbo e elegância).
internal interface IPaymentProcessSelector
{
IBankTypePaymentProcessor SelectPaymentProcessor(PaymentContext paymentContext);
}
internal interface IBankTypePaymentProcessor
{
bool ProcessPayment(PaymentContext paymentContext);
}
Com elas, já podemos "fechar" o nosso processador de pagamentos, pois já temos o que é necessário para que ele cumpra seu papel.
internal sealed class PaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessSelector paymentProcessSelector;
public PaymentProcessor(IPaymentProcessSelector paymentProcessSelector)
{
this.paymentProcessSelector = paymentProcessSelector;
}
public bool ProcessPayment(PaymentContext paymentContext)
{
return paymentProcessSelector.SelectPaymentProcessor(paymentContext)
.ProcessPayment(paymentContext);
}
}
Notou o truque? Ele já nasce dependente de um IPaymentProcessSelector, que normalmente será injetado através de um container de injeção de dependências. Apesar do exemplo ainda não possuir uma implementação de DI, isso faz parte desta ideia de inclusão do OCP e já existe uma forma fácil de construir um desses nativamente no C#. Vamos continuar que chegaremos lá.
Separando o joio, do trigo, do joio, do trigo.
Agora na etapa em que falamos do protagonista da história, ele mesmo, o IPaymentProcessSelector. Sua responsabilidade é selecionar o processador de pagamentos correto para o contexto de dados que transita por ali.
Considerando o exemplo inicial, onde a seleção toda acontecia por meio de switches, se as lógicas implementadas fossem extremamente complexas para cada tipo de processamento, não bastaria isolar cada uma em seu próprio IBankTypePaymentProcessor, pois o problema continuaria nos switches, que continuariam exigindo a alteração direta para inclusão de novos processos.
A solução mais simples que me vem em mente seria encontrar uma forma de indexar todos os IBankTypePaymentProcessor e injetá-los na implementação do IPaymentProcessSelector de alguma forma que não fosse mais necessária a intervenção em nenhuma outra classe. Para chegarmos nessa solução, primeiro criamos uma estrutura que seja capaz de identificar um IBankTypePaymentProcessor, conforme abaixo:
internal readonly struct BankType
: IEquatable<BankType>
{
private readonly Bank bank;
private readonly PaymentType type;
public BankType(Bank bank,
PaymentType type)
{
this.bank = bank;
this.type = type;
}
public override bool Equals(object? obj)
=> obj is BankType choice
&& Equals(choice);
public bool Equals(BankType other)
{
return type == other.type &&
bank == other.bank;
}
//GetHashCode() and other equality operators here
}
internal interface IBankTypePaymentProcessor
{
BankType PaymentBankType { get; }
bool ProcessPayment(PaymentContext paymentContext);
}
Agora que um IBankTypePaymentProcessor já pode ser identificado, vamos criar algumas implementações, assim:
internal class BankAInstantTransferPaymentProcessor
: IBankTypePaymentProcessor
{
public BankType PaymentBankType
=> new(Bank.BankA, PaymentType.InstantTransfer);
public bool ProcessPayment(PaymentContext paymentContext)
{
return true;
}
}
internal class BankBSlowTransferPaymentProcessor
: IBankTypePaymentProcessor
{
public BankType PaymentBankType
=> new(Bank.BankB, PaymentType.SlowTransfer);
public bool ProcessPayment(PaymentContext paymentContext)
{
return false;
}
}
O próximo passo vai ficar a cargo do sistema de injeção de dependências do .NET, aquele que eu mencionei que já existe e iríamos usar. Há mais ou menos um mês, eu fiquei sabendo que é possível registrar múltiplas implementações para a mesma interface e usá-las injetadas como IEnumerable.
var services = new ServiceCollection();
services.AddSingleton<IPaymentProcessSelector, PaymentProcessSelector>();
services.AddSingleton<IPaymentProcessor, PaymentProcessor>();
services.AddSingleton<IBankTypePaymentProcessor, BankAInstantTransferPaymentProcessor>();
services.AddSingleton<IBankTypePaymentProcessor, BankBSlowTransferPaymentProcessor>();
var serviceProvider = services.BuildServiceProvider();
internal sealed class PaymentProcessSelector : IPaymentProcessSelector
{
private readonly Dictionary<BankType, IBankTypePaymentProcessor> bankTypePaymentProcessors = new();
public PaymentProcessSelector(IEnumerable<IBankTypePaymentProcessor> bankTypePaymentProcessors)
{
foreach (var processor in bankTypePaymentProcessors)
{
if (!this.bankTypePaymentProcessors.TryAdd(processor.PaymentBankType, processor))
{
throw new ArgumentException($"There are more than one processor for {processor.PaymentBankType}.");
}
}
}
public IBankTypePaymentProcessor SelectPaymentProcessor(PaymentContext paymentContext)
{
var bankType = new BankType(paymentContext.BankAccount.Bank, paymentContext.PaymentType);
if (!bankTypePaymentProcessors.TryGetValue(bankType, out var processor))
{
throw new ArgumentException($"A payment processor for {bankType} is not available.");
}
return processor;
}
}
Antes, eu não sabia que isso existia e daria certo, mas se você executar um código assim, no construtor do PaymentProcessSelector você receberá os dois IBankTypePaymentProcessor registrados na coleção de serviços. Isso é muito fantástico, pois acabamos de "fechar" mais uma classe já que o nosso seletor não precisa mais de modificações e cumpre sua missão com êxito.
Porém existe um contraponto, que pra mim é insignificante se a análise do sistema estiver estável. Caso você queira injetar IBankTypePaymentProcessor específico, isso não é possível, pois quando o IServiceProvider resolve a dependência, ele seleciona a última que foi registrada. No caso abaixo, por exemplo, receberíamos no construtor a BankBSlowTransferPaymentProcessor. Mas isso não me soa muito razoável, pois se você precisa de um processador específico, use o seletor, ele está lá pra isso.
internal class SingleDependencyBankType
{
private readonly IBankTypePaymentProcessor bankTypePaymentProcessor;
public SingleDependencyBankType(IBankTypePaymentProcessor bankTypePaymentProcessor)
{
this.bankTypePaymentProcessor = bankTypePaymentProcessor;
}
}
O fino ajuste.
Se você chegou até aqui, deve ter percebido que ainda restou um "ponto aberto", ou seja, ainda existe no código um momento em que não é possível escapar da terrível alteração que fere o OCP. O ponto que me refiro é o registro das dependências na coleção de serviços. Da forma como está implementado, ainda é necessário que, após criar uma nova classe do tipo IBankTypePaymentProcessor, tal classe seja registrada manualmente. Então, depois de toda essa engenharia, tudo me parece voltar à estaca zero e a intervenção manual é obrigatória. Não é!
Usando reflection é possível encontrar todas as implementações de uma interface existentes num assembly, e depois, usando um outro método menos comum do IServiceCollection, registrar todas. Saca só:
var type = typeof(IBankTypePaymentProcessor);
var implementations = Assembly.GetExecutingAssembly().GetTypes()
.Where(p => type.IsAssignableFrom(p))
.Where(p => !p.IsInterface);
foreach (var implementation in implementations)
{
services.Add(new ServiceDescriptor(type, implementation, ServiceLifetime.Singleton));
}
Pronto! Mais uma rotina "fechada". A partir de agora, toda nova implementação de IBankTypePaymentProcessor já será automaticamente registrada e injetada. Talvez eu seja preguiçoso, mas eu adoro isso e acho que é muito prático para o dia-a-dia do programador.
Tem um repositório com o código todo. Veja AQUI.
Conclusão
Concluindo, eu sinto que o OCP é um princípio importante que deve ser seguido de forma inteligente visando a extensibilidade e a manutenibilidade do código. No entanto, em ambientes com regras de negócio muito voláteis, sempre foi difícil aplicá-lo de maneira eficiente, sendo mais simples realizar uma alteração rápida. Para esses casos, o uso bem definido do princípio de responsabilidade única pode ser uma alternativa mais adequada, pois se cada regra está isolada, alterá-la não é um risco, mas uma necessidade. Por fim, é importante salientar que cada situação é única e requer uma análise cuidadosa, não sigam esse post como regra.
PS.: Será que algum dia eu, como dev, serei substituído por uma IA? Pelo menos meu texto é mais legal que o do ChatGPT.
PS2.: Porquê eu fiz a implementação do IServiceProvider manualmente? Eu me acostumei a fazer isso em aplicações Windows Forms e Console onde não existe um container padrão implementado. No ASP.Net já tem, fuça lá.
Top comments (0)