.NET 11: Controllers finally get new features
.NET 11 is out, and Controllers have a new feature. One of the annoying things about Controllers is that every endpoint has to go through the middleware pipeline, whether that be authorisation, authentication or rate limiting. Usually this is fine, but it's not needed for a simple endpoint like a health check. But you can skip the middleware pipeline with the ShortCircuit attribute.
Short circuiting Controllers in .NET 11
I have a HealthController class with an endpoint that checks the status of each health check.
In .NET 11, you can add the ShortCircuit attribute. This can be added either to the Controller or to an individual method.
// HealthController.cs
[ApiController]
[Route("api/health")]
[ShortCircuit]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
public HealthController(HealthCheckService healthCheckService)
{
_healthCheckService = healthCheckService;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var report = await _healthCheckService.CheckHealthAsync(
cancellationToken);
var result = new
{
Status = report.Status.ToString(),
Checks = report.Entries.Select(entry => new
{
Name = entry.Key,
Status = entry.Value.Status.ToString()
})
};
return report.Status == HealthStatus.Unhealthy
? StatusCode(StatusCodes.Status503ServiceUnavailable, result)
: Ok(result);
}
}To prove that it worked, I've added some middleware to Program.cs and put a breakpoint inside it.
// Program.cs
app.Use(async (context, next) =>
{
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger(typeof(Program).FullName!);
logger.LogInformation("Middleware ran");
// Call the next delegate/middleware in the pipeline.
await next(context);
});Without the ShortCircuit attribute, calling Get in HealthController always goes through the middleware and the breakpoint is hit. With the attribute added, the breakpoint in the middleware doesn't get triggered, and the endpoint still returns a successful response.
Setting a default status code
You can also set a default status code for methods that don't return an IActionResult. All you need to do is add the status code as a parameter to the ShortCircuit attribute.
// HealthController.cs
[ApiController]
[Route("api/health")]
[ShortCircuit(204)]
public class HealthController : ControllerBase
{
...
[HttpGet("app-ready")]
public void AppReady()
{
}
}The AppReady method doesn't return anything, so calling it now returns a 204 response.
The benefits of short circuiting
Skipping parts of the middleware pipeline has a number of benefits:
It saves CPU and memory
Middleware can't interfere with the response
Health checks don't fill your request logs and telemetry
Union types in Controllers
.NET 11 comes with C# 15, which introduces union types. A union allows a value to be one of a fixed set of types, and union types are supported in Controllers.
I have two records. One is for a card payment.
// CardPaymentDto.cs
public record CardPaymentDto(string Last4, decimal Amount);The other is for a bank transfer.
// BankTransferDto.cs
public record BankTransferDto(string Reference, decimal Amount);The PaymentDto union means that a value can be either a CardPaymentDto or a BankTransferDto.
// PaymentDto.cs
public readonly union PaymentDto(CardPaymentDto, BankTransferDto);In the Controller, the endpoint returns the PaymentDto union type. If the id is 1, it returns CardPaymentDto. Otherwise, it returns BankTransferDto.
// PaymentsController.cs
[Route("api/payments")]
[ApiController]
public class PaymentsController : ControllerBase
{
[HttpGet("{id:int}")]
public ActionResult<PaymentDto> Get(int id)
{
PaymentDto payment = id == 1
? new CardPaymentDto("4242", 49.99m)
: new BankTransferDto("RTC-1001", 120.00m);
return payment;
}
}How union types appear in the API documentation
One of the downsides with this feature is the way it generates the example value in the API documentation. The example value uses the properties from CardPaymentDto .
{
"last4": "string",
"amount": 0.1
}However, BankTransferDto is also part of the union and has completely different properties
I can only assume that it reads the properties from the first type added to the union. I'm not sure what the fix is, because the endpoint could return either a card payment or a bank transfer.
Get notified of new content
Join other .NET developers improving their skills and knowledge.
Controllers are catching up with Minimal APIs
These are good features for Controllers, but they are catching up with Minimal APIs. The short circuit feature has been in Minimal APIs for the last three years.
Microsoft now recommends using Minimal APIs if you're building a Web API. The thing I like about them is that you don't have to register nearly as many services as you do with Controllers.
Union types in Minimal APIs
Union types are also supported in Minimal APIs and work in the same way as Controllers. The GetPayment endpoint returns the PaymentDto type. If the id is 1, it returns a card payment. Otherwise, it returns a bank transfer.
// PaymentsEndpoints.cs
public static class PaymentsEndpoints
{
public static IEndpointRouteBuilder MapPaymentsEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/payments");
group.MapGet("{id:int}", GetPayment);
return app;
}
public static PaymentDto GetPayment(int id)
{
PaymentDto payment = id == 1
? new CardPaymentDto("4242", 49.99m)
: new BankTransferDto("RTC-1001", 120.00m);
return payment;
}
}Calling GetPayment with an id of 1 returns the card payment details, and calling the same endpoint with an id of 2 returns the bank transfer details.
Async validation in Minimal APIs
If you've ever validated your API requests with data annotations, you'll know there's a big feature missing. You can't make asynchronous calls. To get around this, you had to use third party libraries like FluentValidation.
Minimal APIs in .NET 11 finally allows you to make asynchronous calls with the AsyncValidationAttribute class.
I've created a custom validation attribute that inherits AsyncValidationAttribute and overrides the IsValidAsync method. Inside it, there is a check to see if the email already exists, which involves an asynchronous call. The synchronous IsValid method also has to be overridden, but it throws an exception to make sure this attribute is only validated asynchronously.
// UniqueEmailAttribute.cs
public class UniqueEmailAttribute : AsyncValidationAttribute
{
protected override async Task<ValidationResult?> IsValidAsync(
object? value,
ValidationContext context,
CancellationToken cancellationToken)
{
var userService = context.GetRequiredService<IUserService>();
if (value is string email
&& await userService.EmailExistsAsync(email, cancellationToken))
{
return new ValidationResult("That email is already registered.");
}
return ValidationResult.Success;
}
protected override ValidationResult? IsValid
(object? value, ValidationContext context) =>
throw new InvalidOperationException(
"Validate this attribute with IsValidAsync.");
}The attribute is registered against the property you wish to validate.
// RegisterEmailDto.cs
public class RegisterEmailDto
{
[Required, EmailAddress, UniqueEmail]
public string Email { get; set; } = string.Empty;
}This class is then used as the request in the Minimal API endpoint.
// UsersEndpoints.cs
public static class UsersEndpoints
{
public static IEndpointRouteBuilder MapUsersEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/users");
group.MapPost("/register", Register);
return app;
}
public static Created Register(RegisterEmailDto request) =>
TypedResults.Created($"/users/{request.Email}");
}Validation for Minimal APIs is enabled by calling builder.Services.AddValidation() in Program.cs. When I post an email address that has already been registered to the Register endpoint, it returns a 400 response saying that the email is already registered. This means the request is going through the new async validation attribute.
Does async validation work with Controllers?
If you're hoping that this will work with Controllers, I'm afraid I've got bad news. I used the same request class in a controller.
[Route("api/v1/users")]
[ApiController]
public class UsersController : ControllerBase
{
[HttpPost("register")]
public Created Register(RegisterEmailDto request)
{
return TypedResults.Created($"/users/{request.Email}");
}
}When I tried it, it executed the IsValid method in UniqueEmailAttribute, and that isn't asynchronous. This is another reason why you should be using Minimal APIs.
Updating to .NET 11
Updating to .NET 11 should be easy. You have to:
Install the .NET 11 SDK onto your machine
Update
TargetFrameworktonet11.0in your .csproj fileUpdate the NuGet packages
Update the .NET version in your Dockerfile
If you're hosting your application on a web server such as IIS, you'll also need to install the .NET 11 runtime onto it.
But it's rarely that simple. Whenever I update an application, I always run into problems. Quite often when updating NuGet packages, I'm referencing code that no longer exists, and as a result, the application won't compile.
Should you update to .NET 11?
It's worth thinking about whether it's worth updating to .NET 11. It's a short term release, so it only has a two year support window. Support for both .NET 10 and .NET 11 ends in November 2028, so if you update to .NET 11, you're effectively going to have to do another update anyway.
Given how time consuming it can be to update an application, I'm going to stick with .NET 10 for my existing applications. But if I need to create a new application, I will use .NET 11 and take advantage of the new features.
Entity Framework Core updates
There are also some updates to Entity Framework Core.
Create and apply a migration in one step
Previously, adding a migration meant running two commands. One to add the migration and one to update the database. Now you can combine the two.
In Visual Studio's Package Manager Console, you call Update-Database, add the -Migration parameter with the name of the migration, and add the -Add parameter.
Update-Database -Migration AddProducts -AddThis adds the migration and updates the database. If you don't use Visual Studio, you can do the same with the .NET CLI.
dotnet ef database update AddProducts --addI'm not sure I would use this feature. When I add a migration, I like to review it before updating the database.
MinBy and MaxBy support
The MinBy and MaxBy LINQ methods are now supported in Entity Framework Core for .NET 11. They return the item with the lowest or highest value, rather than just the value itself.
To get the product with the lowest price, you call MinByAsync and pass in the price in the expression.
var product = await _context.Products
.Select(p => new { p.Id, p.Name, p.Price })
.MinByAsync(x => x.Price);
return product is not null
? new ProductDto(
product.Id, product.Name, product.Price)
: null;Getting the product with the highest price works in exactly the same way. You just change the method name to MaxByAsync.
var product = await _context.Products
.Select(p => new { p.Id, p.Name, p.Price })
.MaxByAsync(x => x.Price);
return product is not null
? new ProductDto(
product.Id, product.Name, product.Price)
: null;Watch the video
Watch the video where I go through the best new features in .NET 11 for Controllers, Minimal APIs and Entity Framework Core, and show you how to update your application.
Related tutorials
.NET 8 allows for a faster way to validate the option values in appsettings.json in an ASP.NET Core Web API using data annotations.
Primary constructors is a C# 12 feature that allows to add parameters to a class and includes dependency injection support.