Olá!
Este é um post da série Design, onde pretendo explorar aspectos de design de software que entendo relevantes, e que podem determinar o rumo de suas aplicações. E, neste artigo, falaremos sobre um aspecto já bastante discutido e ainda polêmico: modelos anêmicos.
O que são Modelos Anêmicos
De forma sucinta, modelos anêmicos são uma forma de modelagem de domínio que consiste na separação entre classes que apresentam apenas estado ou apenas comportamentos. Pensando em C#, teríamos uma classe que contém apenas propriedades, os famosos getters e setters, e uma segunda classe, geralmente chamada de "serviço", que atuaria sobre a primeira.
Aqui temos um exemplo:
public class Student
{
public Guid Id { get; set; }
public string Name { get; set; }
public byte Age { get; set; }
public byte Grade { get; set; }
}
Aqui temos um exemplo de uma classe que representa um elemento de um domínio de uma escola, um estudante, com suas propriedades -- que determinarão seu estado.
Imagine que precisamos alterar uma característica deste usuário como, por exemplo, promovê-lo à próxima série. Neste modelo teríamos algo como o código abaixo:
public class StudentService
{
private readonly IStudentRepository _repository;
...
public void Approve(Guid id, byte grade)
{
var student = _repository.GetById(id);
student.Grade++;
student.Age++;
_repository.Update(student);
}
public void Reprove(Guid id)
{
var student = _repository.GetById(id);
student.Age++;
_repository.Update(student);
}
...
}
Perceba que aqui temos duas classes para fazer uma operação simples, com uma classe de "serviço" que recebe um estudante, altera seu estado, e o persiste na base de dados.
Familiar. Não?
Agora, imagine que precisamos cadastrar um novo aluno. Teríamos o seguinte:
public class StudentService
{
private readonly IStudentRepository _repository;
public void Create(Student student)
{
if (string.IsNullOrWhiteSpace(student.Name))
throw new ArgumentException("Invalid name. A student name must be provided.", nameof(student.Name));
if (student.Name.Length > 100)
throw new ArgumentException("Invalid name. A valid name should be at maximum 100 characters long.", nameof(student.Name));
if (student.Age < 6)
throw new ArgumentException("Invalid age. The minimum age to a student is 6 years old.", nameof(student.Age));
if (student.Grade < 1)
throw new ArgumentException("Invalid grade. A student must at least at the first grade.", nameof(student.Grade));
if (student.Grade > 9)
throw new ArgumentException("Invalid grade. A last grade possible for a student is the ninth.", nameof(student.Grade));
student.Id = Guid.NewGuid();
_repository.Create(student);
}
...
public void Approve(Guid id, byte grade)
...
public void Reprove(Guid id)
...
}
Repare que, neste trecho, o serviço recebe uma instância de Student, valida suas propriedades, e a persiste na base de dados.
E, para concluirmos nosso exemplo, imagine que o nome de aluno precise ser alterado por alguma razão. Teríamos o seguinte:
public class StudentService
{
private readonly IStudentRepository _repository;
public void Create(Student student)
...
public void UpdateName(Guid id, string name)
{
var student = _repository.GetById(id);
if(string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Invalid name. A student name must be provided.", nameof(name));
student.Name = name;
_repository.Update(student);
}
...
public void Approve(Guid id, byte grade)
...
public void Reprove(byte id)
...
}
Ou seja, a cada alteração de estado que precisamos induzir em Student, precisamos introduzir um novo método na classe de serviço responsável por operar sobre ela, de modo que, no final das contas, a classe de serviço acaba representando melhor o que é um estudante que a própria classe Student. Ou seja, todo o conhecimento sobre quais operações são possíveis sobre um estudante está fora de sua representação, que é uma mera estrutura de dados.
A despeito da consideração acima, este é costuma ser considerado um bom código. Mas ele oculta um problema que pode render sérios bugs à sua aplicação: ele não é capaz de garantir, em primeiro lugar, consistência.
Isso porque como todas as propriedades de Student são publicamente acessíveis e modificáveis, qualquer classe que tenha acesso a uma instância de Student pode agir livremente sobre ela, alterando seu estado e, eventualmente, corrompendo-o.
Repare que também temos um problema de duplicação de lógica de negócio, uma vez que tanto na criação do estudante, quanto na atualização de seu nome, precisamos verificar se o mesmo atende à regra de não ser vazio e ter até 100 caracteres. Lembrando que estamos atuando em uma implmentação simples. Imagine essa duplicação ocorrendo com modelos maiores e mais pontos de acesso e modificação!
Nota: Repare que, de propósito, a regra que valida o comprimento do nome foi suprimida no método UpdateName acima. Este é um exemplo de como a duplicação de lógica é potencialmente danosa: em sua ocorrência é possível que partes importantes sejam ignoradas, levando nosso modelo a um estado inconsistente e a outros bugs -- neste caso, havendo uma limitação de 100 caracteres na base de dados, uma exceção será lançada.
Uma outra desvantagem deste modelo é a tendência ao acoplamento e/ou vazamento de domínio. Ou seja, os dados do seu modelo de domínio tendem a ser expostos as is ao mundo exterior ou, caso precisem ter algum atributo ocultado, terão a ocultação implementada diretamente sobre ele em vez de na camada de aplicação. Veja o exemplo abaixo:
public class Student
{
[JsonIgnore]
public Guid Id { get; set; }
public string Name { get; set; }
public byte Age { get; set; }
public byte Grade { get; set; }
}
A simples necessidade de se ocultar o Id do estudante, faz com que seu modelo de domínio, que não deveria ter conhecimento sobre como será exposto pela aplicação -- neste caso, via Json -- acaba sendo contaminado por um detalhe do que deveria estar restrito à camada de aplicação do seu sistema.
Ao mesmo tempo, como o modelo domínio é usado como contrato em sua Web API, caso alguma propriedade de Student precise mudar, haverá quebra do contrato.
Imagine que surge um requisito que peça que o nome do estudante seja dividido entre nome e sobrenome, pois em sistemas internos o sobrenome será utilizado antes do nome (ex. Pereira, Alberto), mas sua apresentação na Web API que atende ao app da escola deva ser preservada.
O código deveria passar a ser o seguinte:
public class Student
{
[JsonIgnore]
public Guid Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
public byte Age { get; set; }
public byte Grade { get; set; }
}
Neste caso, em nossa API hipotética, quebraríamos nosso contrato com o app, que passaria a exibir apenas o primeiro nome do aluno no lugar do nome completo, já que o atributo Name teve seu significado alterado.
Isso significa que modelos anêmicos são ruins?
A resposta é: não necessariamente. Modelos anêmicos podem ser úteis em sistemas que:
- Tem um ciclo de desenvolvimento curto. Ou seja, serão criados para uma finalidade específica e não deverão evoluir.
- Tem um ciclo de vida curto. Ou seja, não serão mantidos por muito tempo, sendo logo descartados.
- Destinam-se apenas a CRUD (Create, Read, Update, Delete).
Nestes cenários, onde pede-se um desenvolvimento mais simples e rápido, modelos anêmicos podem ser uma solução, dado que sua complexidade inicial é mais baixa que a de sua contraparte, modelos de domínio ricos, como demonstra o gráfico abaixo:
Repare que neste gráfico temos duas informações importantes:
- Modelos anêmicos oferecem uma complexidade inicial reduzida, mas que tende a explodir à medida em que o sistema evolui. Ou seja, em sistemas que tendem a crescer, ou viver por muito tempo, a dificuldade para introduzir funcionalidades cresce em função do tempo.
- A alternativa a um modelo anêmico é inicialmente mais complexa, mas sua complexidade cresce a uma taxa muito reduzida no tempo, tornando o desenvolvimento de novas features menos complexo.
Importante! modelos anêmicos induzem a um estilo de programação procedural, onde serviços se comportam como módulos de funções, e as classes de modelo como estruturas de dados. Ora, se operamos em um paradigma orientado a objetos (como com C#, a linguagem sobre a qual falamos aqui), qual o sentido em utilizar outro paradigma para desenvolver nossas aplicações?
Dadas todas as questões demonstradas até aqui sobre modelos anêmicos, qual alternativa temos para contorná-las?
Utilizando Modelos Ricos
Modelos ricos nada mais são que modelos de domínio que seguem um princípio da Programação Orientada a Objetos, o que define classes como modelos de objetos que possuem estado (campos e propriedades) e comportamento (métodos e eventos). Ou seja, ao utilizar modelos ricos, estamos, por definição, respeitando e aplicando o paradigma da linguagem (em nosso caso, C#).
Além disso, utilizando este tipo de modelo, conseguimos resolver os problemas que encontramos na abordagem com modelo anêmicos, ainda que ao custo de uma complexiade maior.
Vejamos em mais detalhes abaixo:
public class Student
{
public Guid Id { get; private set; }
public StudentName Name { get; private set; }
public Age Age { get; private set; }
public Grade Grade { get; private set; }
private Student() {}
public static Student Create(StudentName name, Age age, Grade grade)
{
return new Student
{
Id = Guid.NewGuid(),
Name = name,
Age = age,
Grade = grade
};
}
public void UpdateName(StudentName name) =>
Name = name;
public void Approve()
{
Grade++;
Age++
}
public void Reprove() =>
Age++;
}
Aqui temos diferenças importantes em relação ao modelo anêmico apresentado anteriormente. A primeira, e mais importante é que, a partir de agora, temos toda a lógica relativa às operações sobre um estudante na própria classe Student, e não mais em StudentService. Esta diferença representa uma vantagem, uma vez que as operações e os dados estão concentrados em um único ponto do sistema, e não mais distribuída em duas ou mais classes, o que torna a lógica mais facilmente identificável.
Outra diferença perceptível é que tornamos privado o construtor de nosso modelo, criando um único ponto possível de instanciação a ele: o método estático Create, que recebe os tipos relativos à propriedades de Student. Ao mesmo tempo, tornamos as propriedades do modelo read only, removendo os setters. Essa combinação entre um único ponto de instanciação, e propriedades livres de setters nos proporciona o meio a partir do qual garantiremos a consistência de nosso modelo: encapsulamento.
Ou seja, todos os elementos necessários para garantir que nosso modelo não seja induzido a um estado indesejado está no próprio modelo Student, tornando-o inviolável a partir de outras classes que tenham acesso ao modelo. Qualquer mudança de estado induzida a Student será feita por meio de métodos públicos determinados por ela, sendo apenas sua a responsabilidade por garantir sua consistência.
Por fim, é possível perceber que introduzimos tipos novos em nossso modelo de estudantes, e a intenção com estes tipos, neste exemplo, é garantir a não duplicação de lógica de negócio. Vejamos um exemplo, a classe StudentName.
public struct StudentName
{
public string Name { get; private get; }
public string Surname { get; private get; }
public static StudentName Create (string name, string surname)
{
if(string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(surname))
throw new ArgumentException("Invalid name. A student name and surname must be provided.");
if(name.Length + surname.Length > 100)
throw new ArgumentException("Invalid name. A valid name should be at maximum 100 characters long.");
return new StudentName
{
Name = name,
Surname = surname
};
}
public static implicit operator string(StudentName studentName) =>
$"{studentName.Name} {studentName.Surname}";
}
Repare que, por conta do emprego desta classe, tanto na criação de Student, quanto em seu método UpdateName, apenas uma instância de StudentName é atribuída, sem que a validação do valor de nome e sobrenome seja repetida, pois StudentName já garante sua consistência em sua criação, evitando assim o risco de haver duplicação, neste caso, dois comportamentos distintos sobre um mesmo dado em diferentes pontos do código.
Nota: os demais tipos constam do repositório do Github para este artigo, cujo link se encontra ao final do texto.
Mas... E os contratos?
Algo que você pode ter notado é que ainda que tenhamos garantido a consistência de nosso modelo, ainda não resolvemos o problema do vazamento de domínio e acoplamento com a a camada de aplicação. Ou seja, ainda que tenhamos alterado nosso modelo, o contrato de nossa Web API ainda está acoplado a ele.
Vamos resolver este problema com os tipos abaixo:
public record CreateStudentRequest(string Name, byte Age, byte Grade);
public record UpdateStudentNameRequest(string Name, string Surname);
public record StudentModel
{
public string Name { get; private set; }
public byte Age { get; private set; }
public byte Grade { get; private set; }
public static explicit operator StudentModel(Student student) =>
new StudentModel
{
Name = student.Name,
Age = student.Age,
Grade = student.Grade
};
}
Pronto! A partir de agora temos um tipo que representa a solicitação de criação de um estudante, outro que representa a solicitação de alteração de seu nome, e um último que representa o atual estado do estudante que será ofertado via Web API, gerando total separação entre o modelo de domínio e nossa camada de aplicação, onde esta conhece nosso modelo Student, mas o inverso não acontece. Temos, portanto, um modelo de domínio desacoplado da camada de aplicação.
Conclusão
Como pudemos ver, o emprego de modelos anêmicos pode ser muito útil em sistemas simples e com ciclo de vida curto, ao mesmo tempo em que é potencialmente danoso para sistemas complexos. Muitos bugs podem ser gerados neste modelo pois diversos pontos do sistema podem ter acesso aos nossos modelos de domínio e induzí-los a um estado inconsistente. Ao mesmo tempo, a duplicação de lógica, o acoplamento do modelo de domínio à camada de aplicação, e seu consequente vazamento, trazem um prejuízo potencial à mantenabilidade do sistema. É preciso ter muito cuidado com essas questões ao decidir qual modelo de domínio usar.
Algo muito importante, entretanto, é notar que houve substancial aumento da complexidade inicial de nosso código com um modelo rico, pois tivemos de implementar mais tipos com a finalidade de promover a consistência e a coesão de nosso modelo. Esta complexidade é a mesma demonstrada no gráfico que vimos anteriormente, e é exatamente por isso que é recomendável adotá-lo em sistemas complexos, pois os benefícios trazidos por este modelo justificam seu custo!
Nota: Se você está familiarizado com DDD, deve ter reparado que StudentName, Age e Grade, se comportam como Value Objects, enquanto Student como uma Entity.
Não foi nossa intenção cobrir DDD neste artigo, mas pretendemos fazê-lo em algum momento no futuro para ilustrar melhor estes e outros patterns do DDD.
Gostou? Me deixe saber pelos indicadores. Dúvidas? Me deixe saber pelos comentários.
Como de costume, segue o repositório do Github com os códigos demonstrados neste artigo. Uma observação: ao contrário de artigos anteriores, este código não é totalmente funcional. Mas você pode completar a implementação e testar à vontade! :)
Até a próxima!