-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Web API: why IActionResult instead of using exceptions mechanism? #5507
Comments
We felt that using exceptions for control flow is an anti-pattern (which is generally a commonly-held belief). But in practice exceptions are problematic for several reasons:
But then back to the non-practical answer: this is really, really, really not what exceptions were designed for. So, as always, to each their own, but we found that overall the exception-based mechanism was not worth carrying forward. |
There is no advantage in throwing an exception on a top level module. Exceptions are intended for reaching easily the higher level module that handles errors, thus avoiding the burden of returning any error to the immediate caller. Business layer top level is supposed to collect all exceptions and translating them into exceptions a controller "may understand", ie exceptions speaking the language of client operations. Then, controllers are supposed to collect these "User level" exceptions and using them to drive the interaction with the client side, not to throw further exceptions with a standard behavior. |
How do you factorize code (inside your controller) without using exceptions? // class ProductsController
private void CheckProductExists(int id)
{
Product item = repository.Get(id);
if (item == null) throw new HttpResponseException(HttpStatusCode.NotFound);
}
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] Product product)
{
CheckProductExists(id);
return Ok(repository.Update(id, product));
}
[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] Product product)
{
CheckProductExists(id);
return Ok(repository.Patch(id, product));
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
CheckProductExists(id);
repository.Delete(id);
return NoContent();
} I don't want to copy-paste code: // class ProductsController
[HttpGet("{id}")]
public IActionResult Get(int id)
{
Product item = repository.Get(id);
if (item == null) return NotFound();
return Ok(item);
}
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] Product product)
{
Product item = repository.Get(id);
if (item == null) return NotFound();
return Ok(repository.Update(id, product));
}
[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] Product product)
{
Product item = repository.Get(id);
if (item == null) return NotFound();
return Ok(repository.Patch(id, product));
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
Product item = repository.Get(id);
if (item == null) return NotFound();
repository.Delete(id);
return NoContent();
} |
There are several possibilities, including: You could return a bool (or enum) indicating the result of the operation: private bool CheckProductExists(int id)
{
Product item = repository.Get(id);
return item == null;
} And then in the controller convert true/false into an HTTP response. Or you could have the helper method return an IActionResult: private IActionResult CheckProductExists(int id)
{
Product item = repository.Get(id);
if (item == null)
{
return NotFound(...);
}
return null;
} And then the controller code would check the return value, and if it's non-null, return it (or else continue). |
@Eilon your suggestion: private IActionResult CheckProductExists(int id)
{
Product item = repository.Get(id);
if (item == null) return NotFound(...);
return null;
}
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] Product product)
{
var action = CheckProductExists(id);
if (action != null) return action;
return Ok(repository.Update(id, product));
}
[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] Product product)
{
var action = CheckProductExists(id);
if (action != null) return action;
return Ok(repository.Patch(id, product));
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var action = CheckProductExists(id);
if (action != null) return action;
repository.Delete(id);
return NoContent();
} |
@tkrotoff, Errors are "exceptional cases" for a "do computations" module but are not "exceptional cases" for an error handling module, since in this case they are just the module "standard job". Now controllers are not just pipes connecting business layer with the client, but they are responsible for the overall clent-server communication protocol. This means dealing with all possible outcomes coming from the business layer. For a Controller, handling a "record not found" is no way and "exceptional case", on the contrary it is exactly its "standard job". Thus, inside a controller errors and other "side cases" should be handled with adequate return values, not with exception or by "returning" immediately an answer to the client. The decision of when and what to return to the client should be left to the top method that answered the request and that is in charge for handling the client-server protocol, not to an helper method! In fact, during your system maintenance you might add an action method that handles with "batches" of entities simultaneously with a more complex server-client protocol. In this case you don't need a "NotFound" result but, maybe, putting this information in a more complex answer. |
@tkrotoff @Eilon This is an example
|
Filters can certainly do some of this, but if the logic is more complex, filters can be a challenge. For example, if the filter needs to run "in the middle" of some more complex validation process, it's difficult to orchestrate that. |
Both HttpExceptions, and Filters are attempts to avoid writing each time the repetitive standard code that handles the client-server communication protocol. Exceptions do it by relying on a standard protocol you cannot modify on the controller side, while Filters factor out some logics, but, in general, they cannot factor out the whole protocol since they act just on the start/end of controller processing. The right way to factor out the whole protocol code is by defining an abstract controller that implements the protocol one might inherit from. Such an abstract controller should contain Generics to adapt to several types, and standard action methods+all standard methods needed to implement the protocol. The inheriting controller might customize the behavior to adapt it to its business logics by overriding some protected abstract methods exposed to plug-in business logics. Abstracting the controller protocol in WebApi apps is beneficial also if you don't reuse the code, since you concentrate on the protocol instead of your app, thus writing a better protocol that handles are situations. |
Thx for all your answers 👍 |
In the end, I'm using filters to make sure REST API (action) parameters are passed using
This somewhat lowers the Null References: The Billion Dollar Mistake that C# suffers. Then if a parameter value is wrong I call // [ValidateActionParameters]
// class ProductsController
private Product Product_GetWithCheck(int id)
{
Product item = repository.Get(id);
if (item == null) ModelState.AddModelError(nameof(id), $"Bad id: '{id}'");
return item;
}
[HttpGet("{id}")]
public IActionResult Get([Required] int id)
{
Product item = Product_GetWithCheck(id);
if (!ModelState.IsValid) return BadRequest(ModelState);
return Ok(item);
}
[HttpPut("{id}")]
public IActionResult Update([Required] int id, [Required] [FromBody] Product product)
{
Product item = Product_GetWithCheck(id);
if (!ModelState.IsValid) return BadRequest(ModelState);
return Ok(repository.Update(id, product));
}
[HttpPatch("{id}")]
public IActionResult Patch([Required] int id, [Required] [FromBody] Product product)
{
Product item = Product_GetWithCheck(id);
if (!ModelState.IsValid) return BadRequest(ModelState);
return Ok(repository.Patch(id, product));
}
[HttpDelete("{id}")]
public IActionResult Delete([Required] int id)
{
Product item = Product_GetWithCheck(id);
if (!ModelState.IsValid) return BadRequest(ModelState);
repository.Delete(id);
return NoContent();
}
More real life example: [ValidateActionParameters]
public class SlotsController : Controller
{
private User User_FindByIdWithCheck(string userId)
{
var user = _usersRepository.FindById(userId);
if (user == null) ModelState.AddModelError(nameof(userId), $"Couldn't find user '{userId}'");
return user;
}
[HttpGet("{userId}/Slots")]
public IActionResult Slots([Required] string userId,
[Required] DateTime start, [Required] DateTime end)
{
var user = User_FindByIdWithCheck(userId);
if (start > end) ModelState.AddModelError(nameof(start), $"'{start}' should be inferior to '{end}'");
if (!ModelState.IsValid) return BadRequest(ModelState);
...
return Ok(...);
}
} |
I think this can be closed now |
@Eilon - You wrote: "We felt that using exceptions for control flow is an anti-pattern (which is generally a commonly-held belief)." But the OP's examples do not use exceptions for control flow, they simply throw exceptions which is precisely the intent of the throw keyword! Using exceptions for flow control has nothing to do with throw but with catch and his examples do not do that. You also wrote: "It can make code more difficult to maintain. Exceptions blow right through all control-flow mechanisms aside from catch/finally." Does this mean you are advocating that developers are wrong to ever use throw? that it was a mistake to have included this in the language? if you don't mean that please explain? The real weakness I see in a huge amount of MVC code is that the patterns used are specific to MVC REST and thus weaken reuse and complicate testing. A far more helpful pattern is to to do as little as possible in the handlers, after all these are nothing more than HTTP over RPC - that's all that's going on most of the time. Then all called code can be designed as agnostic (agnostic with respect to how arguments are propagated) with a filter handling and re-throwing as necessary. If the (agnostic) methods throw so be it, that's the designers intent and that is an established design pattern, the fact that we may be calling this over HTTP should be a minor technicality. The basic fact is (which often gets lost in these discussions) is that REST is nothing more than a particular way of passing arguments over HTTP, the called code ideally should not be cluttered with code that's specific to how arguments have been supplied or over what medium. |
What bothers me about this is that we're not only throwing away .NET's well established design patterns around exceptions, we're also throwing away type safety. Returning an IActionResult is akin to returning an System.Object or Variant. We loose all of the type data needed by the compiler to ensure you are returning the correct thing. And needed by IDL generators such as RAML and Swagger to produce accurate response definitions. Which in turn are needed for client code generation. |
Huh? Indicating an error condition is EXACTLY what exceptions are designed for. |
Then don't do it? MVC lets you return whatever you want. Just change the return type. Easy. |
If you really want to throw exceptions and have them converted into responses, it's really easy to write a filter for it. Instead of complaining about it here, (in several issues now) you'd be done by now 😉 |
Yea, I could redo that for every project. Or it could be baked in as a standard feature like it should have been in the first place. |
But! Ideally, throwing an exception is best, but in a real project, it can also be thrown at the service or repository layer. (of course, it's possible to solve problems with conventions or with lint 😂) Finally, I really want to provide exception classes and handling filters (by configuration). In the next release. |
Before:
Now:
GetProduct
signature is not explicit anymore, you have to read its implementation to understand that it returns aProduct
.And there is a bigger problem: how do you easily factorize/reuse code? example:
The text was updated successfully, but these errors were encountered: