Corrigi hoje o problema que encontrei ontem quando fiz a validação com model binder (se você não leu, leia até o final para entender o problema). A correção levou menos de vinte minutos e estou bastante satisfeito. Aproveitei e fiz alguns testes unitários também.

O problema:

Toda minha lógica de validação estava no model binder, e por isso meu modelo de domínio (composto de uma única classe: e-mail) estava anêmico, ou seja, sem comportamento, era um mero agregador de dados.

A solução:

Movi toda a lógica para a entidade EmailMessage, e criei uma coleção de erros que o binder utiliza para setar seus erros.

A classe de mensagem ficou assim:

    [ModelBinder(typeof(EmailMessageBinder))]
    public class EmailMessage
    {                
        public string FromEmailAddress { get; set; }
        public string Message { get; set; }
        public string To { get; set; }
        public string FromName { get; set; }

        private string _subject;
        public string Subject
        { 
            get { return _subject; }
            set  
            {
                if (string.IsNullOrEmpty(value))
                    _subject = value;
                else
                    _subject = "[GIGGIO.NET] " + value; 
            }
        }

        public ICollection<KeyValuePair<string, string>> ValidationErrors()
        {
            var errors = new List<KeyValuePair<string, string>>();
            errors.AddValidation(() => this.FromEmailAddress, 
                (object p) => !string.IsNullOrEmpty((string)p), "Campo obrigatório não preenchido.");
            errors.AddRequiredStringValidation(() => this.Message, "Campo obrigatório não preenchido.");
            errors.AddRequiredStringValidation(() => this.FromName, "Campo obrigatório não preenchido.");
            errors.AddRequiredStringValidation(() => this.Subject, "Campo obrigatório não preenchido.");
            errors.AddRequiredStringValidation(() => this.To, "Campo obrigatório não preenchido.");
            errors.AddValidation(() => this.FromEmailAddress,
                delegate(object p)
                {
                    if (p == null) return false;
                    var regexEmail = @"umaregexgigante";
                    return Regex.Match((string)p, regexEmail).Success;
                }
                , "Endereço de origem inválido.");

            return errors;
        }
    }

Notem que a coleção retornada pelo método ValidationErrors não é um Dictionary<string, string>, porque dicionários não permitem chaves duplicadas, e eu precisava deste suporte. Utilizei então uma coleção de pares de chaves e valores. Funcionou bem.

Notem ainda que toda a validação é feita a partir de lambdas e métodos anônimos. Elas utilizam um método de extensão. Os métodos trabalham sobre minha coleção de chaves e valores, e são estes:

    internal static class ValidationExtensions
    {
        public static void AddValidation(this ICollection<KeyValuePair<string, string>> errors,
                                         Expression<Func<object>> getValue,
                                         Func<object, bool> validate,
                                         string errorMessage)
        {
            var value = getValue.Compile()();
            if (!validate(value))
            {
                var memberName = ((System.Linq.Expressions.MemberExpression)getValue.Body).Member.Name;
                errors.Add(new KeyValuePair<string, string>(memberName, errorMessage));
            }
        }

        public static void AddRequiredStringValidation(this ICollection<KeyValuePair<string, string>> errors,
                                         Expression<Func<object>> getValue,
                                         string errorMessage)
        {
            AddValidation(errors,
                getValue,
                (object p) => !string.IsNullOrEmpty((string)p),
                errorMessage);            
        }

    }

No primeiro há uma validação genérica. Eu usei ela na primeira validação (linha 26 da EmailMessage), só para exemplificar, e na última (linha 32 da EmailMessage). Eu passo aos métodos a propriedade a validar (como uma expression, passada sempre por uma lambda), a função de validação (um delegate criado a partir de uma lambda ou método anônimo), e a mensagem de erro. Aliás, eu adoro Lambdas, são muito úteis. Esta, no entanto, foi a primeira vez que usei Expressions em um caso real, onde usei para extrair o nome da propriedade (linha 11 das extensões) e para obter o valor da propriedade através da compilação da Expression em um delegate (linha 10). Prático, não?

