When working on an API, the happy path is always the easiest to implement. But what about those not-so-happy paths?
Problem
Let’s imagine a simple GET request handler that gets the desired object from the database and returns it to the client.
[HttpGet("{id}")]
public async Task<ActionResult<Book>> Get(int id)
{
var book = await dbContext.Books.FirstOrDefaultAsync(b => b.Id == id);
return Ok(book);
}
Well, that was easy.
What if there isn’t an object with such ID?
[HttpGet("{id}")]
public async Task<ActionResult<Book>> Get(int id)
{
var book = await dbContext.Books.FirstOrDefaultAsync(b => b.Id == id);
if (book is null)
return NotFound();
return Ok(book);
}
Still quite easy – we only add a null check.
Now let’s move the logic out of the controller (because that’s how it’s usually done in those APIs that aren’t created as a part of a tutorial).
[HttpGet("{id}")]
public async Task<ActionResult<Book>> Get(int id)
{
// we don't know exactly what MyDataLayerDoes, but it should give us the book
var book = MyDataLayer.GetBook(id);
// and if it gives us null, we assume that such book doesn't exist
if (book is null)
return NotFound();
return Ok(book);
}
This still works quite well. But is returning null
the best way to say that the book couldn’t be found?
What if there is something else that can cause the method to return null
?
Should we deal with all non-standard situations by throwing an exception?
Or what if there is nothing to return, like in the example below?
If we receive a DELETE request and handle it in the controller, it’s all right.
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(int id)
{
var book = await dbContext.Books.FirstOrDefaultAsync(b => b.Id == request.Id);
if (book is null)
return NotFound();
dbContext.Remove(book);
await dbContext.SaveChangesAsync();
return NoContent();
}
However, what if we move the logic to our data layer? We could of course return the book and check for null
in the same way as in the GET request handler, but why should Delete()
method return a book object if it’s needed just for the null check?
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(int id)
{
// What should "something" be?
var something = MyDataLayer.DeleteBook(id);
// How do we check if the book was really deleted?
if(something == ???)
return NotFound();
// What if other things can go wrong as well?
if(some other condition...)
return BadRequest();
return NoContent();
}
We need a special kind of object that will clearly tell us:
- whether the operation was successful,
- if yes, then what it returned,
- if no, then what was the problem.
Solution
How others do it
This apparently is not an uncommon problem, because there are programming languages with built-in constructs that deal with those 3 things mentioned above.
Functional languages provide classes as Option
(Scala), Option
(F#) or Try
(Scala) that can carry additional information about the object.
We don’t have such classes in C#, but we can take inspiration from them and create our own ones!
Note: If you want to know more about what else you can achieve with those classes (e.g. get rid of a couple of null checks and/or try-catch blocks), I recommend this talk about railway-oriented programming.
How we can do it
Let’s create our own Result
class that whose objects will contain all the necessary data!
Create the Result class
We create a non-generic version for “empty” results (for methods that would otherwise return void
):
public class Result
{
public bool IsSuccess { get; set; }
public ErrorCode? ErrorCode { get; set; }
}
And a generic version that also includes the object that the method would return:
public class Result<T> : Result
{
public T Body { get; set; }
}
In this implementation, we only include ErrorCode
in the result, but you can of course add a custom error message as well.
public enum ErrorCode
{
// error codes might be mapped to HTTP errors
NotFound,
BadRequest,
// or completely arbitrary - it will be controller's job to handle them
IDontLikeIt
}
Add static methods for quick initialization
These are all going to be methods of the Result
class.
public static Result Success()
{
return new Result { IsSuccess = true };
}
public static Result Failure(ErrorCode errorCode)
{
return new Result { IsSuccess = false, ErrorCode = errorCode };
}
public static Result<T> Success<T>(T body)
{
return new Result<T> { IsSuccess = true, Body = body };
}
public static Result<T> Failure<T>(ErrorCode errorCode)
{
return new Result<T> { IsSuccess = false, ErrorCode = errorCode };
}
Some might argue that this is ugly in terms of OOP and I don’t doubt that. However, my goal was to make usage of those methods as simple as possible, so instead of Result<Book>.Success(book)
(if we would put Success<T>
method to Result<T>
class), we just have Result.Success(book)
.
How it looks like
In the controller, we obtain the result object and use if
statements to handle all possible cases.
This is how a GET request will be processed:
[HttpGet("{id}")]
public async Task<ActionResult<Book>> Get(int id)
{
Result<Book> result = MyDataLayer.GetBook(id);
if (result.IsSuccess)
{
var book = result.Body;
return Ok(book);
}
if (result.ErrorCode == ErrorCode.NotFound)
return NotFound();
// return BadRequest or some other universal error - all possible error types should be covered by the if statements above
return BadRequest();
}
And similar solution for DELETE request handling:
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(int id)
{
Result result = MyDataLayer.DeleteBook(id);
if(result.IsSuccess)
return NoContent();
if(result.ErrorCode == ErrorCode.BadRequest)
return BadRequest();
if(result.ErrorCode == ErrorCode.NotFound)
return NotFound();
// return BadRequest or some other universal error - all possible error types should be covered by the if statements above
return BadRequest();
}
Conclusion
If we separate data handling logic from controllers, it is more difficult to pass information about errors without throwing exceptions. In this article we created a solution for this problem, inspired by functional programming languages capabilites.
Source code
You can view the complete source code on Github. It uses MediatR for the layer between the controllers and the database.
Last modified on 2020-12-07