Scarass
Scarass

Reputation: 944

The optimal way to decouple business logic from controller

The rule is that controllers shouldn't have business logic, instead they should delegate it to the services. But when we do that, we can't handle all possible cases and return appropriate HTTP response.

Let's look at an example. Let's say that we are building some kind of a social network, and we need to create an endpoint for rating (liking or disliking) a post.

First let's take a look at an example where we delegate the logic to the service, this is our controller action:

public IActionResult Rate(long postId, RatingType ratingType)
{
    var user = GetCurrentUser();
    PostRating newPostRating = _postsService.Rate(postId, ratingType, user);
    return Created(newPostRating);
}

Do you see a problem in this? What if there is no post with the given id, how would we return a not found response? What if user has no permissions to rate a post, how would we return a forbidden response?

PostsService.Rate can only return a new PostRating, but what about other cases? Well, we could throw an exception, we would need to create a lot of custom exception, so that we can map them to the appropriate HTTP responses. I don't like to use exceptions for this, I think there is a better way to do handle these cases instead of exceptions. Because I think that cases when post doesn't exist and when user has no permissions aren't exceptional at all, they're just normal cases just like rating a post successfully.

What I propose, is handling that logic in a controller instead. Because in my opinion, that should be a controllers responsibility anyway, to check all of the permissions before commiting an action. So this is how I would do it:

public IActionResult Rate(long postId, RatingType ratingType)
{
    var user = GetCurrentUser();
    var post = _postsRepository.GetByIdWithRatings(postId);

    if (post == null)
        return NotFound();

    if (!_permissionService.CanRate(user, post))
        return Forbidden();

    PostRating newPostRating = new PostRating 
    {
        Post = post,
        Author = user,
        Type = ratingType
    };

    _postRatingsRepository.Save(newPostRating);

    return Created(newPostRating);
}

This is the way it should be done in my opinion but I bet that someone would say that this is too much logic for the controller, or that you shouldn't use a repository in it.

If you don't like using a repository in controller than where instead would you put a method that gets or saves posts? In service? So there would be PostsService.GetByIdWithRatings and PostsService.Save that would do nothing else but just call PostsRepository.GetByIdWithRatings and PostsRepository.Save. This so unnecessary and only causes boilerplate code.

Update: Maybe someone will say to check the permissions using PostsService and then call PostsService.Rate. This is bad because it involves more unnecessary trips to database. For an example, it would probably be something like this:

public IActionResult Rate(long postId, RatingType ratingType)
{
    var user = GetCurrentUser();

    if(_postsService.Exists(postId))
         return NotFound();

    if(!_postsService.CanUserRate(user, postId))        
         return Forbidden();

    PostRating newPostRating = _postsService.Rate(postId, ratingType, user);
    return Created(newPostRating);
}

Do I even need to explain any further why this is bad?

Upvotes: 3

Views: 636

Answers (2)

Alex
Alex

Reputation: 5247

What I did (just now) is created new class ApiResult

public class ApiResult
{
    public int StatusCode { get; private set; } = 200;
    public string RouteName { get; private set; }
    public object RouteValues { get; private set; }
    public object Content { get; private set; }

    public void Ok(object content = null)
    {
        this.StatusCode = 200;
        this.Content = content;
    }

    public void Created(string routeName, object routeValues, object content)
    {
        this.StatusCode = 201;
        this.RouteName = routeName;
        this.RouteValues = routeValues;
        this.Content = content;
    }

    public void BadRequest(object content = null)
    {
        this.StatusCode = 400;
        this.Content = content;
    }

    public void NotFound(object content = null)
    {
        this.StatusCode = 404;
        this.Content = content;
    }

    public void InternalServerError(object content = null)
    {
        this.StatusCode = 500;
        this.Content = content;
    }
}

And a controller base class with a single method TranslateApiResult

public abstract class CommonControllerBase : ControllerBase
{
    protected IActionResult TranslateApiResult(ApiResult result)
    {
        if (result.StatusCode == 201)
        {
            return CreatedAtAction(result.RouteName, result.RouteValues, result.Content);
        }
        else
        {
            return StatusCode(result.StatusCode, result.Content);
        }
    }
}

And now in controller I do:

[ApiController]
[Route("[controller]/[action]")]
public class MyController : CommonControllerBase
{
    private readonly IMyApiServcie _service;

    public MyController (
        IMyApiServcie service)
    {
        _service = service;
    }

    [HttpGet]
    public async Task<IActionResult> GetData()
    {
        return TranslateApiResult(await _service.GetData());
    }
}

In your services you inject repositories and other dependencies:

public class MyApiServcie : IMyApiServcie 
{
    public async Task<ApiResult> GetData()
    {
        var result = new ApiResult();
        // do something here
        result.Ok("success");
        return result;
    }
}

Now, the reason for Api prefix before the Service is that this service is not meant to be the final service containing all logic.

At this point I would split the business logic into different domains so the services (or facades) would end up without Api prefix in them just to differentiate between i.e. CarService. Preferably these services will not know of anything related to API responses, statuses etc. It's up to you how implement it, though.

Upvotes: 0

Chris Pratt
Chris Pratt

Reputation: 239360

There's a number of ways to handle this, but the closest thing to a "best practice" method is probably using a result class. For example, if your service method creates a rating and then returns that rating it created, you instead return an object that encapsulates the rating along with other relevant information, such as success status, error messages, if any etc.

public class RateResult
{
    public bool Succeeded { get; internal set; }
    public PostRating PostRating { get; internal set; }
    public string[] Errors { get; internal set; }
}

Then, your controller code would become something like:

public IActionResult Rate(long postId, RatingType ratingType)
{
    var user = GetCurrentUser();
    var result = _postsService.Rate(postId, ratingType, user);
    if (result.Succeeded)
    {
        return Created(result.PostRating);
    }
    else
    {
        // handle errors
    }
}

Upvotes: 1

Related Questions