Em Habré e não apenas uma quantidade decente de artigos foi escrita sobre o Domain Driven Design - tanto em geral sobre arquitetura, como com exemplos em .Net. Mas, ao mesmo tempo, uma parte tão importante dessa arquitetura como o Value Objects é frequentemente mal mencionada.
Neste artigo, tentarei descobrir as nuances da implementação de Value Objects no .Net Core usando o Entity Framework Core.
Sob um gato, há muito código.
Pouco de teoria
O núcleo da arquitetura do Design Orientado a
Domínio é o
Domínio - a área de assunto na qual o software que está sendo desenvolvido é aplicado. Aqui está toda a lógica comercial do aplicativo, que geralmente interage com vários dados. Os dados podem ser de dois tipos:
- Objeto de entidade
- Objeto de valor (doravante - VO)
Objeto de entidade define uma entidade na lógica de negócios e sempre possui um identificador pelo qual a entidade pode ser encontrada ou comparada com outra entidade. Se duas entidades tiverem um identificador idêntico, essa é a mesma entidade. Quase sempre mude.
O objeto de valor é um tipo imutável, cujo valor é definido durante a criação e não muda ao longo da vida útil do objeto. Não possui um identificador. Se dois VOs são estruturalmente idênticos, eles são equivalentes.
A entidade pode conter outra entidade e VO. VOs podem incluir outros VOs, mas não a Entidade.
Assim, a lógica do domínio deve funcionar exclusivamente com Entidade e VO - isso garante sua consistência. Tipos de dados básicos, como string, int etc. frequentemente eles não podem atuar como um VO, porque podem simplesmente violar o estado do domínio - o que é quase um desastre na estrutura do DDD.
Um exemplo Nos vários manuais, a classe Person, que ficou doente de todo mundo, é frequentemente mostrada assim:
public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
Simples e claro - identificador, nome e idade, onde você pode cometer um erro?
Mas pode haver vários erros aqui - por exemplo, do ponto de vista da lógica de negócios, um nome é obrigatório, não pode ter tamanho zero ou mais de 100 caracteres e não deve conter caracteres especiais, pontuação, etc. E a idade não pode ser inferior a 10 ou mais de 120 anos.
Do ponto de vista da linguagem de programação, 5 é um número inteiro completamente normal, semelhante a uma string vazia. Mas o domínio já está em um estado incorreto.
Vamos seguir praticando
Neste ponto, sabemos que o VO deve ser imutável e conter um valor válido para a lógica de negócios.
A imunidade é obtida ao inicializar a propriedade somente leitura ao criar o objeto.
A validação do valor ocorre no construtor (cláusula Guard). É desejável disponibilizar publicamente a verificação - para que outras camadas possam validar os dados recebidos do cliente (o mesmo navegador).
Vamos criar um VO para Nome e Idade. Além disso, complicamos um pouco a tarefa - adicione um PersonalName combinando FirstName e LastName e aplique-o a Person.
Nome public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } }
Nome pessoal public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } }
Idade public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } }
E finalmente Pessoa:
public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } }
Portanto, não podemos criar Pessoa sem um nome completo ou idade. Além disso, não podemos criar um nome "errado" ou uma idade "errada". Um bom programador certamente verificará os dados recebidos no controlador usando os métodos Name.IsValid ("John") e Age.IsValid (35) e, no caso de dados incorretos, informará o cliente sobre isso.
Se fizermos uma regra em todos os lugares do modelo para usar apenas Entidade e VO, protegeremos a nós mesmos de um grande número de erros - dados incorretos simplesmente não entrarão no modelo.
Persistência
Agora precisamos salvar nossos dados no data warehouse e obtê-los mediante solicitação. Usaremos o Entity Framework Core como ORM e o data warehouse é o MS SQL Server.
O DDD define claramente: Persistência é uma subespécie da camada de infraestrutura porque oculta uma implementação específica de acesso a dados.
O domínio não precisa saber nada sobre persistência, isso determina apenas as interfaces dos repositórios.
E Persistence contém implementações específicas, configurações de mapeamento, além de um objeto UnitOfWork.
Há duas opiniões sobre se vale a pena criar repositórios e Unidade de Trabalho.
Por um lado - não, não é necessário, porque no Entity Framework Core tudo isso já está implementado. Se tivermos uma arquitetura multinível no formato DAL -> Business Logic -> Presentation, que é baseada no armazenamento de dados, por que não usar diretamente os recursos do EF Core.
Mas o domínio no DDD não depende do armazenamento de dados e do ORM usado - essas são todas as sutilezas da implementação que estão encapsuladas no Persistence e não interessam a ninguém. Se fornecermos o DbContext para outras camadas, divulgamos imediatamente os detalhes da implementação, vinculamos firmemente ao ORM selecionado e obtemos o DAL - como base de toda a lógica de negócios, mas não deve ser. Grosso modo, o domínio não deve notar uma alteração no ORM e até mesmo a perda de persistência como uma camada.
Portanto, a interface do repositório de Pessoas, no domínio:
public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
e sua implementação em Persistência:
public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } }
Parece nada complicado, mas há um problema. Pronto para uso O Entity Framework Core funciona apenas com tipos básicos (string, int, DateTime etc.) e não sabe nada sobre PersonalName e Age. Vamos ensinar o EF Core a entender nossos objetos de valor.
Configuração
A API Fluent é mais adequada para configurar a Entidade no DDD. Os atributos não são adequados, pois o domínio não precisa saber nada sobre as nuances do mapeamento.
Crie uma classe no Persistence com a configuração básica PersonConfiguration:
internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } }
e conecte-o ao DbContext:
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
Mapeamento
A seção para a qual este material foi escrito.
No momento, existem duas maneiras mais ou menos convenientes de mapear classes não padrão para os tipos base - Conversões de Valor e Tipos de Propriedade.
Conversões de valor
Esse recurso apareceu no Entity Framework Core 2.1 e permite determinar a conversão entre os dois tipos de dados.
Vamos escrever o conversor para Age (nesta seção, todo o código está em PersonConfiguration):
var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired();
Sintaxe simples e concisa, mas não sem falhas:
- Não foi possível converter nulo;
- Não é possível converter uma propriedade em várias colunas em uma tabela e vice-versa;
- O EF Core não pode converter uma expressão LINQ com essa propriedade em uma consulta SQL.
Vou me debruçar sobre o último ponto em mais detalhes. Adicione um método ao repositório que retorne uma lista de Pessoa acima de uma determinada idade:
public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); }
Há uma condição para a idade, mas o EF Core não poderá convertê-lo em uma consulta SQL e, atingindo Where (), carregará toda a tabela na memória do aplicativo e, somente então, usando o LINQ, atenderá à condição p.Age.Value> age.Value .
Em geral, Conversões de Valor é uma opção de mapeamento simples e rápida, mas é preciso lembrar sobre esse recurso do EF Core; caso contrário, em algum momento, ao consultar tabelas grandes, a memória pode acabar.
Tipos próprios
Os Tipos proprietários apareceram no Entity Framework Core 2.0 e substituíram os Tipos complexos do Entity Framework regular.
Vamos fazer a idade como tipo de propriedade:
builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); });
Nada mal. E os tipos proprietários não têm algumas das desvantagens das conversões de valor, como os pontos 2 e 3.
2.
É possível converter uma propriedade em várias colunas na tabela e vice-versa
O que você precisa para o PersonalName, embora a sintaxe já esteja um pouco sobrecarregada:
builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); });
3. O EF Core
pode converter uma expressão LINQ com essa propriedade em uma consulta SQL.
Adicione a classificação por Sobrenome e Nome ao carregar a lista:
public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
Essa expressão será convertida corretamente em uma consulta SQL e a classificação será realizada no lado do servidor SQL, e não no aplicativo.
Claro, também existem desvantagens.
- Problemas com nulo não desapareceram;
- Os campos Tipos proprietários não podem ser somente leitura e devem ter um setter protegido ou privado.
- Tipos proprietários são implementados como Entidade regular, o que significa:
- Eles têm um identificador (como uma propriedade de sombra, ou seja, não aparece na classe de domínio);
- O EF Core rastreia todas as alterações nos Tipos de propriedade, exatamente da mesma forma que na Entidade comum.
Por um lado, isso não é exatamente o que objetos de valor deveriam ser. Eles não devem ter nenhum identificador. Os VOs não devem ser rastreados para alterações - porque são inicialmente imutáveis, as propriedades da Entidade pai devem ser rastreadas, mas não as propriedades do VO.
Por outro lado, esses são detalhes de implementação que podem ser omitidos, mas, novamente, não esqueça. O rastreamento de alterações afeta o desempenho. Se isso não for perceptível em amostras de uma única entidade (por exemplo, por ID) ou em pequenas listas, com uma seleção de grandes listas de entidades "pesadas" (muitas propriedades de VO), a redução do desempenho será muito perceptível precisamente por causa do rastreamento.
Apresentação
Nós descobrimos como implementar objetos de valor em um domínio e repositório. É hora de usar tudo. Vamos criar duas páginas simples - com a lista Pessoa e o formulário para adicionar Pessoa.
O código do controlador sem os métodos de ação tem a seguinte aparência:
public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; }
Adicione Ação para obter a lista de Pessoas:
[HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
Ver @model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table>
Nada complicado - carregamos a lista, criamos um objeto de transferência de dados (PersonModel) para cada
Pessoa e enviado para a Visualização correspondente.
Muito mais interessante é a adição de Person:
[HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); }
Ver @model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} }
Há uma validação obrigatória dos dados recebidos:
if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
Se isso não for feito, ao criar um VO com um valor incorreto, uma ArgumentException será lançada (lembre-se da Cláusula Guard nos construtores de VO). Com a verificação, é muito mais fácil enviar uma mensagem ao usuário de que um dos valores está incorreto.
Aqui você precisa fazer uma pequena digressão - no Asp Net Core, há uma maneira regular de validação de dados - usando atributos. Mas no DDD, esse método de validação não está correto por vários motivos:
- Os recursos de atributo podem não ser suficientes para a lógica de validação;
- Qualquer lógica comercial, incluindo regras para validar parâmetros, é configurada exclusivamente pelo domínio. Ele tem um monopólio sobre isso e todas as outras camadas devem considerar isso. Os atributos podem ser usados, mas você não deve confiar neles. Se o atributo ignorar dados incorretos, obteremos novamente uma exceção ao criar um VO.
Voltar para AddPerson (). Após a validação dos dados, PersonalName, Age e Person são criados. Em seguida, adicione o objeto ao repositório e salve as alterações (Confirmar). É muito importante que o Commit não seja chamado no repositório do EfPersons. A tarefa do repositório é executar alguma ação com os dados, não mais. A confirmação é feita apenas de fora, quando exatamente - o programador decide. Caso contrário, uma situação é possível quando ocorre um erro no meio de uma certa iteração comercial - alguns dos dados são salvos e outros não. Recebemos o domínio no estado "quebrado". Se a confirmação for concluída no final, se o erro ocorrer, a transação simplesmente reverterá.
Conclusão
Dei exemplos da implementação de objetos de valor em geral e as nuances do mapeamento no Entity Framework Core. Espero que o material seja útil para entender como aplicar os elementos do Design Orientado a Domínios na prática.
Código-fonte do projeto Pessoas completasDemo -
GitHubO material não divulga o problema de interagir com objetos de valor opcionais (que podem ser nulos) - se PersonalName ou Age não forem propriedades necessárias de Person. Eu queria descrever isso neste artigo, mas ele já saiu um pouco sobrecarregado. Se houver interesse nessa questão - escreva nos comentários, a continuação será.
Para os fãs de "belas arquiteturas" em geral e do Domain Driven Design em particular, recomendo o recurso
Enterprise Craftsmanship .
Existem muitos artigos úteis sobre a construção correta de arquitetura e exemplos de implementação no .Net. Algumas idéias foram emprestadas, implementadas com sucesso em projetos de “combate” e parcialmente refletidas neste artigo.
A documentação oficial para
Tipos de propriedade e
Conversões de valor também
foi usada.