Keeping controllers clean and tidy is something we’ve learned we should do the first time we’ve stumbled upon the MVC pattern. But as the project grows and other team members enter the project, things might get out of hand. Especially when deadlines need to be met, and that’s the case almost all the time, unfortunately.

To help you organize your ASP.NET Core Web API controllers better, we’ve compiled a simple list with examples that should keep your controller slim for a long time.

We’re going to cover:

Let’s start.

Data Access Logic

Let’s start with the most obvious one. We shouldn’t use controllers to access data directly. While this is a general rule of thumb, let’s keep in mind that not all the projects need several layers, and some are probably best kept simple.

For other projects, especially the bigger ones, we shouldn’t use data access logic in the controllers. Most of the time, one data access method becomes two, and then we need to add one more… And after a few months we have fully cluttered our controller to the point we don’t even know what’s happening.

Not only that, once it gets bad, we’ll probably need to add some logic to process the data we’ve returned, and voila, MVC is broken. The repository pattern is a great way to hide our data access logic and create a separate layer for it, but still, we shouldn’t use it in our controllers directly:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _repository.GetProduct(id);
    return Ok(product);
}

While something like this is entirely okay for simpler projects, you can imagine it becoming a garbage pile very soon on complex projects with multiple team members.

To get the data you can use the default ASP.NET Core feature Entity Framework Core, or some other frameworks like Dapper.

So what should we do about it?

Read on.

Business Logic

Let’s say we need to process our newly returned product somehow. For example, we need to calculate the product price for some reason:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _repository.GetProduct(id);

    ApplyTheDiscount(product);

    return Ok(product);
}

And there we go, we’ve successfully made our controllers hard to maintain in the long run. Not only that, but our controller has lost its purpose, and it’s probably going to live the rest of its life as an empty purposeless husk doomed to exist in eternal agony. Okay, maybe not that dramatic, but you get the point.

Instead of doing something like this, we can create a service layer in our application and use it to store all our business logic in there, along with the data access logic.

This will keep our controllers slim and clean:

public ProductController(IServiceManager service)
{
    _service = service;
}

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);

    return Ok(product);
}

Okay, that’s much better. (We’re going to assume that the ServiceManager is injected from now on). Also, if you want to see a real implementation of the service layer, you can read our Onion Architecture article.

But there’s one more big problem in this snippet. Can you guess what it is?

If you’ve guessed we’re returning the entity directly to the frontend, you are right.

There are multiple reasons why that’s wrong. We use entities to design our database and relationships. That means we can have problems with validation, serialization, and we’re probably getting our entities dirty since we send them to the frontend. 

Another thing is that fronted applications usually need some, but not all the fields from the entity. Or some pages might need fields for different entities altogether. To keep things clean, we use something called a DTO (Data Transfer Object) which can easily be adjusted to our needs. To read more about DTOs and how to use them in the ASP.NET Core project, you can read part 5 and part 6 of our ASP.NET Core Web API series.

That brings us to our next point, mapping entities to DTOs and vice versa.

Mapping Model Classes Manually

We could do something like this:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);

    var productDto = new ProductDto
    {
        Name = Name,
        Details = Details,
        Price = Price
    };
        
    return Ok(productDto);
}

Now we’ve solved the previous problem, and we’re sending DTO instead of our entity as a result and that’s much better. We’re going to avoid a lot of potential problems this way.

But as you can see, we’re doing the mapping from the entity to the DTO manually. This is not only tiresome and repetitive, but it makes the code really unreadable. Imagine having complex models that you constantly need to map back and forth.

One approach would be to move this repetitive logic to separate classes and do the mapping there.

But another, even better approach would be to use a mapping library like AutoMapper to make this process even easier.

By using AutoMapper, we can easily perform the same thing:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);

    var productDto = _mapper.Map<ProductDto>(product);

    return Ok(productDto);
}

As you can see this looks much better. AutoMapper is an incredibly useful library, and it has limitless potential to make your life easier. If you want to learn more about it check out our article on how to get started with AutoMapper.

This is also possible in another direction when mapping DTOs to entities as well. This usually happens in our PUT and POST actions:

[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);

    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

Awesome stuff. Much cleaner. As you can see, we are passing the DTO to the service and do the mapping there. If your architecture allows you to do that, we would always recommend mapping entities and DTOs inside a service layer rather than inside a controller.

To make our GET request even better, we can move the mapping logic to the service layer as well:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var productDto = await _service.GetProductAndApplyDiscount(id);

    return Ok(productDto);
}

Now the service layer gets us the DTO directly instead of the entity, which we need to map in the controller, and thus making the controller even cleaner.

Exception Handling

Exception handling is essential in any application. But is the controller a place to do it? The short answer is no.

ASP.NET Core offers a great way of handling exceptions globally through middleware. Combining global exception middleware with the correct status codes for other scenarios is a great way to keep controllers clean, and avoid unpleasant situations and application crashes.

Instead of doing exception handling manually:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    try
    {
        var productDto = await _service.GetProductAndApplyDiscount(id);

        return Ok(productDto);
    }
    catch (Exception ex)
    {
        _logger.LogError($"Something went wrong while getting the product: {ex}");
                
        return StatusCode(500, "Internal server error");
    }
}

We can configure our exception handling logic in the Configure method of the Startup class:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.ConfigureExceptionHandler(logger);
    ...
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

