Controllers as action filters in ASP.NET Core MVC

Β· 1068 words Β· 6 minutes to read

It is common to leverage action filters when building MVC applications - this was the case in classic ASP.NET MVC, in ASP.NET Web API and is a still widely used technique (with much richer support!) in ASP.NET Core MVC.

What is not commonly known though, is that it’s possible for controllers to act as their own filters - so let’s have a look at this feature today.

Background πŸ”—

You can do a lot of useful things with filters, especially around validation of requests. One fairly typical example of filter usage would be to check if a given ID exists in the underlying data store.

In order to illustrate this fairly common usage scenario, let’s imagine the following controller (simplified for brevity, with only Get By ID and Delete actions):

[Route("[controller]")]

[ApiController]

public class ContactsController : Controller

{

    private readonly IContactRepository _repository;



    public ContactsController(IContactRepository repository)

    {

        _repository = repository ?? throw new ArgumentNullException(nameof(repository));

    }



    [HttpGet("{id}", Name = "GetContactById")]

    public async Task<ActionResult<Contact>> Get(int id)

    {

        var contact = await _repository.Get(id);

        if (contact == null)

        {

            return NotFound();

        }



        return Ok(contact);

    }



    [HttpDelete("{id}")]

    public async Task<IActionResult> Delete(int id)

    {

        var deleted = await _repository.Get(id);

        if (deleted == null)

        {

            return NotFound();

        }

           

        await _repository.Delete(id);

        return NoContent();

    }

}

In both Get By ID and Delete actions, we first check whether the Contact exists, and if it doesn’t, we issue a 404. Instead of checking in two (or more) places for whether a given ID is present in the data store, you can delegate this operation to a filter.

In ASP.NET Core MVC, filter can also have its own dependencies (such as the data storage) injected via constructor. However, for this to work, it has to be specified as TypeFilter. This is shown next.

public class ContactExistsAttribute : TypeFilterAttribute

{

    public ContactExistsAttribute() : base(typeof(ContactExistsFilter))

    {}



    private class ContactExistsFilter : IAsyncActionFilter

    {

        private readonly IContactRepository _contactRepository;

        

        public ContactExistsFilter(IContactRepository contactRepository)

        {

            _contactRepository = contactRepository;

        }



        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)

        {

            if (context.ActionArguments.ContainsKey("id"))

            {

                var id = (int)context.ActionArguments["id"];

                if (await _contactRepository.Get(id) == null)

                {

                    context.Result = new NotFoundResult();

                    return;

                }

            }



            await next();

        }

    }

}

We can then use such a filter to eliminate the duplicate code from our controller, as it would implicitly check for id in the action arguments, match this to the data storage and issue a 404 in case the entry is not there.

The simplified code looks like this:

[Route("[controller]")]

[ApiController]

[ContactExists]

public class ContactsController : Controller

{

    private readonly IContactRepository _repository;



    public ContactsController(IContactRepository repository)

    {

        _repository = repository ?? throw new ArgumentNullException(nameof(repository));

    }



    [HttpGet("{id}", Name = "GetContactById")]

    public async Task<ActionResult<Contact>> Get(int id)

    { 

        // we could pass the instance from the filter too

        // in order not to refetch from the store

        var contact = await _repository.Get(id);

        return Ok(contact);

    }



    [HttpDelete("{id}")]

    public async Task<IActionResult> Delete(int id)

    {

        await _repository.Delete(id);

        return NoContent();

    }

}

However it is not necessarily a great idea to have the filter separate from the controller, cause, after all, they are tightly coupled to each other and would normally always be used together.

Therefore, a nice alternative is to use the controller as a filter for itself, and this is what this post is about.

Controller as action filter πŸ”—

Action filters are defined by IActionFilter and IAsyncActionFilter interfaces, and typically implemented directly or using the base class ActionFilterAttribute, which you can inherit from and override the necessary method. This is something that we have already seen in the previous snippets, with our ContactExistsFilter.

However, what is very interesting, is that the base Controller class is already a filter too - it implements both IActionFilter and IAsyncActionFilter interfaces, in both cases as no-op, virtual methods. So you can override them, and have your controller become a filter for itself.

This means that we can combine our ContactsController and ContactExistsAttribute into one class.

[Route("[controller]")]

[ApiController]

public class ContactsController : Controller

{

    private readonly IContactRepository _repository;



    public ContactsController(IContactRepository repository)

    {

        _repository = repository ?? throw new ArgumentNullException(nameof(repository));

    }



    [HttpGet("{id}", Name = "GetContactById")]

    public async Task<ActionResult<Contact>> Get(int id)

    { 

        // we could pass the instance from the filter too

        // in order not to refetch from the store

        var contact = await _repository.Get(id);

        return Ok(contact);

    }



    [HttpDelete("{id}")]

    public async Task<IActionResult> Delete(int id)

    {

        await _repository.Delete(id);

        return NoContent();

    }

    

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)

    {

        if (context.ActionArguments.ContainsKey("id"))

        {

            var id = (int)context.ActionArguments["id"];

            if (await _repository.Get(id) == null)

            {

                context.Result = new NotFoundResult();

                return;

            }

        }



        await next();

    }

}

Since in this case both the controller and its “filter” functionality are the same class, they obviously have a single constructor for both use cases - so dependencies are universally available. In fact, the instance of the controller is only created once per request - both to act as filter in the filter pipeline and then as controller during the request handling. This also simplifies sharing state between the controller and action functionalities - should you need to do so. In the case of the earlier filter-controller separation two separate object instances were used per each request.

If you are referencing Microsoft.AspNetCore.Mvc.Core instead of Microsoft.AspNetCore.Mvc package - perhaps you are building lightweight APIs and want to omit things like Razor Views - then you will not have access to the base Controller class, and instead you’d likely be using ControllerBase instead, which is not acting as filter by default.

However, you can still make the controller act as filter by simply manually implementing one of the action filter interfaces - i.e. IAsyncActionFilter. In that case you should remember to mark the method implementation as [NonAction], otherwise the framework will attempt to use it as an action.

This is shown in the next snippet.

[Route("[controller]")]

[ApiController]

public class ContactsController : ControllerBase, IAsyncActionFilter

{

    private readonly IContactRepository _repository;



    public ContactsController(IContactRepository repository)

    {

        _repository = repository ?? throw new ArgumentNullException(nameof(repository));

    }



    [HttpGet("{id}", Name = "GetContactById")]

    public async Task<ActionResult<Contact>> Get(int id)

    { 

        // we could pass the instance from the filter too

        // in order not to refetch from the store

        var contact = await _repository.Get(id);

        return Ok(contact);

    }



    [HttpDelete("{id}")]

    public async Task<IActionResult> Delete(int id)

    {

        await _repository.Delete(id);

        return NoContent();

    }

    

    [NonAction]

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)

    {

        if (context.ActionArguments.ContainsKey("id"))

        {

            var id = (int)context.ActionArguments["id"];

            if (await _repository.Get(id) == null)

            {

                context.Result = new NotFoundResult();

                return;

            }

        }



        await next();

    }

}

And that’s all! As usually, the source code for this article is available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP