Search code examples
validationexceptionaspnetboilerplatebusiness-rulesservice-layer

Alternative way for Throwing UserFriendlyException and Exception Handling for Business Rule Validation


Considering the cost of throwing exception, an alternative way is something like this:

public interface IValidationDictionary
{
    void AddError(string key, string message);
    bool IsValid { get; }
}

public class ModelStateWrapper : IValidationDictionary
{
    private ModelStateDictionary _modelState;

    public ModelStateWrapper(ModelStateDictionary modelState)
    {
        _modelState = modelState;
    }

    public void AddError(string key, string errorMessage)
    {
        _modelState.AddModelError(key, errorMessage);
    }

    public bool IsValid
    {
        get { return _modelState.IsValid; }
    }
}
public interface IApplicationService
{
    void Initialize(IValidationDictionary validationDictionary);
}
public interface IUserService : IApplicationService
{
    Task CreateAsync(UserCreateModel model);
}
public class UserService : IUserService
{
    private readonly IUnitOfWork _uow;
    private IValidationDictionary _validationDictionary;

    public UserService(IUnitOfWork uow)
    {
        _uow = uow ?? throw new ArgumentNullException(nameof(uow));
    }

    public void Initialize(IValidationDictionary validationDictionary)
    {
        _validationDictionary = validationDictionary ?? throw new ArgumentNullException(nameof(validationDictionary));
    }

    public Task CreateAsync(UserCreateModel model)
    {
        //todo: logic for create new user

        if (condition)
            //alternative: throw new UserFriendlyException("UserFriendlyMessage");
            _validationDictionary.AddError(string.Empty, "UserFriendlyMessage");

        if (other condition)
            //alternative: throw new UserFriendlyException("UserFriendlyMessage");
            _validationDictionary.AddError(string.Empty, "UserFriendlyMessage");
    }
}

public class UsersController : Controller
{
    private readonly IUserService _service;

    public UsersController(IUserService service)
    {
        _service = service ?? throw new ArgumentNullException(nameof(service));
        _service.Initialize(new ModelStateWrapper(ModelState));
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromForm]UserCreateModel model)
    {
        if (!ModelState.IsValid) return View(model);

        await _service.CreateAsync(model);

        //todo: Display ModelState's Errors
    }
}

considering there is a difference between input validation like validate a DTO and business rule validation https://ayende.com/blog/2278/input-validation-vs-business-rules-validation

Input Validation for me is about validating the user input. Some people call "Name must not be empty" a business rule, I think about it as input validation. Business Rules validation is more complex, because a business rule for me is not "Name must not be empty", it is a definition of a state in the system that requires an action. Here is a definition of a business rule:

An order should be payed within 30 days, this duration can be extended, to a maximum of three times.

Is there any idea for send some error message of business rule validation that appear in between application service method's logic


Solution

  • Another approach

    public class Result
    {
        public bool Success { get; private set; }
        public string Error { get; private set; }
        public bool Failure { /* … */ }
        protected Result(bool success, string error) { /* … */ }
        public static Result Fail(string message) { /* … */ }
        public static Result<T> Ok<T>(T value) {  /* … */ }
    }
    
    public class Result<T> : Result
    {
        public T Value { get; set; }
        protected internal Result(T value, bool success, string error)
            : base(success, error)
        {
            /* … */
        }
    }
    

    The method is a command and it can’t fail:

    public void Save(Customer customer)
    

    The method is a query and it can’t fail:

    public Customer GetById(long id)
    

    The method is a command and it can fail:

    public Result Save(Customer customer)
    

    The method is a query and it can fail

    public Result<Customer> GetById(long id)