And keep our controllers clean from all the exception handling logic.

You can find more details on how to configure this in our article on Global Exception Handling in ASP.NET Core.

Repetitive Logic (Action Filters)

Unavoidably, we’re doomed to have some repetitive parts of the code in our application. That’s the nature of the programming and we need to find good ways to fix that.

Fortunately for us, there is a great mechanism for that in ASP.NET Core called Action Filters.

Here’s a simple PUT action:

[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    if (productDto == null)
    {
        _logger.LogError("Object sent from the frontend is null.");
        return BadRequest("Object sent from the frontend is null.");
    }

    if (!ModelState.IsValid)
    {
        _logger.LogError("Invalid model state for the ProductDto object");
        return UnprocessableEntity(ModelState);
    }

    var product = await _service.CreateProduct(productDto);

    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

There are two if statements we can extract and use on multiple occasions. An action filter is a perfect solution for that.

This can easily be avoided by using a simple validation filter:

[HttpPost]
[ServiceFilter(typeof(ValidationFilterAttribute))]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);

    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

Now we don’t need to worry about validation anymore. 

Creating action filters is not that hard per se, but it’s not trivial. But once you master them, they become very powerful tools in your API development toolbox.

If you want to learn more about them check our article on Implementing Action Filters in ASP.NET Core.

Manual Authorization

ASP.NET Core is equipped with some great tools to Authorize users and protect the resources accordingly.

You don’t need to go out of your way to create some complex and unnecessary mechanisms to do authorization. In most cases, the [Authorize] attribute can do wonders.

Take for example this code:

[HttpPost, Authorize(Roles = "Manager")]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);

    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product );
}

This means only the users with the role “Manager” can create new products. Makes perfect sense. 

Of course, there is more to it and you need to authenticate users first via Identity. You also have to make sure to assign roles to users on creation or later on.

If you want to learn more about this process, you can read our Role-Based Authorization in the ASP.NET Core article.

Synchronous Code

When creating flexible APIs, one of the most important things is to make it asynchronous from top to bottom. 

Asynchronous APIs provide a much better experience, and that’s the way it should be.

Entity Framework Core is already equipped with async methods to access the database, so why not start from the top (controllers) and push the async approach down to the data access layer?

A typical async controller POST action looks like this:

[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);

    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

As you can see we’re using the async keyword, and we’re awaiting the result from the CreateProduct service method.

To help you understand this concept even better, we’ve written an article about asynchronous programming in .NET and a piece on how to create an Asynchronous Generic Repository Pattern if you want to get more specific. Do check it out if you’re interested.

There is some conflicting advice about whether we should use it or not, but it’s best to try it out before deciding for yourself.

Use GET Methods To Achieve Everything

This one is pretty strange, but you’re either going to understand why it’s on the list or you won’t get it at all. 

We all know that a good API consists of all kinds of methods, GET, POST, PUT, DELETE, PATCH, and sometimes even more. 

Some people are not fond of these, and they like to create their own set of rules of how a REST API should be organized. According to them, GET methods are all you’ll ever need, so they use them as a staple of their APIs.

This is of course very wrong and if you ever encounter these kinds of APIs or the people that made them, we are so sorry. You shouldn’t have witnessed that. Once you get a hold of yourself, try to educate your peers on how to properly implement GET, POST, PUT and DELETE methods at least.

Make the world a better place one API at a time.

One Controller – Multiple Responsibilities

Making one controller do many different things is plain wrong. 

The product controller should only be responsible for routing the product-related stuff. If you find yourself writing if statements or thinking too much about how to process the incoming data, that means the time for the new controller to be created is upon you.

Not only that, if your routes become too long or too complicated, you can probably create a few more controllers to make your API more user-friendly.

Worst case scenario is that your model is too tangled up, so you might need to restructure your model, which is what everyone loves to hear anyway, right? 🙂

Tight Coupling

Last but not least is the tight coupling of the dependencies.

Fortunately for us, ASP.NET Core is designed to support dependency injection out of the box. So make use of it!

Dependency injection and service registration have never been easier, so it would be a shame to make some tight coupling without a reason.

Controllers support dependency injection too, so you can easily inject multiple services through the controller constructor:

private readonly ILoggerManager _logger;
private readonly UserManager<User> _userManager;
private readonly IAuthenticationManager _authManager;

public AuthenticationController(ILoggerManager logger,
    UserManager<User> userManager,
    IAuthenticationManager authManager)
{
    _logger = logger;
    _userManager = userManager;
    _authManager = authManager;
}

There are three different services we’re injecting via controller injection.

Once injected, these services are available throughout the controller.

If we should ever need another service, we can easily register it in the ConfigureServices method of the Startup class:

services.AddScoped<INeededService, NeededService>();

And it’s ready to be used throughout our application.

It’s important to note that there are three different lifetime scopes in which we can register our services. We can register them as transient, scoped, or singleton services.

If you want to learn more about how Dependency injection in ASP.NET Core works, check out the Dependency Injection in ASP.NET Core article.

Conclusion

These are the most important things you should think about when trying to keep your controllers in ASP.NET Core clean.

We can easily get sidetracked and forget about our controllers altogether, so these points might serve as a reminder and a list on what to take care of and not forget while working on new and exciting technologies.

It’s easy to lose control, but hard to get it back.