Na segunda é uma validação de strings obrigatórias, ou seja, vou sempre verificar se a string é inexistente ou vazia. Ela utiliza a função genérica para fazer seu trabalho, passando para ela a lambda de verificação da string. Por causa disso ela não precisa receber a função de validação, já que sempre faço verificação via IsNullOrEmpty.

Validação simples e reutilizável. Excelente.

Observação que nada tem a ver com o assunto: Infelizmente não há ainda contravariância, então fui obrigado a usar uma Expression<Func<object>>, em vez de usar uma Expression<Func<string>>. Se recebesse uma expressão de string não conseguiria repassá-la ao método AddValidation, porque ele entenderia que são tipos diferentes, e não compilaria (venha logo C# 4…).

No Binder mudou tudo. Toda a responsabilidade de validação foi removida. Ele simplesmente olha para a coleção de erros e acrescenta os erros à sua coleção de model errors (veja linha 17):

    public class EmailMessageBinder : DefaultModelBinder
    {
        protected override bool OnModelUpdating(ControllerContext controllerContext, 
                                                ModelBindingContext bindingContext)
        {
            var message = (EmailMessage)bindingContext.Model;
            message.To = "[email protected]";
            return base.OnModelUpdating(controllerContext, bindingContext);
        }

        protected override void OnModelUpdated(ControllerContext controllerContext, 
                                               ModelBindingContext bindingContext)
        {
            var message = (EmailMessage)bindingContext.Model;
            var validationErrors = message.ValidationErrors();            
            foreach (var validationError in validationErrors)
                bindingContext.ModelState.AddModelError(validationError.Key, validationError.Value);
            base.OnModelUpdated(controllerContext, bindingContext);
        }

Bem mais limpo e claro, não é? Estou usando o método OnModelUpdated, que é onde você realiza as últimas ações sobre o objeto de modelo.

No controlador nada mudou.

Vejam o teste unitário, testando apenas as regras de negócio de validação:

        [TestMethod()]
        public void ValidationErrorsTest()
        {
            EmailMessage target1 = new EmailMessage();
            var errors1 = target1.ValidationErrors();

            EmailMessage target2 = new EmailMessage()
            {
                To = string.Empty,
                FromEmailAddress = string.Empty,
                Message = string.Empty,
                FromName = string.Empty,
                Subject = string.Empty
            };
            var errors2 = target2.ValidationErrors();

            Action<ICollection<KeyValuePair<string, string>>> Verify = 
                    delegate(ICollection<KeyValuePair<string, string>> errors)
            {
                Assert.AreEqual(2, (from pairs in errors where pairs.Key == "FromEmailAddress" select pairs).Count());
                Assert.AreEqual(1, (from pairs in errors where pairs.Key == "Message" select pairs).Count());
                Assert.AreEqual(1, (from pairs in errors where pairs.Key == "FromName" select pairs).Count());
                Assert.AreEqual(1, (from pairs in errors where pairs.Key == "Subject" select pairs).Count());
                Assert.AreEqual(1, (from pairs in errors where pairs.Key == "To" select pairs).Count());
                Assert.AreEqual(6, errors.Count);
            };

            Verify(errors1);
            Verify(errors2);            
        }

Notem que faço dois testes, um com as propriedades nulas, e outro com elas vazias. Os resultados tem que ser os mesmos, então fiz a validação através de uma lambda para não ficar me repetindo. Simples e claro, certo? Já imaginaram o trabalho que ia dar testar essa regra de validação da maneira anterior, onde o binder era o responsável pela validação? Bem mais complicado.

Esse foi um caso real, espero que tenha sido um bom exemplo não só de ASP.Net MVC, mas também de separação de resposabilidades e testes. O que acharam?

Giovanni Bassi

Arquiteto e desenvolvedor, agilista, escalador, provocador. É fundador e CSA da Lambda3. Programa porque gosta. Acredita que pessoas autogerenciadas funcionam melhor e por acreditar que heterarquia é mais eficiente que hierarquia. Foi reconhecido Microsoft MVP há mais de dez anos, dos mais de vinte que atua no mercado. Já palestrou sobre .NET, Rust, microsserviços, JavaScript, TypeScript, Ruby, Node.js, Frontend e Backend, Agile, etc, no Brasil, e no exterior. Liderou grupos de usuários em assuntos como arquitetura de software, Docker, e .NET.