Nos últimos anos vimos muitas implementações que usam Tipos (classes ou interfaces) como discriminador único em diversos contextos.
Essa é uma decisão prejudicial que torna o design mais burocrático, eleva a complexidade desnecessariamente. Como veremos nesse post o resultado é que temos um código mais caro e mais pobre.
Esse fenômeno afeta o mecanismo de injeção de dependência do ASP.NET, MediatR, Masstransit e outras várias implementações da Microsoft ou da comunidade.
Há exemplos dessa natureza por todo lado.
Hoje é dia de explicar porque tipos, sozinhos, são incompletos e porque estamos reaproveitando cada vez menos nosso código.
Essa é a continuação de um papo...
Contexto
O site que você está lendo esse texto, o gaGO.io existe desde 2008, com outro nome, outro domínio, mas em sua essência, o mesmo, sofrendo um reboot total em 2014, incentivado por um ataque bem-sucedido ao meu wordpress, graças a uma falha de segurança na dobradinha PHP + IIS + Windows Server.
Um dos primeiros assuntos que dediquei a falar aqui foi Inversão e Injeção de Dependência.
Quando escrevi IoC e Dependency Injection – Os erros comuns, em 2014, usei Register/Resolver e sua classificação como anti-pattern trazer luz para um problema do qual suspeitava ser um problema, e que ao longo de uma década se confirmou.
Na minha análise, era um erro de design com repercussões, e implicações negativas para o reaproveitamento de código, produzindo um aumento significativo da complexidade no design, em algumas situações comuns.
De 2006 a 2010 a discussão sobre IoC e DI eram mais latentes, já que a comunidade estava começando a adotar esses padrões em aplicações .NET e, portanto, toda a comunidade tentava entender e promover práticas e boas práticas.
Especificamente falando de Register/Resolver, temos 2 contrapontos que servem para ilustrar como essa discussão é densa e atravessa décadas.
Service Locator is an Anti-Pattern
Mark Seemann
2010
link
Service Locator is not an Anti-Pattern
Jimmy Bogard
2022
link
Mas afinal, qual é o problema?
Primeiro precisamos ficar na mesma página, então vamos contextualizar alguns assuntos para que fique fácil digerir esse conteúdo.
Quando me refiro a Tipo, estou falando de Classes e Interfaces. Structs também entram, mas nesse texto você verá que será disprezível.
Então, Tipos (classes/interfaces) se expressam como blocos com estado e comportamento. Esses blocos, como pecinhas de lego, podem ser rearranjados em diferentes contextos, com diferentes configurações, chamamos isso de reaproveitamento.
Ou seja, quando falamos de reaproveitamento, podemos falar de 2 fenômenos diferentes.
Reaproveitamento de Instâncias: Quando usamos uma instância mais de uma vez em variados contextos, evitando a duplicação de código.
Reaproveitamento de Tipos: Quando criando várias instâncias de um mesmo tipo, nos aproveitando das diferentes configurações possíveis que podemos obter ao injetar dependências e configurações diferentes.
Vamos pensar em uma Conexão com o SQL Server. Nós podemos reaproveitar uma única instância do tipo SQLConnection, mas podemos também criar instâncias diferentes, que possuem conexões para bancos diferentes.
No post de 2014, descrevo um exemplo de 2 instâncias da classe FileLogger, cada uma configurada com um nome de arquivo diferente em uma string do construtor, de tal forma que usassem arquivos diferentes para gravar o log. O exemplo continua atual e continua servindo como pano de fundo para ilustrar o problema.
O problema emerge quando o tipo é usado como única identificação para um determinado contexto.
Quando usamos um Tipo (classe ou interface) como identificador de contexto, continuamos com a capacidade de reaproveitar instâncias, somos obrigados a refatorar o tipo ou criar controladores para lidar com o contexto.
Para reaproveitar um tipo que foi usado como identificador de contexto, precisamos sujar nossos modelos:
- Criando propriedades
- Criando novos tipos (derivados ou com um ancestral comum)
- Criando mecanismos lidem com os diferentes contextos para entregar (ou até mesmo criar) instâncias corretas.
- Managers
- Controladores
- Factories
A criação desses elementos desnecessários é o aumento de complexidade, ou o imposto que pagamos por uma decisão equivocada.
Se você usa ASP.NET Core, você está pagando esse imposto hoje!
Se olharmos para injeção de dependência, quando registramos um tipo, usando apenas o tipo como identificador, perdemos a capacidade de ter várias configurações diferentes para esse tipo de forma transparente, sem necessidade de remodelar o próprio tipo ou criar novos tipos para lidar com o contexto.
Em 2018 voltei ao post IoC e Dependency Injection – Os erros comuns e mostrei o efeito colateral dessa abordagem, na prática, analisando como esse problema afeta a modelagem, demandando a criação de managers e factories como o HttpClientFactory feito pela equipe da Microsoft.
A implementação do ServiceCollection e ServiceProvider nos permite lidar com várias instâncias de um mesmo tipo, mas somente como um conjunto, em lista. Assim, olhamos para o tipo como identidade de um grupo de instâncias. Demandando estratégias auxiliares.
Tais implementações só se fazem necessárias porque estamos diante de um problema
O problema não se trata apenas de injeção de dependência, se trata de usar Tipos como identificadores.
Por exemplo, quando apenas os tipos Request/Response expressam a identidade do handler que realizará uma operação, no MediatR, perdemos a capacidade de reaproveitar esse conjunto Request/Response em contextos diferentes.
Muitas vezes desenvolvedores e arquitetos consideram o reaproveitamento de uma única instância e ignoram o reaproveitamento do TIPO (classe/interface) como se não houvesse a possibilidade de que existam instâncias diferentes, com configurações diferentes servindo a macro-propósitos iguais e micro-propósitos diferentes.
Qual o impacto no código que escrevemos no dia-a-dia?
Toda vez que ignoramos a capacidade de termos múltiplas instâncias únicas de um mesmo Tipo, precisamos trazer para a modelagem a responsabilidade de lidar com o contexto.
Imagine um serviço, que dependa de um repositório, e esse repositório depende de uma instância de uma SQLConnection. Agora pense que você possa ter 2 instâncias desse serviço:
- A primeira, usa um repositório com uma conexão para o banco A.
- A segunda usa um repositório com uma conexão para o banco B.
O debate não gira em torno da necessidade ou não de termos 2 conexões, teríamos o mesmo fenômeno se precisássemos de 2 instâncias de:
- Serviço de envio de e-mail
- Serviço de notificação
- Serviço de pagamento
- Repositórios
- Conexões com serviços dos mais diversos
Em especial com conexões temos um desenho mais fácil, já que o grafo de dependências é extremamente curto, dependendo apenas da string de conexão. Entretanto, qualquer grafo de dependência mais complexo gera grande esforço configuração.
O resultado é que se faz necessário adicionar comportamentos de identificação e gestão de contexto, então se vamos reaproveitar componentes e libraries, precisamos criar novas classes que lidam com o contexto, ou somos obrigados a refatorar essas implementações para que elas lidem com o contexto.
Quanto maior o projeto, mais código evitável é escrito para contornar o que deveria consistir em puro e simples reaproveitamento.
Hoje a maioria de nós contorna esse problema fazendo contornos na modelagem, como se não existisse um problema.
Parâmetros que deveriam ser estado
Uma das principais manobras que fazemos na modelagem está na modelagem de serviços. Removemos todos os estados que não possam ser usados em 100% dos casos.
Há cenários em que um serviço precisa de uma configuração, e ao invés de manter tal configuração como um estado, podendo ter instâncias com configurações diferentes, injetadas em lugares diferentes, optamos por isolar o estado da implementação.
O problema dessa abordagem é que o consumidor da classe passa a precisar lidar com assuntos dos quais não deveria. O reflexo é o aumentando da complexidade, o maior esforço cognitivo de quem implementa um consumidor de tal tipo. Não é raro o consumidor precisar lidar com a obtenção de um desses dados a partir das configurações ou mesmo da injeção de dependência.
Abaixo temos um exemplo bem interessante.
// Injeção de dependência RedisConfiguration redisConfig = builder.Configuration.CreateAndConfigureWith<RedisConfiguration>(nameof(RedisConfiguration)); builder.Services.AddSingleton(sp => ConnectionMultiplexer.Connect(redisConfig.ConnectionString)); builder.Services.AddSingleton(sp => sp.GetRequiredService<ConnectionMultiplexer>().GetDatabase(redisConfig.Database)); builder.Services.AddSingleton<ICache, RedisAdapter>(); ... // Uso public class RedisAdapter: ICache { ... private readonly IDatabase database; ... public RedisAdapter(IDatabase database) { ... this.database = database; ... } public void Set<T>(string key, T value, TimeSpan? timeToLive = null) { ... } ... }
Esse é um exemplo real de um dos meus projetos recentes. Ele demonstra com clareza como transformei a informacão de TTL, representada pelo nome timeToLive, em parâmetro.
Nesse cenário, toda dependência que queira colocar alguma coisa no cache, deve saber exatamente qual o TTL a ser aplicado.
Isso quer dizer que ao invés do TTL ser uma configuração da instância RedisAdapter, todos que chamam o metodo RedisAdapter.Set precisam conhecer o TTL adequado, e talvez até obter de algum lugar.
Talvez você possa questionar se o TTL é uma informação pertinente ou não ao método ou à instância, não caia nessa armadilha. Não é essa a discussão. Podemos pensar em outros exemplos como um FileService que precisa do nome do Bucket (do Minio ou S3). Onde no contexto de um profile de usuário, usaria um bucket A, e no contexto global do tenant ou para toda a aplicação usaria um bucket B.
SPOILER
Mas calma, nem tudo é caos,
você vai ver que no final do post eu apresento
a solução da Microsoft no .NET 8.
Sim, após quase 10 anos de .NET Core, esse problema foi sanado.
Mas esse problema afeta quem? Em quais situações?
Uma dúvida que paira no ar é qual ou quais estereótipos são afetados? Seja um Serviços, Eventos, Repositórios, basicamente só entidades, objetos de valor, e transfer objects não são afetados.
Vemso consistentemente faces diferentes do mesmo problema, em diferentes cenários.
Services
Uma classe que represente um serviço de envio de e-mail, por exemplo, pode ser usado em qualquer lugar da aplicação.
Citamos bastante nos tópicos acima exemplos que afetam serviços.
Eventos
Da mesma forma que podemos usar a mesma estrutura de dados, para expressar diversos eventos. Pense em um evento baseado em Pedido como a classe abaixo:
namespace Purchase.Events { public class Purchase { public int Id { get; set; } public int CustomerId { get; set; } public DateTime Date { get; set; } public decimal Total { get; set; } public int StatusId { get; set; } pullic PurchaseItem[] PurchaseItems { get; set; } } public class PurchaseItem { public int Id { get; set; } public string Name { get; set; } public int Qtt { get; set; } public decimal Price { get; set; } } }
Em um cenário onde implementamos Event Notification Pattern @ Event Driven Architecture temos a oportunidade de reaproveitar essa classe dezenas de vezes.
Seja no evento de criação do pedido, alteração de status do pedido etc etc.
Essa mesma estrutura serve a dezenas de eventos totalmente diferentes.
PS: O questionamento sobre o evento ter dados completos do cliente, dados completos do endereço, outros dados do pedido etc não deve ser feito nesse post.
Projetos como MassTransit usam por default o tipo como identificador que determina não comente como e para onde publicar mensagens, bem como usa a mesma infraestrutura para identificar de onde consumir mensagens.
Claro que ainda é preciso levar em conta que é possível especificar detalhes da publicação, mas apenas por Tipo.
O mecanismo permite que se injete código no setup, para que, com base na mensagem se chegue à conclusão sobre qual o destino de uma mensagem. Dessa forma para conseguir promover o reaproveitamento deveríamos trazer uma informação adicional para a mensagem que permita promover a diferenciação.
Handlers
Quando olhamos para o MediatR, está claro que ele também é impactado por esse tipo de decisão. Não está claro se é reflexo da injeção de dependência ou não.
Quando pensamos em handlers, usando tipos como identificador, perdemos a capaciade de reaproveitar esse tipo. Só podemos fazê-lo com herança.
Abaixo temos um exemplo padrão com o tipo Ping representando um conjunto de request/response. O tipo (PING) é o identificador dessa operação, não podendo ser reaproveitada em outro lugar, nem por outra implementação de IRequestHandler<Ping,string>.
public class Ping : IRequest<string> { } public class PingHandler : IRequestHandler<Ping, string> { public Task<string> Handle(Ping request, CancellationToken cancellationToken) { return Task.FromResult("Pong"); } } ... var response = await mediator.Send(new Ping()); Debug.WriteLine(response); // "Pong"
A ausência de um identificador alternativo, produz a necessidade de novos tipos para novas operações, por mais que as estruturas de Entrada e Saída possam ser idênticas.
Pensemos em implementações mais realistas de Ping.
public class PingResult { public bool Success { get; set; } public string Message { get; set; } public TimeSpan Time { get; set; } } public interface IPingService { PingResult Ping(); }
public class LocalPingService: IPingService { ... public PingResult Ping() { ... } }
public class RemotePingService : IPingService { ... private Uri endpoint; ... public PingResult Ping() { ... } }
public class PingHandler : IRequestHandler<Ping2, string> { private IPingService service; public PingHandler(IPingService service) => this.service = service; public Task<string> Handle(Ping request, CancellationToken cancellationToken) { var result = service.Ping(); return Task.FromResult($"Success: {result.Success} | Time: {result.Time} | Message: {result.Message}"); } }
Nesse exemplo temos uma implementação de ping local e outra de ping remoto. Elas implementam a mesma interface IPingService.
A ideia seria desse exemplo seria usarmos contextualmente 2 instâncias de PingHandler. Assim poderíamos ter em partes diferentes da aplicação handlers diferentes, cada qual com uma versão diferente de IPingService injetada.
Note que as implementações de IPingService são 100% agnósticas e não dependendo do MediatR.
Hora de contornar o problema!
A primeira solução prátiva seria lidar com a especialização dos handlers. Note que agora saio de 1 Handler genérico para 2 implementações de Handlers e 2 implementações de Request/Response, como no exemplo abaixo.
public class Ping : IRequest<string> { } public class PingLocal : Ping { } public class PingRemote : Ping { } public class PingLocalHandler : IRequestHandler<PingLocal, string> { private IPingService service; public PingLocalHandler(IPingService service) => this.service = service; public Task<string> Handle(PingLocal request, CancellationToken cancellationToken) { var result = service.Ping(); return Task.FromResult($"Success: {result.Success} | Time: {result.Time} | Message: {result.Message}"); } } public class PingRemoteHandler : IRequestHandler<PingRemote, string> { private IPingService service; public PingRemoteHandler(IPingService service) => this.service = service; public Task<string> Handle(PingRemote request, CancellationToken cancellationToken) { var result = service.Ping(); return Task.FromResult($"Success: {result.Success} | Time: {result.Time} | Message: {result.Message}"); } }
Para alcançar as duas instâncias com MediatR, precisaríamos derivar o tipo Ping em novos com novos tipos, um tipo para sensibilizar a instância do handler que dialoga com LocalPingService e outro para a instância que dialoga com RemotePingService.
E claro, poderíamos evoluir para o uso de Generics para expressar o Tipo derivado de Ping, bem como no exemplo abaixo.
... services.AddSingleton<LocalPingService>(); services.AddSingleton<RemotePingService>(); services.AddSingleton< IRequestHandler<PingLocal, string>, GenericPingHandler <PingLocal, LocalPingService>>(); services.AddSingleton< IRequestHandler<PingRemote, string>, GenericPingHandler <PingRemote, RemotePingService>>(); ... ` public class GenericPingHandler<TPing, TService> : IRequestHandler<TPing, string> where TPing : Ping where TService : IPingService { private IPingService service; public GenericPingHandler(IServiceProvider serviceProvider) => service = serviceProvider.GetRequiredService<TService>(); public Task<string> Handle(TPing request, CancellationToken cancellationToken) { var result = service.Ping(); return Task.FromResult($"Success: {result.Success} | Time: {result.Time} | Message: {result.Message}"); } }
Mas tudo isso se traduz em complexidade!
O que era simples, 2 instâncias de um mesmo tipo, configuradas com dependências diferentes, confundem mecanismos dos mais variados. MediatR não é vilão, é apenas mais um dos afetados.
Há de se questionar se a questão no MediatR é em função da injeção de dependência do ASP.NET ou não. Para entender seria necessário aprofundar na história do projeto e rever decisões de sua história, é possível, que esse assunto já tenha sido discutido no passado.
Façam suas apostas.
Controllers e Actions
Em contraponto ao modelo de handler que opera exclusivamente com tipos, usando os tipos como identificadores, no ASP.NET temos um mecanismo bem consistente que passa longe desse problema.
As actions de nossas controllers não são identificadas apenas por seus tipos, muito pelo contrário. As rotas, e seus verbos diferenciam uma das outras, possuindo ainda mecanismos de validação contra colisão.
Já do ponto de vista de identificação de cada método, a própria linguagem garante que a assinatura de cada método contem, além de seus tipos, um nome.
Analogias que explicam o ponto
Tijolo em uma parede
Se pensarmos em uma classe como um tijolinho de parede. E vamos supor que queremos pintar um único tijolo de uma cor diferente. Referenciar esse tijolo específico em uma parede (uma instância da classe Tijolo), sem especificar sua posição, não é suficiente para sabermos qual das centenas de tijolos estamos querendo expressar que queremos pintar.
Rodas de um carro
Se pensarmos em rodas de um carro, algo com uma cardinalidade menor, também precisamos de uma referência para saber qual das 4 rodas estamos nos referindo.
Exceções
Existe uma exceção em um contexto específico quando lidamos com envelopes.
Entretanto, é inapropriado trazer envelopes para essa discussão, já que, em geral, eles são amplamente reaproveitáveis e não possuem variações em seus tipos.
Isso ocorre em virtude das propriedades serem usadas para a identificação do seu tratador, assim, temos a identidade definida por suas propriedades e abstraídas pela infraestrutura do ASP.NET.
Um request HTTP é um exemplo de um envelope amplamente usado.
- Headers
- Request URL
- Request Method
- Status Code
- Body
Qual o fenômeno observável que gera essa reflexão?
"Uncle Bob" é o apelido carinhoso de Robert C. Martin, uma figura influente no mundo do desenvolvimento de software. Ele é amplamente reconhecido por seu trabalho promovendo práticas de engenharia de software, como desenvolvimento ágil, programação extrema (XP), e especialmente por ser um dos autores do Manifesto Ágil. Além disso, Uncle Bob é conhecido por seus livros sobre princípios de design de software, padrões de código e metodologias de desenvolvimento, como "Clean Code" e "Clean Architecture", que se tornaram leituras essenciais para desenvolvedores de software que buscam aprimorar suas habilidades na escrita de código de alta qualidade e manutenível.
Certa vez ouvi Uncle Bob racionalizar que classes nada mais são que funções agrupadas que manipulam um determinado contexto limitado (pela classe em si).
Se olhamos para qualquer classe vamos ver 2 possíveis características:
- Comportamento
- Estado
Então quando uma classe tem outra como dependência, essa dependência é um de seus estados. Essa dependência pode ser um serviço, ou apenas uma estrutura de dados de configuração, como Options por exemplo.
Na hora de modelar uma classe, deveríamos ter muito cuidado para projetar toda a Entrada e Saída de nossos métodos. Assim precisamos decidir e deliberar sobre aquilo que é configuração da instância, e aquilo que é argumento dos métodos.
A negligência aqui custa caro para quem consume suas classes.
Vamos pensar no envio de email, por exemplo.
using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; using MimeKit.Text; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DemoMediatR; public class EmailService { private readonly EmailConfig config; public EmailService(EmailConfig config) { this.config = config; } public void Send(string to, string subject, string body) { MimeMessage email = BuildEmailMessage(to, subject, body); this.SendInternal(email); } private MimeMessage BuildEmailMessage(string to, string subject, string body) { var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse(this.config.From)); email.To.Add(MailboxAddress.Parse(to)); email.Subject = subject; email.Body = new TextPart(TextFormat.Html) { Text = body }; return email; } protected virtual string SendInternal(MimeMessage email) { string result = null; var smtp = new SmtpClient(); try { smtp.Connect(this.config.Server, this.config.Port, this.config.SecureOptions); if (!string.IsNullOrWhiteSpace(this.config.Username) && !string.IsNullOrWhiteSpace(this.config.Password)) { smtp.Authenticate(this.config.Username, this.config.Password); } result = smtp.Send(email); } finally { smtp.Disconnect(true); } return result; } } public class EmailConfig { public string Server { get; set; } // "smtp.gmail.com" public int Port { get; set; } // 587 public SecureSocketOptions SecureOptions { get; set; } // SecureSocketOptions.StartTls public string Username { get; set; } // "[Username]" public string Password { get; set; } // "[Password]" public string From { get; set; } // "[email protected]" }
Esse exemplo pode ter algum bug porque construí exclusivamente para esse post e nunca foi executado.
Fonte Original: https://medium.com/@patelrajni31/send-an-email-via-smtp-with-mailkit-using-net-6-8d6fc0dfb305
No exemplo acima, criado para esse post, existem algumas decisões importantes.
- Quem cria esse HTML?
- É da infraestrutura de envio de email (representado por EmailService) abstrair essa complexidade?
- Ou outra abstração é necessária para orquestrar a(s) consulta(as), a transformação dos dados, o parser etc. ?
- Configurações de SMTP pertencem à instância ou são argumentos que consumidores dessa classe precisam obter/produzir para encaminhar?
Imagine um cenário de e-commerce:
- Em um cadastro de usuário/autenticação EmailService deve ter configurações específicas de um perfil de SMTP server.
- Já em um contexto de recuperação de carrinho, outra estratégia.
- Ambas são diferentes de estratégias de envio de e-mail em passa, como em cenários de promoções e marketing.
Claro que podemos pensar em um desenho de microsserviços, onde cada um tem apenas a sua instância, mas pensando em um serviço global de notificações onde um único serviço aglutina diversas estratégias, temos sim essa demanda.
Mas e aí, qual é a solução?
Em Julho foi anunciada uma mudança na infraestrutura de injeção de dependências com a implementação do Keyed DI services.
Essa é uma das novidades do .NET 8. Então esse assunto é muito relevante do ponto de vista de modelagem, mais do que foi noticiado e do que têm sido abordado desde então.
Abaixo temos um exemplo da própria Microsoft.
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<BigCacheConsumer>(); builder.Services.AddSingleton<SmallCacheConsumer>(); builder.Services.AddKeyedSingleton<IMemoryCache, BigCache>("big"); builder.Services.AddKeyedSingleton<IMemoryCache, SmallCache>("small"); var app = builder.Build(); app.MapGet("/big", (BigCacheConsumer data) => data.GetData()); app.MapGet("/small", (SmallCacheConsumer data) => data.GetData()); app.Run(); class BigCacheConsumer([FromKeyedServices("big")] IMemoryCache cache) { public object? GetData() => cache.Get("data"); } class SmallCacheConsumer(IKeyedServiceProvider keyedServiceProvider) { public object? GetData() => keyedServiceProvider.GetRequiredKeyedService<IMemoryCache>("small"); }
Claro que ainda temos MediatR, Masstransit e diversas outras libraries utilizando tipos como únicos identificadores, toda vez que optarmos por usar um tipo como identificador, estaremos penalizando a capaciade de ter múltiplas instâncias daquele tipo com esforço zero.
São quase 10 anos aguardando uma feature, enfim chegou!
Eu gosto da solução de factory com options (IOptions), para mim o design fica mais claro.
Já usava o qualifiers no Java, e também Keyed services com outros containers de DI em .Net, mas isso acaba deixando as coisas meio obscuras e, a meu ver, mais dependente de um container de DI.
Esse é um tipo de recurso que eu já abandonei a um bom tempo.
Inevitavelmente deteriora a modelagem criando classes desnecessárias, acoplamento desnecessário.
O uso do Options tem suas vantagens, mas nunca vi usarem essas vantagens (reload), por incrível que pareça.
Se não há uso, dessas vantagens (nem a capacidade de ser reativo a elas), então também deteriora a modelagem.
Tudo que remove a característica pura da modelagem, deteriora a modelagem.
Hora é aceitável, hora não.
Mas aí, nesse caso, cada um tem seus critérios próprios.