Lets face it: Exceptions happens, it's about how we deal with them. There are obviously several ways to propagate the error back to consumers of our api. This article demonstrates my preferred way.
I've seen a lot of code similar to this:

public async Task<SomeClass> GetByIdAsync(Guid id) 
{
    SomeClass item = null;
    try 
    {
        item = await _somePersistence.GetAsync(id);
    }
    catch(Exception e)
    {
        Logger.LogException(e);
    }

    return item;
}

There are several things that worries me about such code. null can mean different things in the context of this method. You cannot tell whether an exception occured or that _somePersistence returned null. Lets just be more explicit in the way we communicate our intentions.

public async Task<SomeClass> GetByIdAsync(Guid id) 
{
    SomeClass item = null;
    try 
    {
        item = await _somePersistence.GetAsync(id);
        if(item == null)
            thrown new NotFoundException($"Could not find item {id}");
    }
    catch(Exception e)
    {
        Logger.LogException(e);
    }

    return item;
}

The intention are now expressed in a more explicity way by throwing an exception. The only problem now is that the exception doesn't reach the caller of this method as our catch-statement catches every exception. Again, be more specific. Do you know how to handle any exceptions that can occur from the _somePersistence.GetAsync(id) method? No? Why bother to handle it? If the answer is "I dont want my application to crash", use a global exception handler. For ASP.NET Core Web API we have a filter that can help us with that.

IExceptionFilter - Why and where to use it

Instead of dealing with HTTP Status Codes in your controller methods we can take advantage of the extensibility offered by the framework.
Lets assume that your api uses the GetById method above, but its rewritten to be expressive.

public async Task<SomeClass> GetByIdAsync(Guid id) 
{
    var item = await _somePersistence.GetAsync(id);
    if(item == null)
        thrown new NotFoundException($"Could not find item {id}"); 
    
    return item;
}

Your controller method uses this method the following way:

[HttpGet]
[Route("{id}")]
public Task<IHttpActionResult> Get([FromRoute]Guid id)
{
   try 
   {
       return Ok(await _repository.GetByIdAsync(id));
   }
   catch(NotFoundException e)
   {
       return NotFound();
   }
}

Handling errors this way is not very flexible, nor is it convenient. Remove all such exception-handling and introduce an implementation of IExceptionFilter.

An example implementation

Create a new class implementation `IExceptionFilter``

public class ExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        var exception = context.Exception;
        if(exception is NotFoundException e)
        {
            context.Result = new JsonResult(e.Message)
            {
                StatusCode = (int)HttpStatusCode.NotFound;
            };
        }
    }
}

Register the filter in your Startup.cs class

services.AddMvc(options =>
{
    options.Filters.Add<ExceptionFilter>();
});

Now you can remove try-catches from your api-controllers and leave the exception logic to be handled by this filter. Happy days!

PS: I would also recommend to log exceptions in this filter. You can resolve implementation registered in you dependency injection container of choice by writing context.HttpContext.RequestServices.GetService(typeof(...));

Thanks for reading!