Entidades no estilo DDD com Entity Framework Core

Este artigo é sobre como aplicar os princípios DDD (Domain-Driven Design) às classes mapeadas pelo Entity Framework Core (EF Core) no banco de dados e por que isso pode ser útil.

TLDR


Há muitas vantagens na abordagem DDD, mas o principal é que o DDD transfere o código das operações de criação / modificação dentro da classe da entidade. Isso reduz significativamente as chances de um desenvolvedor entender / interpretar mal as regras para criar, inicializar e usar instâncias de classe.

  1. O livro de Eric Evans e seus discursos não têm muita informação sobre esse assunto:
  2. Forneça ao cliente um modelo simples para obter objetos persistentes (classes) e gerenciar seu ciclo de vida.
  3. Suas classes de entidade devem declarar explicitamente se podem ser alteradas, como e por quais regras.
  4. No DDD, existe o conceito de agregado. Agregado é uma árvore de entidades relacionadas. De acordo com as regras do DDD, o trabalho com agregados deve ser realizado através da “raiz de agregação” (a essência da raiz da árvore).

Eric menciona repositórios em seus discursos. Não recomendo implementar um repositório com o EF Core, porque o EF já implementa o repositório e os padrões de unidade de trabalho em si. Vou falar mais sobre isso em um artigo separado: " Vale a pena usar um repositório com o EF Core ?"

Entidades no estilo DDD


