Stop exposing your MediatR command/query in minimal API
MediatR has become a popular tool for implementing the mediator pattern in .NET applications, allowing developers to decouple request handlers from their senders.
While MediatR offers numerous benefits, exposing its commands directly in APIs can lead to unexpected complications.
In this article, we'll explore the potential issues that arise when exposing MediatR commands and queries in your API endpoints, and provide practical solutions to mitigate these problems.
What happens when parameters are invalid?
Let's start with a common scenario: retrieving a goat using a GET request. Consider the following example:
app.MapGet("/goat/{id}", async (GetGoatQuery query, IMediator mediator) =>
{
var result = await mediator.Send(query);
return Results.Ok(result);
});
...
public record GetGoatQuery(Guid Id) : IRequest<GetGoatResult>;
public record GetGoatResult(Guid Id, string Name, string? Description);
At first glance, this implementation seems straightforward. However, let's examine what happens when we send a request with an invalid GUID:
[Fact]
public async Task ShouldReturnBadRequest()
{
var response = await _client.GetAsync("/goats/1");
response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
}
The test fails with the following error:
Expected response.StatusCode to be HttpStatusCode.OK {value: 200} because Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to bind parameter "Guid Id" from "1".
This error message is vague and doesn't provide much information about the underlying issue. Moreover, it doesn't reveal any application-specific details, which is generally good for security but not helpful for debugging.
What happens when body is invalid?
The problem becomes even more pronounced when dealing with POST requests and complex objects (it's the same for queries). Let's look at an example of creating a new goat:
app.MapPost("/goats", async ([FromBody] AddGoatCommand command, IMediator mediator) =>
{
var result = await mediator.Send(command);
return Results.Created(result.Id.ToString(), result);
});
...
public record Address(string Street, string City);
public record AddGoatCommand(string Name, DateTime BirthDate, Address Address) : IRequest<AddGoatResult>;
public record AddGoatResult(Guid Id);
Now, let's try to send a JSON request with an invalid date format:
var command = "{\"Name\":\"Goat\",\"BirthDate\":\"999-99-16T09:19:47.6898565+02:00\",\"Address\":{\"Street\":\"Street\",\"City\":\"City\"}}";
This results in a deserialization error on the AddGoatCommand
, which exposes two major issues:
- The error message reveals the namespace of the command
ExposeCommand.AddGoat.AddGoatCommand
, potentially exposing internal application structure. - The message is not user-friendly and fails to explain the specific error beyond a general deserialization problem.
We can see that error handling is far from correct for an API. Instead of providing context so that clients can understand the errors associated with their request, the server returns internal information that shouldn't be exposed.
Now that we've observed the behavior, we can ask ourselves what solutions there are to this problem.
And to begin with, we'll start with the false leads
False leads to handle requests errors
Using DataAnnotations to validate structure
The first approach that developers might consider is using DataAnnotations, such as existing [Required]
or selfmade as [DateTimeValidation]
in our case, to enforce rules on our objects. However, this solution falls short when dealing with the core issue at hand. Let's examine why:
public class AddGoatCommand : IRequest<AddGoatResult>
{
[Required]
public string Name { get; set; }
[DateTimeValidation]
public DateTime BirthDate { get; set; }
public Address Address { get; set; }
}
While this approach might seem promising at first glance, it's important to understand that DataAnnotations operate after the deserialization process.
This timing creates a critical issue: if the incoming JSON is malformed or contains invalid data types (like our earlier example with an incorrect date format), the deserialization will fail before the DataAnnotations can even be applied.
Implement custom error handling middleware
Another approach is to add middleware to hide the error and avoid giving information about the application. Here's an example of an ErrorHandlingMiddleware
:
public class ErrorHandlingMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext context)
{
try
{
await next(context);
}
catch (BadHttpRequestException ex)
{
// TODO: Manage exception here
}
catch (Exception ex)
{
// TODO: Manage interal error here
}
}
}
The error generated by ASP.NET is a BadHttpRequestException
. We can therefore catch it at a higher level in the application, then enter InnerException
to retrieve a more precise message about the error.
We could display The JSON value is not in a supported DateTime format
.
From a distance, this solution is far from ideal:
- It's still very generic in its messages, despite the fact that order-related information is hidden, which is already a plus.
- We don't solve the deserialization problem, which is still present, preventing us from providing more information.
- We delegate request management to middleware, which is too high-level in our application
Using API controller instead of minimal API
For developers using traditional controllers instead of minimal APIs, the ConfigureApiBehaviorOptions
method offers a powerful way to customize error responses:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.Select(e => new
{
Field = e.Key,
Errors = e.Value.Errors.Select(err =>
{
if (err.ErrorMessage.Contains("BirthDate"))
return "La date de naissance est invalide. Veuillez utiliser un format valide.";
return err.ErrorMessage;
})
}).ToArray();
return new BadRequestObjectResult(new
{
Message = "Validation failed",
Errors = errors
});
};
});
In this way, the API returns complete error messages regarding deserialization.
There's even an issue on the ASP.NET GitHub for integrating this functionality into minimal APIs.
But as with middleware, it's a bad idea to centralize error management outside of object construction. The example shows the creation of 2/3 routes, but in a real case with dozens or even hundreds of routes, management would become complicated.
Solution: make deserialization easier to realize
The key is to use types that are easily deserialized from JSON without any complex parsing. These types include:
string
bool
int
,long
,float
,double
,decimal
- Nullable versions of the above (
int?
,bool?
, etc.) - Arrays or lists of the above types
By doing so, we limit the risk of error by using only types that can be understood by all languages/users. You could even decide to go even further and accept only string
to be 100% certain of deserializing the request, which may be overkill here.
Instead of exposing complex commands directly, create input models with only simple types:
public record AddressInput(string? Street, string? City);
public record AddGoatInput(string? Name, string? BirthDate, AddressInput? Address);
By using this input model, we can be confident that deserialization will succeed in virtually all cases. Any required parsing or type conversion (such as converting the birth date string to a DateTime
) can then be handled explicitly in our application code, where we have full control over error handling and can provide meaningful, user-friendly error messages.
All that remains is to convert AddGoatInput
into AddGoatCommand
and return the associated errors. This method drastically simplifies the management of construction errors for objects exposed in the API.
Parse, don't validate : command construction
One solution, which I've already seen on projects, could be to transform the types into those of the command with a conversion, and then use an IPipelineBehavior
that only takes care of validating the command parameters.
This solution should be avoided, as it breaks the principle: Parse, don't validate.
This principle consists in verifying an object when it is created, and not afterwards.
The whole point is to be certain that when an object exists, it is necessarily valid, so you don't have to check it every time you use it.
To give a simple example, you probably have methods that take an string?
as a parameter, which you check X times to make sure it's not IsNullOrEmpty
. To avoid this, simply create an object that takes an string?
and checks the condition. If the parameter is null, then an exception would be thrown. This way, you can be sure that once your object is constructed, it contains a string
and not a string?
.
That's exactly what we're going to do here.
public record AddGoatCommand(string Name, DateTime BirthDate, Address Address) : IRequest<AddGoatResult>
{
public static AddGoatCommand From(AddGoatInput input)
{
if (!DateTime.TryParse(input.BirthDate, out var birthDate))
throw new InternalValidationException(nameof(BirthDate), $"{nameof(BirthDate)} is not valid.");
// Add more rules here, also for Address builder
// ....
var address = new Address(input.Address.Street, input.Address.City);
return new AddGoatCommand(input.Name, birthDate, address);
}
}
This implementation is just one of many, with Builder
or other. The important thing is to ensure that AddGoatCommand
is only built when it is valid.
For example, you could insert a validation on the age, which must be less than 100 and greater than 0.
This approach allows for custom error handling and validation:
app.MapPost("/goatswithinput", async ([FromBody] AddGoatInput input, IMediator mediator) =>
{
try
{
var command = AddGoatCommand.From(input);
var result = await mediator.Send(command);
return Results.Created(result.Id.ToString(), result);
}
catch (InternalValidationException e)
{
var error = new
{
e.Field,
Error = e.Message
};
return Results.BadRequest(new
{
Message = "Validation failed",
Errors = error
});
}
});
If you want to go further, the solution can be improved in several respects:
- Return a list of errors rather than a single error, to enable the return of one error per field, which simplifies the correction of the request on the client side.
- Add an error code to enable clients to manage message translations themselves.
- Use monads to remove the
try catch
that encompasses object construction - Use HTTP
ProblemDetails
object to harmonize return values - And I'm sure there are other improvements I can't think of
This approach provides detailed error messages while maintaining control over the exposed information.
The benefits of using input models and builder patterns include:
- Guaranteed deserialization of the request
- Customizable error messages and validation rules
- Ensuring that created commands are always valid objects (adhering to the "Parse, don't validate" principle)
Conclusion
By not exposing MediatR commands directly, we reduce the risk of leaking internal application structure. This makes it harder for potential attackers to gain insights into our system's architecture. Additionally, by controlling the error messages, we prevent the exposure of sensitive information that could be used in targeted attacks.
In conclusion, while MediatR simplifies the implementation of the mediator pattern, exposing its commands directly in APIs can lead to security vulnerabilities and poor user experience. By implementing input models with simple types, utilizing builder patterns for command creation, and customizing error handling, developers can create more robust, secure, and user-friendly APIs while still harnessing the power of MediatR.
Remember, the goal is not just to make your API work, but to make it resilient, secure, and maintainable in the long run. By following these practices, you'll be well on your way to creating APIs that stand the test of time and scale.
All the article's code is available on Git:
Have a goat day 🐐