Move away from null checks and .SingleOrDefault checks in controllers/handlers and instead rely on validating this information up front
See original GitHub issue@BillWagner @MisterJames @tonysurma @sanilpaul @stevejgordon @VishalMadhvani
This is an overall pattern I’ve noticed in allReady, where every time we go to query something, we’re always allowing the return of nulls and then building subsequent conditionals to handle those nulls in consuming code. This additional overhead of checking for nulls carries into our unit tests, and makes our classes do more work than they have to.
For an example, let’s take the following files:
- AllReady.Areas.Admin.Controllers.CampaignController
- CampaignSummaryQueryHandlerAsync
- associated unit tests
When CampaignController sends CampaignSummaryQueryHandlerAsync, the user is requesting an edit action method which will return to them a screen where they can change information about the Campaign:
Here is the controller code:
public async Task<IActionResult> Edit(int id)
{
var viewModel = await _mediator.SendAsync(new CampaignSummaryQueryAsync { CampaignId = id });
if (viewModel == null)
{
return NotFound();
}
...
Here is the handler code:
public async Task<CampaignSummaryViewModel> Handle(CampaignSummaryQueryAsync message)
{
CampaignSummaryViewModel result = null;
var campaign = await _context.Campaigns
.AsNoTracking()
.Include(ci => ci.CampaignImpact)
.Include(mt => mt.ManagingOrganization)
.Include(l => l.Location)
.Include(c => c.CampaignContacts).ThenInclude(tc => tc.Contact)
.SingleOrDefaultAsync(c => c.Id == message.CampaignId)
.ConfigureAwait(false);
...
When, under any circumstance, would we not be able to find the Campaign we want to edit by id? How is this id provided? It’s provided by a previous query/action method on the controller.
The way that campaign got into the system is we had a page that allowed a user to create a campaign, and validated everything up front before the creation was allowed. Basically, the UI enforces this. The Campaign should be there when we go to list all Campaigns, and it should also be there when a user picks a Campaign from this list to edit.
Sure, you could cobble together a URL like this:
\Admin\Campaign\Edit\5
and paste it into the address bar of your browser and hit enter, but if someone is doing that, they’re most likely up to no good. So why are we optimizing the user experience to be friendly (we’re going to return a nice NotFound()
result) for someone is most likely up to no good?
The answer is we shouldn’t. We should be optimizing for the happy path.
That being said, here is an example of additional unit tests we’ve been adding to the system to test these null returns:
public class CampaignAdminControllerTests
{
[Fact]
public async Task EditGetReturnsHttpNotFoundResultWhenViewModelIsNull()
{
CampaignController sut;
MockMediatorCampaignSummaryQuery(out sut);
Assert.IsType<NotFoundResult>(await sut.Edit(It.IsAny<int>()));
}
}
I’d like to see a fundamental shift if this practice to optimize for the happy path. The chances of a user going to edit a Campaign that they just loaded 3 seconds ago from and Index action method being missing is extremely low. The only way I could see that happening is if some other user deleted the same campaign while they were in middle of their workflow, and again, I see this being very low.
Thoughts?
Issue Analytics
- State:
- Created 7 years ago
- Comments:12 (6 by maintainers)
Top GitHub Comments
@hodorswit, thanks for the input.
cc @stevejgordon @tonysurma @shahiddev @BillWagner @MisterJames
This is something we definitely can deal with using ActionFilters and ExceptionFilters, but like you said, if might not be apparent to new developers that this “magic” is happening in the filters, event though most .net developers are familiar with the framework-supplied filters that we see decorated controller classes and action methods.
That being said, one of the goals of the project (partially b/c it’s open source), is to optimize for the new contributor “F5” experience (where one can pull, then stand up the system and start running it immediately) as well as keep the cognitive load as low as possible to enable people that want to contribute at a very shallow level (for example, writing unit tests), or go deep (for example, invoking web-based API’s, figuring out the contract, wrapping it in an abstraction, etc…)…
The more and more I think about this Issue, I know believe it is tied to Issue #873, which is our ongoing discussion of what logging framework to use in production.
It seems that an Azure-based logging might when out over my proposal of ELMAH.IO, b/c it has native support for the application insights and telemetry that .NET Core provides.
But either way, regardless of the logging platform (ELMAH.IO/Azure logging), that logging framework automatically should be able to take exceptions that are not handled, log them for you, allow you define a compensating action (in production, re-direct to generic error page).
On the back-end, the logging framework should have built in dashboards as well as hooks for email/sms notifications to go out letting people know something went wrong, so in a way.
So, in summary, a mature logging framework already accomplishes what Action and Exception filters would by hand.
That being said, that does not rule out us taking the Action/Exception filter approach, as this approach still has it’s merits. We could even inject and ILogger<T> instance into these filters to be more explicit about exceptions we’d like to capture, what “level” of errors that are (info, warn, etc…) and how they should be logged.
@mgmccarthy My initial opinion is that I prefer the current approach. Yes it adds a bit of code, but ultimately we are coding for all possible cases and ensuring we return a valid and suitable response. I agree the scenarios which could lead to controllers being hit with invalid id’s should be very limited but it’s the web and it’s still a possibility.
In your example, what would we return instead of null from the handler in the case that an invalid id was provided (either intentionally or due to some unforeseen issue/bug elsewhere)? What does the controller do as it’s expecting something back from the handler. If for any reason we haven’t got something to give it, what will the controller return?
I’ve not found it particularly painful to code with the current style and it is then explicit in what’s expected to happen.