Design: Obsessão por Tipos Primitivos

William Santos - Feb 10 '21 - - Dev Community

Olá!

Este é mais um post da seção Design e, desta vez, vamos falar sobre Obsessão por Tipos Primitivos. Este post pode ser considerado uma continuação do post anterior sobre Modelos Anêmicos e Modelos Ricos, uma vez que compartilham algumas características como veremos à frente.

O que é a Obessão por Tipos Primitivos

É um code smell muito comum que ocorre pelo uso de tipos primitivos oferecidos pela linguagem para representar elementos do domínio de forma inadequada. Vamos a um exemplo:

public class Customer
{
    ...
    public string Email { get; set; }
    public string Name { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Este é um código muito comum. Talvez você já tenha implementado algo do gênero (eu com certeza já!). Mas, qual o problema com este código?

Vejamos!

1) Atribuição indevida

Repare que tanto o Email quanto o Name são do mesmo tipo, string. Isso significa que nada nos impediria de cometer o seguinte erro:

var customer = new Customer
{
    Email = name,
    Name = email
};
Enter fullscreen mode Exit fullscreen mode

Repare que, neste momento, foi criada uma inconsistência em nosso modelo de domínio Customer. O nome e o e-mail estão invertidos e, a menos que haja algo como limitações de tamanho distintos para essas strings no banco de dados, seria bem difícil descobrir este erro sem que o cliente o informasse.

2) Validação dispersa

Como vimos no post anterior, um dos problemas de se usar modelos anêmicos é a terceiração da responsabilidade por sua validação. Ou seja, uma classe de serviço (ou até mesmo uma de validação) teria de ser usada para validar os campos desejados, criando algo semelhante ao seguinte:

public class CustomerValidator
{
    public (bool Success, string ErrorMessage) IsValid(string email, string name)
    {
        ...
        if(string.IsNullOrWhiteSpace(email))
            return (false, "A not empty e-mail address must be provided.");
        if(!email.Contains('@'))
            return (false, "A valid e-mail address must be provided.");
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

O problema com esta abordagem é que sempre que um e-mail precisar ser validado em outro modelo de domínio, ou mesmo para este em outra operação, a lógica de validação precisará ser repetida, reduzindo assim a mantenabilidade do código, tornando-o mais propenso a erros.

Como resolver?

A solução, por incrível que pareça, é muito simples. Assim como no caso de modelos anêmicos fazemos uma substituição por modelos ricos, substituiremos os tipos primitivos por tipos complexos, ou "objetos de valor" (Value Objects).

No caso dos tipos usados em Customer, teríamos o seguinte:

public class Email
{
    private string Value { get; init; }

    private Email(string address) => 
        Value = address;

    public static Result<Email> Create(string address)
    {
        if(string.IsNullOrWhiteSpace(address))
            return Result<Email>.Fail("A not empty e-mail address must be provided.");

         if(!address.Contains('@'))
            return Result<Email>.Fail("A valid e-mail address must be provided");

        return Result<Email>.Ok(new Email(address));
    }

    public static implicit operator string(Email email) =>
        email.Value;

    public override bool Equals(object obj)
    {
        Email email = obj as Email;

        if (ReferenceEquals(email, null))
            return false;

        return Value == email;
    }

    public override int GetHashCode() =>
        Value.GetHashCode();
}

...

public class Name
{
    private string Value { init; }

    private Name(string name) => 
        Value = name;

    public static Result<Email> Create(string name)
    {
        if(string.IsNullOrWhiteSpace(name))
            return Result<Name>.Fail("A not empty name must be provided.");

         if(name.Length > 50)
            return Result<Name>.Fail("The provided name is too long.");

         return Result<Name>.Ok(new Name(name));
    }

    public static implicit operator string(Name name) =>
        name.Value;

    public override bool Equals(object obj)
    {
        Name name = obj as Name;

        if (ReferenceEquals(name, null))
            return false;

        return Value == name;
    }

    public override int GetHashCode() =>
        Value.GetHashCode();
}
Enter fullscreen mode Exit fullscreen mode

E em nosso modelo Customer passaríamos a ter o seguinte:

public class Customer
{
    public Email Email { get; set; }
    public Name Name { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Repare que, caso utilizássemos o mesmo código que utilizamos na atribuição indevida, o próprio compilador nos avisaria do erro pois estaríamos tentando atribuir uma string a um Email.

Ao mesmo tempo, dispensaríamos o uso do validador CustomerValidator, pois cada tipo se encarrega de garantir sua consistência, e a criação de nosso modelo seria simplificada para a seguinte forma:

[HttpPost]
public IActionResult Create(CustomerCreationRequest request)
{
    var emailResult = Email.Create(request.Email);
    var nameResult = Name.Create(request.Name);

    if(!emailResult.Success)
        return BadRequest(emailResult.Error);

    if(!nameResult.Success)
        return BadRequest(nameResult.Error);

    var customer = new Customer
    {
        Email = emailResult.Value,
        Name = nameResult.Value
    };
    ...
}
Enter fullscreen mode Exit fullscreen mode

Repare que evitamos os dois problemas mencionados acima: tornou-se impossível atribuir um email a um nome e vice-versa. E, ao mesmo tempo, como cada tipo valida o dado de entrada em sua criação, a validação fica concentrada e não mais dispersa, de modo que passou a ser necessário apenas invocar o método de criação do tipo para que a validação seja realizada.

Sucesso!

Mas, o que é esse tipo Result?

Você deve ter reparado que foi empresado um tipo Result<T> para informar o retorno da criação de nossos tipos. Este é um tipo que será descrito por completo em um futuro artigo mas, grosso modo, temos neste tipo o seguinte: T é o tipo de retorno esperado. Portanto, a variável emailResult do código acima seria do tipo Result<Email>. Já nameResult seria do tipo Result<Name>. Há também uma flag indicando sucesso, a propriedade Success, e dois métodos Ok e Fail, onde Ok recebe o tipo T e indica sucesso, e Fail recebe uma mensagem de erro e não indica sucesso.

O código desta classe seria mais ou menos o seguinte:

public class Result<T>
{
    public bool Success { get; init; }
    public T Value { get; init; }
    public string Error { get; init; }

    public static Result<T> Ok(T value) =>
        new Result<T> { Success = true, Value = value };

    public static Result<T> Fail(string error) =>
        new Result<T> { Error = error };
}
Enter fullscreen mode Exit fullscreen mode

Repare que a razão para o uso desta classe é o mesmo do uso das classes Email e Name: ter uma representação adequada de um elemento de nosso domínio, evitando o uso de tipos diversos para o retorno de métodos, permitindo que todo o código se beneficie de sua implementação.

E por hoje é só!

Vimos, mais uma vez, as vantagens de se usar tipos complexos para representar nossos valores de domínio. Evitar o uso de tipos primitivos fora de suas propostas originais nos ajuda a ter um código mais coeso e menos propenso a erros.

Gostou? Me deixe saber pelos indicadores. Tem alguma dúvida? Deixe um comentário ou entre em contato pelas minhas redes sociais.

Muito obrigado, e até a próxima!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .