2

I have an ASP.NET Core API app and I'm using IActionResult for a return type. For an example, I'll use the following snippet from Microsoft's documentation:

[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetById(int id)
{
    if (!_repository.TryGetProduct(id, out var product))
    {
        return NotFound();
    }

    return Ok(product);
}

My controllers are similar, where routes are using the IActionResult and using functions such as NotFound(), BadRequest(), or Ok() and following the repository pattern for obtaining data from a database. In Microsoft's trivial example, they're simply evaluating if the result is null, and if it is they'll return NotFound().

In the repository logic, there could be any number of issues that occurred. For example:

  • DB query success, but product wasn't found (404 not found)
  • DB query failure, connection string was invalid (500 server error)
  • Product ID didn't pass some validation test (400 bad request)

In all of the above cases, you could maybe justify returning null from the TryGetProduct() function, since it wasn't able to retrieve a product in any of those cases. But returning null as a general return result from a repository function doesn't seem helpful and could mean any number of issues occurred. What's the best way I can return data from a repository to allow the controller to then determine the appropriate status code to return and what are the best practices for this?

Nemanja Todorovic
  • 2,521
  • 2
  • 19
  • 30
Birdman
  • 1,404
  • 5
  • 22
  • 49
  • I'm under the opinion that returning null is appropriate when no results are found in you DB. The DB failing because of a bad query string should throw an exception, which the default middleware should catch and produce an appropriate 500. The validation error for the Product ID is really the only use case you should be thinking about. You could potentially use data annotations: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-3.1#validation-attributes – John Jul 29 '20 at 07:15
  • Returning null is about the worst thing you can do *ever*. Your example problem makes at least one potential issue with it obvious (why is it null?). For your first example, return a `Product` with `Id = -1` from a `GET`, for example. For the other two, you can create a more complex object that is able to encapsulate the notion of `Validation` that can have a result value or a list of validation errors. In any case, emitting connection string or other DB errors isn't a good idea. – ChiefTwoPencils Jul 29 '20 at 07:15
  • 2
    Returning null is just fine when no object is found. If there is an error you should throw an exception and perhaps a different http code. On the other hand resorting to hacks like returning objects with "id = -1" is definitely not a good idea because in theory an object could have an id of -1. Also what I usually do is I have a DataResult object which is generic and has a Content property, a Success property and an Exception property. That way the caller can easily know if something when wrong. – Jonathan Alfaro Jul 29 '20 at 07:25
  • I think you should put a business layer between your controller method and your repository and see the controller method just as an interface to 3rd parties (GUI or whatever). So just verify parameters given to that method. Inside your business layer execute your logic, load data from your db etc. Inside your business layer you can handle everything you need regarding program logic. So in some cases NULL maybe can be a valid value and in others not. Throw Exceptions from your business layer and handle them on your need inside your controller. – user743414 Jul 29 '20 at 08:08
  • You're trying to get `TryGetProduct` to guess what your controller action wants to do. First of all, `TryGetProduct` like all methods following the `TryXXX` *convention* doesn't return anything when it fails, so its out variable is set to default, whatever that is, and **shouldn't be used**. A simple `Try` *can't* return multiple failure results though. If you want to return *multiple* results, you should use a different return type, eg an enum, a value tuple eg `(result, message)` with the payload or failure message, a `Result` type with the payload and explanation. – Panagiotis Kanavos Jul 29 '20 at 08:48
  • Maybe even an `IResult` with success and failure subtypes that can be used in pattern matching, to return a *specific* error that can be used by the controller action. – Panagiotis Kanavos Jul 29 '20 at 08:49
  • Apart from that, 2 of the 3 different error cases shouldn't be handled by the data class. A bad connection string should throw an exception. It's not something you can recover from. Validation of the action's parameters is the job of the controller, not the repository. The repository (or ORM) knows and cares only about the data model, not the resource, message or view model. Which leaves just the normal `not found` case for the repository – Panagiotis Kanavos Jul 29 '20 at 08:51
  • @JonathanAlfaro, having an id that is negative is not a "hack" it's just an example of a better way than null because, theory aside, ids are not negative in any *real* system; but you can do it other ways as well. I think if you were to research your opinion on null usage you'd find you're in the minority. Many developers are removing it entirely and indeed languages new and old are dealing with the "billion dollar mistake" by implementing `Option`s, forcing acknowledgment of nullables or leaving it out the language. – ChiefTwoPencils Jul 29 '20 at 15:29
  • Take a look at [this book](https://www.manning.com/books/functional-programming-in-c-sharp) in general but [chapter 6](https://livebook.manning.com/book/functional-programming-in-c-sharp/chapter-6?origin=product-toc) in particular; it deals with your issue in a very clean and functional way. The [source code](https://github.com/la-yumba/functional-csharp-code) is pretty interesting to look over as well. – ChiefTwoPencils Jul 29 '20 at 15:58
  • @ChiefTwoPencils null is an integral part of C#. Actually has a whole new set of features in the newest version of the language. Using -1 to represent null is just a hack... that does not conform to any pattern I know. Entities can have an id of -1. I have seen many databases that use all numbers for their id columns because they run out of id's pretty quickly. You can set your seed to Int32.MinValue in a sql server database for an Identity column. Returning something when there is nothing.... well that is not a conceptual representation of the functional requirement. – Jonathan Alfaro Jul 29 '20 at 17:32
  • @JonathanAlfaro, it is integral; it's been there since the beginning but the new features that are being added are meant to aid in the issues with nulls; it's not a "hey, null is so great we extended it with all these great features" thing. The fact this has to be done at all says more negative than positive things about nulls or at least their use. Using a negative number as an id was used in EFCore in the time between adding and saving a new object for example so it's an ORM pattern there, likewise 0 often indicates an empty/null object (for better or worse). – ChiefTwoPencils Jul 29 '20 at 19:46
  • Returning something (e.g. anything other than null) can be a representation of the requirement. Consider implementing an `Option` that can have either `Some(value)` or `None`. With pattern matching it can be quit pleasant and clean to deal with. I'd roll my own with a `Match(some, none)` and do `productOption.Match(some: f, none: g);` or use C#s not so great version of matching with `switch`. But, we can agree to disagree; cheers. – ChiefTwoPencils Jul 29 '20 at 19:51

1 Answers1

1

I would like to show my way.

You can try to configure Exception Handler.

Create new class ExceptionMiddlewareExtension like :

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using System;
using System.Net;

namespace Project.Middleware
{
public static class ExceptionMiddlewareExtension
  {
    public static void ConfigureExceptionHandler(this IApplicationBuilder app)
    {
        app.UseExceptionHandler(appError =>
        {
            appError.Run(async context =>
            {
                context.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
                context.Response.ContentType = "application/json";

                var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
                if (contextFeature != null)
                {
                    ResponseModel<string> responseModel = new ResponseModel<string>();
                    if(contextFeature.Error is Exception)
                    {
                        responseModel.Errors.Add(new ErrorModel()
                        {
                            ErrorCode = context.Response.StatusCode.ToString(),
                            Message = contextFeature.Error.Message
                        });
                    }
                    responseModel.Result = false;
                    await context.Response.WriteAsync(responseModel.ToString());
                }
            });
        });
    }
  }
}

Create Response Model:

using System.Collections.Generic;
using Newtonsoft.Json;

namespace Project.Domain
{
public class ResponseModel<T>
  {
    public ResponseModel()
    {
        Errors = new List<ErrorModel>();
    }

    public ResponseModel(T data)
    {
        Data = data;
        Result = true;
    }

    public ResponseModel(List<ErrorModel> errors)
    {
        Errors = errors;
    }

    public bool Result { get; set; }
    public T Data { get; set; }
    public List<ErrorModel> Errors { get; set; }
    

    public override string ToString()
    {
        return JsonConvert.SerializeObject(this);
    }
  }
}

Create Error Model:

using Newtonsoft.Json;
namespace Projec.Domain
{
    public class ErrorModel
    {
        public string Message { get; set; }

        public string ErrorCode { get; set; }
        
        public string Code { get; set; }
        
        public override string ToString()
        {
            return JsonConvert.SerializeObject(this);
        }
    }
}

And then in your Startup.cs add below line to Configure():

app.ConfigureExceptionHAndler();

On the other hand to check your request params is valid you can try ActionFilterAttribute like :

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Project.Middleware
{
    public class ModelStateValidationAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if (!context.ModelState.IsValid)
            {
                context.Result = new UnprocessableEntityObjectResult(context.ModelState);
            }
        }
    }
}

Briefly, i use this way and clearly enough. Your all request will go through filter and middleware. Just use Try-Catch in your managers(or check your object is null) and if something go wrong then throw new Exception(msg).

Also you can write your custome exception model and throw. Then add your model to middleWare to check contextFeature.Error is CustomException

I hope it will be helpful.

Kaan Kemal
  • 15
  • 4