rejy11
rejy11

Reputation: 752

MediatR CQRS - How to deal with unexisting resources (asp.net core web api)

So I've recently started to learn about using the MediatR library with ASP.NET Core Web API and I'm unsure how to go about returning a NotFound() when a DELETE/PUT/PATCH request has been made for an unexisting resource.

If we take DELETE for example, here is my controller action:

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
    await Mediator.Send(new DeleteCourseCommand {Id = id});

    return NoContent();
}

The Command:

public class DeleteCourseCommand : IRequest
{
    public int Id { get; set; }
}

The Command Handler:

public class DeleteCourseCommandHandler : IRequestHandler<DeleteCourseCommand>
{
    private readonly UniversityDbContext _context;

    public DeleteCourseCommandHandler(UniversityDbContext context)
    {
        _context = context;
    }

    public async Task<Unit> Handle(DeleteCourseCommand request, CancellationToken cancellationToken)
    {
        var course = await _context.Courses.FirstOrDefaultAsync(c => c.Id == request.Id, cancellationToken);


        if (course != null)
        {
            _context.Courses.Remove(course);
            var saveResult = await _context.SaveChangesAsync(cancellationToken);
            if (saveResult <= 0)
            {
                throw new DeleteFailureException(nameof(course), request.Id, "Database save was not successful.");
            }
        }

        return Unit.Value;
    }
}

As you can see in the Handle method, if there is an error when saving, an exception is thrown which results in a 500 internal server error (which is correct I believe). But if the Course is not found, how can I feed this back to the Action on the Controller? Is it simply a case of invoking a Query to GET the course in the Controller Action, then return NotFound() if it doesn't exist or then invoke the Command to DELETE the Course? This would work of course but of all the examples I've been through, I haven't come across an Action which uses two Mediator calls.

Upvotes: 12

Views: 12736

Answers (3)

Todd Skelton
Todd Skelton

Reputation: 7239

I like returning events from my commands. The command is telling your application what the client wants it to do. The response is what it actually did.

BTW—it's said that command handlers should return anything. That's really only true in a fully async environment where the command won't be completed until sometime after the response to the client that it's accepted. In that case, you would return Task<Unit> and publish these events. The client would get them via some other channel, like a SignalR hub once they were raised. Either way, events are the best way to tell a client what's going on in your application.

Start by defining an interface for your events

public interface IEvent
{

}

Then, create events for each of the things that can happen in a command. You can include information in them if you'd want to do something with that information or just leave them empty if the class itself is enough.

public class CourseNotFoundEvent : IEvent
{

}

public class CourseDeletedEvent : IEvent
{

}

Now, have your command return an event interface.

public class DeleteCourseCommand : IRequest<IEvent>
{

}

Your handler would look something like this:

public class DeleteCourseCommandHandler : IRequestHandler<DeleteCourseCommand, IEvent>
{
    private readonly UniversityDbContext _context;

    public DeleteCourseCommandHandler(UniversityDbContext context)
    {
        _context = context;
    }

    public async Task<IEvent> Handle(DeleteCourseCommand request, CancellationToken cancellationToken)
    {
        var course = await _context.Courses.FirstOrDefaultAsync(c => c.Id == request.Id, cancellationToken);

        if (course is null) 
            return new CourseNotFoundEvent();

        _context.Courses.Remove(course);
        var saveResult = await _context.SaveChangesAsync(cancellationToken);
        if (saveResult <= 0)
        {
            throw new DeleteFailureException(nameof(course), request.Id, "Database save was not successful.");
        }

        return new CourseDeletedEvent();
    }
}

Finally, you can use pattern matching on your web API to do things based on the event that gets returned.

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
    var @event = await Mediator.Send(new DeleteCourseCommand {Id = id});

    if(@event is CourseNotFoundEvent)
        return NotFound();

    return NoContent();
}

Upvotes: 6

rejy11
rejy11

Reputation: 752

I managed to solve my problem through some more examples I found. The solution is to define custom Exceptions such as NotFoundException and then throw this in the Handle method of the Query/Command Handler. Then in order for MVC to handle this appropriately, an implementation of ExceptionFilterAttribute is needed to decide how each Exception is handled:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CustomExceptionFilterAttribute : ExceptionFilterAttribute
{
    public override void OnException(ExceptionContext context)
    {
        if (context.Exception is ValidationException)
        {
            context.HttpContext.Response.ContentType = "application/json";
            context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            context.Result = new JsonResult(
                ((ValidationException)context.Exception).Failures);

            return;
        }

        var code = HttpStatusCode.InternalServerError;

        if (context.Exception is NotFoundException)
        {
            code = HttpStatusCode.NotFound;
        }

        context.HttpContext.Response.ContentType = "application/json";
        context.HttpContext.Response.StatusCode = (int)code;
        context.Result = new JsonResult(new
        {
            error = new[] { context.Exception.Message }
        });
    }
}

Startup Class:

services.AddMvc(options => options.Filters.Add(typeof(CustomExceptionFilterAttribute)));

Custom Exception:

public class NotFoundException : Exception
{
    public NotFoundException(string entityName, int key)
        : base($"Entity {entityName} with primary key {key} was not found.")
    {   
    }
}

Then in the Handle method:

if (course != null)
{
    _context.Courses.Remove(course);
    var saveResult = await _context.SaveChangesAsync(cancellationToken);
    if (saveResult <= 0)
    {
        throw new DeleteFailureException(nameof(course), request.Id, "Database save was not successful.");
    }
}
else
{
    throw new NotFoundException(nameof(Course), request.Id);
}

return Unit.Value;

This seems to do the trick, if anyone can see any potential issues with this please let me know!

Upvotes: 3

Kirk Larkin
Kirk Larkin

Reputation: 93043

MediatR supports a Request/Response pattern, which allows you to return a response from your handler class. To use this approach, you can use the generic version of IRequest, like this:

public class DeleteCourseCommand : IRequest<bool>
    ...

In this case, we're stating that bool will be the response type. I'm using bool here for simplicity: I'd suggest using something more descriptive for your final implementation but bool suffices for explanation purposes.

Next, you can update your DeleteCourseCommandHandler to use this new response type, like this:

public class DeleteCourseCommandHandler : IRequestHandler<DeleteCourseCommand, bool>
{
    ...

    public async Task<bool> Handle(DeleteCourseCommand request, CancellationToken cancellationToken)
    {
        var course = ...

        if (course == null)
            return false; // Simple example, where false means it wasn't found.

        ...

        return true;
    }
}

The IRequestHandler being implemented now has two generic types, the command and the response. This requires updating the signature of Handle to return a bool instead of Unit (in your question, Unit isn't being used).

Finally, you'll need to update your Delete action to use the new response type, like this:

public async Task<IActionResult> Delete(int id)
{
    var courseWasFound = await Mediator.Send(new DeleteCourseCommand {Id = id});

    if (!courseWasFound)
        return NotFound();

    return NoContent();
}

Upvotes: 7

Related Questions