Valorizando seus Dados: Desmistificando Objetos de Valor na persistência (EF Core 7) - Part: 3
Boa noite pessoal!
Recentemente, meu amigo Mario e eu começamos a estudar arquitetura em .NET Core com o objetivo de aprimorar nossas habilidades profissionais. Como resultado de nossos estudos, decidimos desenvolver uma aplicação para aplicar a literatura que estudamos e promover discussões e troca de ideias com a comunidade.
Assim, surgiu um projeto que tem como objetivo solucionar os problemas do dia a dia de um restaurante que oferece serviços de delivery. Identificamos questões como cadastro de produtos, gerenciamento de loja, catálogo, pedidos e pagamentos, e gostaríamos de desenvolver uma solução para esses desafios.
Dessa forma, o projeto nasceu, que será futuramente acompanhado de uma aplicação frontend. Nosso objetivo é aplicar princípios como SOLID, clean code, clean architecture, design patterns e notação Big O para construir uma solução robusta, eficiente e douradura.
Nesta minissérie que estou criando sobre Objetos de Valor, apresentei algumas dificuldades decorrentes da obsessão por tipos primitivos que podem surgir em grandes aplicações ou em projetos com muitas modificações de negócio e código. Como solução, introduzi o conceito de objeto de valor, cujo propósito é melhorar e mitigar algumas dessas dificuldades, resultando em uma melhoria na arquitetura do sistema.
No entanto, é importante mencionar que a utilização de objetos de valor pode trazer outras dificuldades no desenvolvimento de software. Por exemplo, o processo de transformação de propriedades JSON para objetos mais complexos e vice-versa (serialização e desserialização) adiciona uma complexidade extra para a aplicação ou para os consumidores da API. Essa complexidade adicional pode colocar em cheque as principais vantagens dos objetos de valor, gerando um certo desconforto ou descontentamento nos desenvolvedores que trabalharão com o código e, consequentemente, desmotivando sua implementação.
No último artigo, compartilhei uma solução para evitar expor a complexidade dos objetos de valor na API. Aplicando uma técnica que automatiza o processo de conversão a partir de tipos primitivos para objetos de valor, tornamos toda a complexidade transparente para os consumidores da API e os desenvolvedores que utilizam as classes de valor.
Dando continuidade a esse tema, podemos aprimorar ainda mais a implementação de uma aplicação que implementa objetos de valor. Além da serialização dos endpoints da aplicação, também é importante considerar a experiência na camada de persistência do sistema.
Sem mais delongas, para ilustrar o desenvolvimento da aplicação, apresento o mesmo código em C# que representa uma Pessoa com um campo Email.
Observe que a representação do Email utiliza uma readonly struct. É importante destacar que a struct Email é apenas uma "classe" wrapper que armazena a informação de um email. Sua implementação contém algumas funções de validação, entre outras, que auxiliam em seu objetivo principal. Seu propósito é armazenar apenas uma informação, no caso, uma string válida de um email.
Agora que temos representação de uma pessoa utilizando c#, proponho abordarmos como representar os valores de uma Pessoa em um banco de dados SQL. Na indústria de desenvolvimento, é comum o uso de ORM (Object Relational Mapper). Portanto, utilizarei um ORM para obter os benefícios desse emprego e criar uma aplicação mais realista. Entre as opções de frameworks de ORM para .NET, optei por usar o Entity Framework Core (EF Core), pois ele é amplamente utilizado no desenvolvimento e possui uma extensa documentação. Além disso, o EF Core oferece uma classe de conversão para tipos personalizados.
Após implementar as classes e configurar minha aplicação com o EF Core, me deparei com um erro ao executar a primeira migration. O ORM não está conseguindo mapear os tipos primitivos?
The property Pessoa.Email' could not be mapped because it is of type 'Email', which is not a supported primitive type or a valid entity type. Either explicitly map this property, or ignore it using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.
Infelizmente, atualmente o EF Core não oferece suporte nativo ao mapeamento de readonly structs e structs como tipos de entidade. É importante estar ciente dessa limitação ao utilizar esses tipos em conjunto com o EF Core.
Mas afinal, por que utilizar Struct para tipos de valor?
Respondendo à perguntar, structs são estruturas de dados semelhantes às classes, mas são tipos de valor e não requerem alocação no heap. Enquanto uma variável de classe contém uma referência aos dados (objeto), uma variável de struct contém diretamente os dados. Elas são úteis para representar pequenas estruturas de dados com semântica de valor, como números complexos, pontos em um sistema de coordenadas ou pares chave-valor em um dicionário.
Existem algumas vantagens em utilizar structs, os objetos criados com base em structs são mais leves em termos de memória e processamento, pois não envolvem alocação dinâmica da memória na heap e não exigem referências. Isso pode ser especialmente útil em cenários em que se precisa de muitas instâncias de um tipo, sem comentar da não necessidade do gerbage colector para a liberação de memória.
Além disso, como tipos de valor, as structs são copiadas por valor quando atribuídas a outras variáveis ou passadas como parâmetros em métodos. Isso significa que a alteração de uma instância de struct não afeta diretamente outras cópias da mesma struct. Isso pode ser útil para garantir imutabilidade ou evitar efeitos colaterais indesejados.
No entanto, é importante notar que o uso de structs deve ser considerado com cuidado. Em geral, é recomendado utilizá-las para representar tipos de valor pequenos e imutáveis. Caso contrário, se uma struct for grande ou mutável, pode ser mais eficiente usar uma classe em seu lugar. Além disso, as structs têm algumas limitações, como não suportar herança e não permitir a declaração de um construtor padrão sem parâmetros.
Bom, as structs possuem uma vantagem notável para a representação de um email. E como aproveitar dos beneficiosos delas, sendo que o Ef gera erro de mapeamento? Como podemos persistir objetos de valor nessas circunstâncias?
Diante dessa pergunta, gostaria de apresentar duas alternativas possíveis para persistir objetos de valor em um banco de dados relacional, com o objetivo de realizar o mapeamento de forma adequada e transparente aos desenvolvedores que irão utilizar esses tipos de valor.
A primeira alternativa é realizar o mapeamento de uma forma que o EF Core possa interpretar a classe wrapper apropriadamente. Nesse caso, podemos transformar a struct em uma classe, e como consequência, o EF Core conseguirá interpretar a classe Email conforme desejamos. Abaixo, segue um exemplo de código:
Porém, ao declararmos as classes dessa forma, o Entity Framework acaba interpretando a classe "Email" como requerente de um campo chamado "Id" para a chave primária. Isso ocorre devido à suposição de que será criada uma tabela chamada "Email" com um relacionamento de um-para-muitos (1:N) em relação à classe "Pessoa". No entanto, essa pode não ser a intenção desejada ao utilizar o EF para o mapeamento de objetos de valor.
Para evitar qualquer ambiguidade e tornar as relações mais explícitas, podemos afirmar que a classe "Pessoa" possui um campo referente ao "Email". Essa abordagem mais cuidadosa e descritiva ajuda a evitar mal-entendidos no mapeamento e garante uma melhor interpretação das relações pelo EF. A implementação seria a seguinte:
Podemos visualizar a seguir o código da migration gerado pelo EF. O EF mapeou corretamente uma coluna de email na tabela Pessoa, conforme desejado, evitando o mapeamento indesejado da tabela de emails. Para complementar, podemos adicionar uma instrução adicional no método OnModelCreating para renomear a coluna Email_Value usando .Property("Email").
Além da possibilidade de transformar a representação do email para uma classe em vez de utilizar uma struct, também temos a opção de adicionar um conversor ao EF. Dessa forma, podemos manter a representação do email como uma struct readonly, aproveitando suas vantagens.
Assim como no artigo anterior sobre objetos de valor em APIs, onde foi adicionada uma classe de conversão para o objeto de valor, permitindo a conversão entre JSON e objeto, o EF também fornece essa funcionalidade por meio da especialização da classe ValueConverter. A implementação pode ser feita similar ao exemplo abaixo, lembrando que a implementação será simplificada para fins didáticos. É importante notar que a implementação da conversão ocorre no construtor base da classe EmailConverter, em que o primeiro parâmetro representa a conversão para SQL e o segundo parâmetro representa a conversão para objeto.
Essa implementação permite ao EF realizar a conversão entre a struct Email e sua representação em SQL. Dessa forma, mantemos a representação do email como uma struct readonly, aproveitando seus benefícios, enquanto o EF pode realizar a persistência corretamente.
Após criar o conversor para emails, é necessário registrá-lo no framework. Para fazer isso, basta adicionar o EmailConverter ao método ConfigureConventions do DbContext, como exemplificado no código abaixo. Dessa forma, estamos informando ao EF que qualquer campo que utilize o tipo Email terá automaticamente o conversor predefinido aplicado. Em outras palavras, acabamos de criar uma extensão do EF Core.
Essa adição ao método ConfigureConventions permite que o EF saiba como lidar com o tipo Email e aplique automaticamente o conversor apropriado durante a persistência e recuperação dos dados.
Para finalizar, ao gerar a migração, podemos observar as seguintes especificações na migration do EF. Com o uso do conversor e o registro adequado, o mapeamento fica completamente transparente para a camada de persistência do ORM.
E como fica a utilização na prática?
Ao mapearmos da forma mais simples sem a adição de converters extra, temos uma pequena desvantagem. Ao realizar buscar pelo campo, necessitamos explicitar a propriedade Value, elucidado no exemplo a seguir.
Recomendados pelo LinkedIn
E logo, o framework gera o seguinte SQL para a consulta sem nenhuma dificuldade.
Agora, exemplificando a utilização com o converter, para executar a mesma consulta filtrando email, basta criar a query da mesma forma como se estivesse buscando por um campo string, deixando a expressão um pouco mais simples. Um exemplo a seguir.
Segue o sql gerado pelo EF core, podemos notar que toda complexidade do objeto de valor estão transparentes na camada de persistência como já citado anteriormente no tópico da migration.
E afinal, diante de todas as vantagens e desvantagens, será que realmente vale a pena aplicarmos objetos de valor na camada de persistência? Será que o custo computacional é considerável? E qual das três estratégias de representação de informações possui um melhor desempenho para a aplicação?
Para responder a essas perguntas, realizei dois benchmarks: um para a inserção de uma série de registros e outro para avaliar o carregamento de uma série de registros do banco de dados.
Durante o benchmark de inserção, foram testadas as três estratégias de representação de email: utilizando struct (InsertPersonValueObject_Struct), utilizando uma classe (InsertPersonValueObject_Class) e a representação por string (InsertPersonValueObject_String). Foram inseridos diversos registros no banco de dados. A ferramenta utilizada para realizar as medições da execução foi o BenchmarkDotNet.
Os resultados das inserções podem ser visualizados na imagem abaixo.
Nos testes realizados, foi observado que o tempo de execução da representação por string é 23,18% maior em relação à representação por struct. Além disso, a representação por string também apresentou um custo de alocação de memória cerca de 2% maior. No entanto, é importante ressaltar que, devido à margem de erro da ferramenta BenchmarkDotNet, as diferenças entre essas duas estratégias podem ser consideradas tecnicamente empatadas.
Já em comparação com a representação por classe, os resultados mostraram uma diferença significativa. A representação por classe apresentou um tempo de execução 159,61% maior em relação à representação por struct. Além disso, o coletor de lixo (garbage collector) coletou mais do dobro de objetos e a alocação de memória também foi praticamente o dobro.
Os resultados podem ser visualizados no relatório, e para facilitar a interpretação dos dados, foi elaborada uma tabela comparativa apresentando as porcentagens:
Legenda das colunas:
Os resultados obtidos demonstram que a representação por struct possui uma leve vantagem em relação à representação por string, porém, essa diferença pode ser compensada pela margem de erro do benchmark. No entanto, é notável um ganho significativo ao utilizar a representação por struct em comparação com a representação por classe. Os resultados foram surpreendentes.
No último teste de benchmark, foram avaliadas as três estratégias de representação de dados durante as operações de carregamento: utilizando struct (SelectPersonValueObject_Struct), utilizando uma classe (SelectPersonValueObject_Class) e a representação por string (SelectPersonValueObject_String). Diversos registros foram carregados do banco de dados e a ferramenta BenchmarkDotNet foi novamente utilizada para realizar as medições.
Os resultados das operações de consulta podem ser visualizados na imagem abaixo. Conforme demonstrado na imagem, ambas as estratégias possuem custos de execução praticamente iguais, considerando a margem de erro. Isso significa que, para realizar consultas utilizando o EF Core, ambas as formas de representação têm a mesma performance. Ao contrário das operações de inserção, onde as estratégias utilizando struct e string mostraram praticamente o dobro de eficiência em relação à utilização de objetos.
Esses resultados mostram que, em termos de desempenho durante as operações de consulta, as estratégias de representação por struct, classe e string são praticamente equivalentes. Portanto, a escolha entre essas estratégias pode ser baseada em outros critérios, como clareza de código, facilidade de manutenção e alinhamento com a arquitetura do sistema.
Concluindo este artigo, onde foi abordado a criação de um protótipo de uma aplicação em .Net core, e evitamos o emprego da obsessão de tipos primitivos para representar valores na camada de aplicação. Esta obsessão foi mitigada implementando objetos de valores, e, vazando sua implementação na camada de persistência.
Para os objetos de valor, discutimos a importância de utilizá-los da melhorar forma possível a fim de melhorar a arquitetura do sistema, mitigar dificuldades decorrentes a implementação por tipos primitivos ou complexos. E assim, evitar exposição da complexidade aos desenvolvedores e aos consumidores da aplicação, como citado no artigo anterior. A solução aplicada neste artigo, consiste no uso de classes wrappers para a representação de objetos de valor, elucidada com o exemplo do Email. E em seguida, abordados meios de se persistir as informações de email no banco de dados.
Ao mapeamento de objetos de valor no banco de dados, utilizamos o ORM Entity Framework Core (EF Core), exploramos duas alternativas: A primeira, transformar a representação do objeto de valor em uma classe para permitir o mapeamento correto, devido a limitações de mapeamento do framework as structs. E em seguida, adicionando um conversor personalizado ao EF Core, que permite manter a representação do objeto de valor como uma struct readonly. Para discutir a viabilidade da utilização de objetos de valor na camada de persistência, realizamos benchmarks para comparar o desempenho das diferentes estratégias de representação, tanto quanto a utilização de struct, classe e string. Sendo a string utilizada como referência para a obsessão por tipos primitivos.
Os testes consistiram em operações de inserção e consulta. Os resultados mostraram que nas operações de inserção, a representação por meio da struct apresentou melhor desempenho em termos de tempo de execução e consumo de memória em comparação com a representação por classe, enquanto a representação por string, possuiu um desempenho similar a struct, ou seja, sem perdas de performasse devido à complexidade adicional.
No entanto, durante as operações de consulta, as três estratégias de representação mostraram desempenho equivalente, indicando que a escolha entre elas pode ser baseada em outros critérios, como clareza de código e alinhamento com a arquitetura do sistema.
Também pode se notar que o EF Core possui uma boa estratégia para aplicar conversores personalizados sem comprometer a aplicação nas operações testadas.
Em suma, a aplicação de objetos de valor na camada de persistência pode não trazer benefícios significativos em termos de desempenho, mas certamente trara ganhos de expressividade e arquitetura no sistema. Saliento, que a escolha da estratégia de representação deve considerar as características específicas do projeto e as necessidades do negócio. Com a aplicação correta das técnicas apresentadas, é possível desenvolver soluções eficientes e duradouras para os desafios do dia a dia. Também é importante salientar, este artigo não tem por objetivo incentivar a aplicação de objetos de valor, mas sim, apresentar suas vantagens e suas adversidades. Pois, esta decisão cabe a escolha do engenheiro responsável diante a realidade e as necessidades do projeto.
Referência do projeto citado no artigo.
Demais artigos da serie
Referências
Senior Specialist Back-end Engineer || Engenheiro de Software Sênior || . NET Developer
1 aestava hj msm fazendo esse trabalho de mapear uma struct usando o Entity. Mas agora que li seu artigo, (excelente, por sinal) ate desisti. Muito, muito trabalho para pouco resultado. Se minha aplicação fosse 70% struct ate valeria o esforço..kkk Vou seguindo na boa e velha classe enquanto o Entity nao atualizar e passar a mapear struct automaticamente como faz com as classes.. Parabens pelo artigo..ja virei seguidor.. Abco
Desenvolvedor Full Stack | Java | Spring | React
1 aShow de bola! Parabéns!