Começarei mostrando o código da entidade no estilo DDD e comparando-o com o modo como as entidades com o EF Core são geralmente criadas (nota do tradutor. O autor chama a palavra "geralmente" de um modelo anêmico.) Neste exemplo, utilizarei o banco de dados da Internet livraria (uma versão muito simplificada da Amazon. ”A estrutura do banco de dados é mostrada na imagem abaixo.

imagem

As quatro primeiras tabelas representam tudo sobre os livros: os próprios livros, seus autores, resenhas. As duas tabelas abaixo são usadas no código de lógica de negócios. Este tópico é descrito em detalhes em um artigo separado.
Todo o código deste artigo foi carregado no repositório GenericBizRunner no GitHub . Além do código da biblioteca GenericBizRunner, há outro exemplo de um aplicativo ASP.NET Core que usa o GenericBizRunner para trabalhar com a lógica de negócios. Mais sobre isso está escrito no artigo " Biblioteca para trabalhar com lógica de negócios e Entity Framework Core ".
E aqui está o código da entidade correspondente à estrutura do banco de dados.

public class Book { public const int PromotionalTextLength = 200; public int BookId { get; private set; } //… all other properties have a private set //These are the DDD aggregate propties: Reviews and AuthorLinks public IEnumerable<Review> Reviews => _reviews?.ToList(); public IEnumerable<BookAuthor> AuthorsLink => _authorsLink?.ToList(); //private, parameterless constructor used by EF Core private Book() { } //public constructor available to developer to create a new book public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { //code left out } //now the methods to update the book's properties public void UpdatePublishedOn(DateTime newDate)… public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText)… public void RemovePromotion()… //now the methods to update the book's aggregates public void AddReview(int numStars, string comment, string voterName, DbContext context)… public void RemoveReview(Review review)… } 

O que procurar:

  1. Linha 5: defina o acesso a todas as propriedades da entidade declaradas privadas. Isso significa que os dados podem ser modificados usando o construtor ou os métodos públicos descritos mais adiante neste artigo.
  2. As linhas 9 e 10. As coleções relacionadas (os mesmos agregados do DDD) fornecem acesso público a IEnumerable <T>, não ICollection <T>. Isso significa que você não pode adicionar ou remover itens da coleção diretamente. Você precisará usar métodos especializados da classe Book.
  3. Linha 13. O EF Core requer um construtor sem parâmetros, mas pode ter acesso privado. Isso significa que outro código de aplicativo não poderá ignorar a inicialização e criar instâncias de classes usando um construtor não paramétrico (comentário de um tradutor. A menos, é claro, que você crie entidades usando apenas reflexão)
  4. Linhas 16-20: A única maneira de criar uma instância da classe Book é usar o construtor público. Este construtor contém todas as informações necessárias para inicializar o objeto. Assim, é garantido que o objeto esteja em um estado válido.
  5. Linhas 23-25: Essas linhas contêm métodos para alterar o estado de um livro.
  6. Linhas 28 a 29: esses métodos permitem alterar entidades relacionadas (agregadas)

Os métodos nas linhas 23-39, continuarei chamando os "métodos que fornecem acesso". Esses métodos são a única maneira de alterar as propriedades e os relacionamentos dentro de uma entidade. A conclusão é que a classe Book está "fechada". Ele é criado por meio de um construtor especial e pode ser modificado apenas parcialmente por métodos especiais com nomes adequados. Essa abordagem cria um contraste nítido com a abordagem padrão para criar / modificar entidades no EF Core, na qual todas as entidades contêm um construtor padrão vazio e todas as propriedades são declaradas públicas. A próxima pergunta é: por que a primeira abordagem é melhor?

Comparação de criação de entidades


Vamos comparar o código para obter dados sobre vários livros do json e criar instâncias das classes Book com base.

a. Abordagem padrão


 var price = (decimal) (bookInfoJson.saleInfoListPriceAmount ?? DefaultBookPrice) var book = new Book { Title = bookInfoJson.title, Description = bookInfoJson.description, PublishedOn = DecodePubishDate(bookInfoJson.publishedDate), Publisher = bookInfoJson.publisher, OrgPrice = price, ActualPrice = price, ImageUrl = bookInfoJson.imageLinksThumbnail }; byte i = 0; book.AuthorsLink = new List<BookAuthor>(); foreach (var author in bookInfoJson.authors) { book.AuthorsLink.Add(new BookAuthor { Book = book, Author = authorDict[author], Order = i++ }); } 

b. Estilo DDD


 var authors = bookInfoJson.authors.Select(x => authorDict[x]).ToList(); var book = new Book(bookInfoJson.title, bookInfoJson.description, DecodePubishDate(bookInfoJson.publishedDate), bookInfoJson.publisher, ((decimal?)bookInfoJson.saleInfoListPriceAmount) ?? DefaultBookPrice, bookInfoJson.imageLinksThumbnail, authors); 

Código do construtor da classe Book

 public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { if (string.IsNullOrWhiteSpace(title)) throw new ArgumentNullException(nameof(title)); Title = title; Description = description; PublishedOn = publishedOn; Publisher = publisher; ActualPrice = price; OrgPrice = price; ImageUrl = imageUrl; _reviews = new HashSet<Review>(); if (authors == null || !authors.Any()) throw new ArgumentException( "You must have at least one Author for a book", nameof(authors)); byte order = 0; _authorsLink = new HashSet<BookAuthor>( authors.Select(a => new BookAuthor(this, a, order++))); } 

O que procurar:

  1. Linhas 1-2: o construtor obriga a passar todos os dados necessários para a inicialização adequada.
  2. Linhas 5, 6 e 17-9: O código contém várias verificações de regras de negócios. Nesse caso específico, uma violação das regras é considerada um erro no código; portanto, em caso de violação, uma exceção será lançada. Se o usuário pudesse corrigir esses erros, talvez eu usasse uma fábrica estática que retorne Status <T> (tradutor de comentários. Usaria Option <T> ou Result <T> como um nome mais comum). Status é um tipo que retorna uma lista de erros.
  3. Linhas 21-23: A ligação BookAuthor é criada no construtor. O construtor BookAuthor pode ser declarado com o nível de acesso interno. Dessa forma, podemos impedir a criação de relacionamentos fora do DAL.

Como você deve ter notado, a quantidade de código para criar uma entidade é aproximadamente a mesma nos dois casos. Então, por que o estilo DDD é melhor? O estilo DDD é melhor nisso:

  1. Controla o acesso. Mudança acidental de propriedade é excluída. Qualquer alteração ocorre através do construtor ou método público com o nome correspondente. Obviamente o que está acontecendo.
  2. Corresponde a DRY (não se repita). Pode ser necessário criar instâncias do livro em vários lugares. O código de atribuição está no construtor e você não precisa repeti-lo em vários locais.
  3. Oculta complexidade. A classe Book possui duas propriedades: ActualPrice e OrgPrice. Esses dois valores devem ser iguais ao criar um novo livro. Em uma abordagem padrão, todo desenvolvedor deve estar ciente disso. Na abordagem DDD, é suficiente para o desenvolvedor da classe Book saber sobre isso. O restante aprenderá sobre essa regra porque está explicitamente escrita no construtor.
  4. Oculta a criação agregada. Em uma abordagem padrão, o desenvolvedor deve criar manualmente uma instância do BookAuthor. No estilo DDD, essa complexidade é encapsulada para o código de chamada.
  5. Permite que as propriedades tenham acesso de gravação privado
  6. Um dos motivos para usar o DDD é bloquear a entidade, ou seja, Não permita alterar diretamente as propriedades. Vamos comparar a operação de alteração com e sem DDD.

Comparação de alterações de propriedade


Uma das principais vantagens das entidades no estilo DDD, Eric Evans chama o seguinte: "Eles comunicam decisões de design sobre acesso a objetos".
Nota tradutor. A frase original é difícil de traduzir para o russo. Nesse caso, decisões de design são decisões tomadas sobre como o software deve funcionar. Isso significa que as decisões foram discutidas e confirmadas. O código com construtores que inicializam corretamente entidades e métodos com nomes corretos que refletem o significado das operações informa explicitamente ao desenvolvedor que as atribuições de determinados valores foram feitas com intenção, e não por engano, e não são um capricho de outro desenvolvedor ou detalhes de implementação.
Eu entendo esta frase da seguinte maneira.

  1. Torne óbvio como modificar dados dentro de uma entidade e quais dados devem ser alterados juntos.
  2. Torne óbvio quando você não deve modificar determinados dados na entidade.
Vamos comparar as duas abordagens. O primeiro exemplo é simples e o segundo é mais complicado.

1. Alteração da data de publicação


Suponha que queremos primeiro trabalhar com um rascunho de um livro e só depois publicá-lo. No momento da redação do rascunho, é definida uma data estimada de publicação, que provavelmente será alterada durante o processo de edição. Para armazenar a data de publicação, usaremos a propriedade PublishedOn.

a. Entidade com propriedades públicas


 var book = context.Find<Book>(dto.BookId); book.PublishedOn = dto.PublishedOn; context.SaveChanges(); 

b. Entidade de estilo DDD


No estilo DDD, o configurador da propriedade é declarado privado, portanto, usaremos um método de acesso especializado.

 var book = context.Find<Book>(dto.BookId); book.UpdatePublishedOn( dto.PublishedOn); context.SaveChanges(); 

Esses dois casos são quase os mesmos. A versão DDD é ainda mais longa. Mas ainda há uma diferença. No estilo DDD, você tem certeza de que a data de publicação pode ser alterada porque existe um método com um nome óbvio. Você também sabe que não pode alterar o publicador porque a propriedade Publisher não possui um método apropriado para alterar. Esta informação será útil para qualquer programador que trabalhe com uma aula de livro.

2. Gerenciar o desconto para o livro


Outro requisito é que devemos ser capazes de gerenciar descontos. O desconto consiste em um novo preço e um comentário, por exemplo, "50% antes do final desta semana!"

A implementação desta regra é simples, mas não muito óbvia.

  1. A propriedade OrgPrice é o preço sem desconto.
  2. Preço atual - O preço atual pelo qual o livro está sendo vendido. Se o desconto for válido, o preço atual será diferente do OrgPrice pelo tamanho do desconto. Caso contrário, o valor das propriedades será igual.
  3. A propriedade PromotionText deve conter o texto do desconto se o desconto for aplicado ou nulo se o desconto não for aplicado no momento.

As regras são bastante óbvias para a pessoa que as implementou. No entanto, para outro desenvolvedor, digamos, desenvolvendo uma interface do usuário para adicionar um desconto. Adicionar os métodos AddPromotion e RemovePromotion à classe de entidade oculta os detalhes da implementação. Agora outro desenvolvedor tem métodos públicos com os nomes correspondentes. A semântica do uso de métodos é óbvia.

Dê uma olhada na implementação dos métodos AddPromotion e RemovePromotion.

 public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText) { var status = new GenericErrorHandler(); if (string.IsNullOrWhiteSpace(promotionalText)) { status.AddError( "You must provide some text to go with the promotion.", nameof(PromotionalText)); return status; } ActualPrice = newPrice; PromotionalText = promotionalText; return status; } 

O que procurar:

  1. Linhas 4-10: é necessário adicionar um comentário de texto promocional. O método verifica se o texto não está vazio. Porque O usuário pode corrigir esse erro.O método retorna uma lista de erros para correção.
  2. Linhas 12, 13: o método define os valores das propriedades de acordo com a implementação que o desenvolvedor escolheu. O usuário do método AddPromotion não precisa conhecê-los. Para adicionar um desconto, basta escrever:

 var book = context.Find<Book>(dto.BookId); var status = book.AddPromotion(newPrice, promotionText); if (!status.HasErrors) context.SaveChanges(); return status; 

O método RemovePromotion é muito mais simples: não envolve tratamento de erros. Portanto, o valor de retorno é nulo.

 public void RemovePromotion() { ActualPrice = OrgPrice; PromotionalText = null; } 

Esses dois exemplos são muito diferentes um do outro. No primeiro exemplo, alterar a propriedade PublishOn é tão simples que a implementação padrão é boa. No segundo exemplo, os detalhes da implementação não são óbvios para alguém que não trabalhou com a classe Book. No segundo caso, o estilo DDD com métodos de acesso especializados oculta os detalhes da implementação e facilita a vida de outros desenvolvedores. Além disso, no segundo exemplo, o código contém lógica de negócios. Embora a quantidade de lógica seja pequena, podemos armazená-la diretamente nos métodos de acesso e retornar uma lista de erros se o método não for usado corretamente.

3. Trabalhar com o agregado - coleção de propriedades


O DDD se oferece para trabalhar com a unidade somente através da raiz. No nosso caso, a propriedade Reviews cria problemas. Mesmo se o setter for declarado privado, o desenvolvedor ainda poderá adicionar ou remover objetos usando os métodos add e remove, ou até mesmo chamar o método clear para limpar a coleção inteira. Aqui, o novo recurso EF Core, nos campos de apoio, nos ajudará.

O campo de suporte permite que o desenvolvedor encapsule a coleção real e forneça acesso público ao link da interface IEnumerable <T>. A interface IEnumerable <T> não fornece métodos de adição, remoção ou limpeza. No código abaixo, há um exemplo do uso de campos de apoio.

 public class Book { private HashSet<Review> _reviews; public IEnumerable<Review> Reviews => _reviews?.ToList(); //… rest of code not shown } 

Para que isso funcione, é necessário informar ao EF Core que, ao ler no banco de dados, você precisa gravar em um campo privado, não em uma propriedade pública. O código de configuração é mostrado abaixo.

 protected override void OnModelCreating (ModelBuilder modelBuilder) { modelBuilder.Entity<Book>() .FindNavigation(nameof(Book.Reviews)) .SetPropertyAccessMode(PropertyAccessMode.Field); //… other non-review configurations left out } 

Para trabalhar com resenhas, adicionei dois métodos: AddReview e RemoveReview à classe de livros. O método AddReview é mais interessante. Aqui está o código dele:

 public void AddReview(int numStars, string comment, string voterName, DbContext context = null) { if (_reviews != null) { _reviews.Add(new Review(numStars, comment, voterName)); } else if (context == null) { throw new ArgumentNullException(nameof(context), "You must provide a context if the Reviews collection isn't valid."); } else if (context.Entry(this).IsKeySet) { context.Add(new Review(numStars, comment, voterName, BookId)); } else { throw new InvalidOperationException("Could not add a new review."); } } 

O que procurar:

  1. Linhas 4-7: intencionalmente não inicializo o campo _reviews em um construtor privado sem parâmetros que o EF Core usa ao carregar entidades do banco de dados. Isso permite que meu código determine se a coleção foi carregada usando o método .Include (p => p.Reviews). No construtor público, eu inicializo o campo, para que o NRE não aconteça ao trabalhar com a entidade criada.
  2. Linhas 8-12: Se a coleção Reviews não foi carregada, o código deve usar DbContext para inicializar.
  3. Linhas 13-16: Se o livro foi criado com sucesso e contém um ID, utilizo outra técnica para adicionar uma revisão: simplesmente instalo a chave estrangeira em uma instância da classe Review e a escrevo no banco de dados. Isso é descrito em mais detalhes na seção 3.4.5 do meu livro.
  4. Linha 19: Se estamos aqui, há algum tipo de problema com a lógica do código. Então, eu lancei uma exceção.

Eu projetei todos os meus métodos de acesso para casos invertidos em que apenas a entidade raiz é carregada. Como atualizar a unidade fica a critério dos métodos. Pode ser necessário carregar entidades adicionais.

Conclusão


Para criar entidades no estilo DDD com o EF Core, você deve seguir as seguintes regras:

  1. Crie construtores públicos para criar instâncias de classe inicializadas corretamente. Se ocorrerem erros durante o processo de criação que o usuário possa corrigir, crie o objeto não usando o construtor público, mas usando o método de fábrica que retorna Status <T>, em que T é o tipo de entidade que está sendo criada
  2. Todas as propriedades são configuradoras de propriedades. I.e. todas as propriedades são somente leitura fora da classe.
  3. Para propriedades de navegação de coleção, declare campos de apoio e tipo de propriedade pública declare IEnumerable <T>. Isso impedirá que outros desenvolvedores alterem coleções incontrolavelmente.
  4. Em vez de setters públicos, crie métodos públicos para todas as operações de alteração de objeto permitidas. Esses métodos devem retornar nulos se a operação não puder falhar com um erro que o usuário possa corrigir ou Status <T> se puder.
  5. O escopo do passivo da entidade é importante. Eu acho que é melhor limitar as entidades a mudar a própria classe e outras classes dentro do agregado, mas não fora. As regras de validação devem se limitar à verificação da correção da criação e alteração do estado das entidades. I.e. Não verifico regras comerciais, como saldos de ações. Existe um código de lógica de negócios especial para isso.
  6. Os métodos de alteração de estado devem assumir que apenas a raiz de agregação é carregada. Se um método precisar carregar outros dados, ele deverá cuidar dele por conta própria.
  7. Os métodos de alteração de estado devem assumir que apenas a raiz de agregação é carregada. Se um método precisar carregar outros dados, ele deverá cuidar dele por conta própria. Essa abordagem simplifica o uso de entidades por outros desenvolvedores.

Prós e contras das entidades DDD ao trabalhar com o EF Core


Gosto da abordagem crítica de qualquer padrão ou arquitetura. Aqui está o que penso sobre o uso de entidades DDD.

Prós


  1. Usar métodos especializados para mudar de estado é uma abordagem mais limpa. Essa é definitivamente uma boa solução, simplesmente porque os métodos nomeados adequadamente revelam intenções de código muito melhores e tornam óbvio o que pode e não pode ser alterado. Além disso, os métodos podem retornar uma lista de erros se o usuário puder corrigi-los.
  2. Alterar agregados apenas através da raiz também funciona bem
  3. Detalhes do relacionamento um para muitos entre as classes Livro e Revisão agora estão ocultos para o usuário. Encapsulamento é um princípio básico de POO.
  4. O uso de construtores especializados permite garantir que as entidades sejam criadas e garantidas para serem inicializadas corretamente.
  5. Mover o código de inicialização para o construtor reduz significativamente a probabilidade de o desenvolvedor não interpretar corretamente como a classe deve ser inicializada.

Contras


  1. Minha abordagem contém dependências na implementação do EF Core.
  2. Algumas pessoas até chamam isso de antipadrão. O problema é que agora as entidades do modelo de assunto dependem do código de acesso ao banco de dados. Em termos de DDD, isso é ruim. Percebi que, se não tivesse feito isso, teria que confiar no chamador para saber o que deveria ser carregado. Essa abordagem quebra o princípio da separação de preocupações.
  3. O DDD força você a escrever mais código.

Realmente vale a pena em casos simples, como atualizar a data de publicação de um livro?
Como você pode ver, eu gosto da abordagem DDD. No entanto, demorei um pouco para estruturá-lo corretamente, mas no momento a abordagem já foi resolvida e estou aplicando nos projetos em que estou trabalhando. Já consegui experimentar esse estilo em pequenos projetos e estou satisfeito, mas todos os prós e contras ainda não foram descobertos quando o uso em grandes projetos.

Minha decisão de permitir o uso de código específico da EFCore nos argumentos dos métodos de entidade do modelo de entidade não foi simples. Tentei evitar isso, mas no final cheguei à conclusão de que o código de chamada tinha que carregar muitas propriedades de navegação. E se isso não for feito, a alteração simplesmente não será aplicada sem erros (especialmente em um relacionamento individual). Isso não era aceitável para mim, então permiti o uso do EF Core dentro de alguns métodos (mas não nos construtores).

Outro lado ruim é que o DDD obriga a escrever significativamente mais código para operações CRUD. Ainda não tenho certeza se vou continuar comendo um cacto e escrever métodos separados para todas as propriedades ou, em alguns casos, vale a pena se afastar de um puritanismo tão radical. Eu sei que há apenas uma carruagem e um caminhão pequeno de CRUD chato, que é mais fácil escrever diretamente. Somente o trabalho em projetos reais mostrará o que é melhor.

Outros aspectos do DDD não abordados neste artigo


O artigo acabou sendo muito longo, então vou terminar aqui. Mas isso significa que ainda há muito material não divulgado. Eu já escrevi sobre algo, sobre algo que escreverei em um futuro próximo. Aqui está o que sobrou:

  1. Lógica de negócios e DDD. Uso vários conceitos de DDD no código de lógica de negócios há vários anos e, usando os novos recursos do EF Core, espero poder transferir parte da lógica para o código de entidade. Leia o artigo “Novamente sobre a arquitetura da camada lógica de negócios com o Entity Framework (Core e v6)”
  2. DDD e o padrão do repositório. Eric Evans recomenda usar um repositório para abstrair o acesso a dados. , «» EF Core – . Porque - .
  3. DBContext' / (bounded contexts). DbContext'. , BookContext Book OrderContext, . , « » , . , .

Todo o código deste artigo está disponível no repositório GenericBizRunner no GitHub . Este repositório contém um exemplo de aplicativo ASP.NET Core com métodos de acesso especializados para modificar a classe Book. Você pode clonar o repositório e executar o aplicativo localmente. Ele usa o Sqlite na memória como um banco de dados, portanto deve ser executado em qualquer infraestrutura.

Feliz desenvolvimento!

Source: https://habr.com/ru/post/pt432410/


All Articles