How to use Minimal APIs in .NET 8 without cluttering Program.cs
- Jul 5, 2024
Minimal APIs were first introduced in ASP.NET Core 6.0 and have been around for some time now. At a high level, Minimal APIs are a simplified way of building HTTP APIs with ASP.NET Core.
But why use Minimal APIs over the classic MVC / Controller workflow that we’re all familiar with?
- ASP.NET MVC is useful if you want to organise your code based on layers. Minimal APIs are useful if you would rather organise your code by features/slice, keeping related things together… but you can still keep a layered architecture with Minimal APIs if you prefer.
- MVC is opt-out, running an entire filter pipeline for each request, even if you don’t actually need all of it. When using Minimal APIs, you need to explicitly opt-in to validation, model binding, and any filters that you’d like to run on each request. Minimal API filters are also simpler, with the capability to do everything that MVC can.
- ASP.NET MVC’s convention based model can be difficult to understand and debug - it isn’t always clear exactly which routes would be discovered and configured via reflection, and getting this right can be error-prone especially early in the project. With Minimal API, routes are explicitly mapped, making Minimal API routes easier to understand and debug.
- Controllers can be harder to test, and tend to grow in complexity over time - I’ve seen controllers become a massive file with a equally massive constructor (upwards of 20 parameters); you’ll need to inject all those parameters when instantiating the class in a unit test, even if method you’re testing only uses one of those parameters…. leading to controllers sometimes not having test coverage.
- ASP.NET Core is moving toward native AOT compilation, which is not compatible with ASP.NET MVC.
Read on to find out how mitigate the most common concerns with adopting Minimal APIs.
The most common concern with Minimal Apis
One of the most common concerns I see is the idea that once your program starts to get larger, using Minimal APIs turn your Program.cs file into a jumbled mess. The fact that all endpoint mapping is performed directly within Program.cs
in the official tutorial, doesn’t help with this perspective.
But you don’t have to make your Program.cs
a jumbled mess.
Just like dependency injection mapping (often done via extension methods), Minimal APIs can be built up outside of Program.cs, and organised very differently to the official tutorial.
Consider the following in Program.cs
:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddWeatherForecastServices();
var app = builder.Build();
WeatherForecastEndpoints.Map(app);
app.Run();
and in WeatherForecastEndpoints.cs
(perhaps in a feature folder, or in a more complex project, an entirely different assembly i.e. WeatherForecasts.csproj
):
public static class WeatherForecastEndpoints {
public static void Map(WebApplication app) {
app.MapGet("api/weather", GetForecast)
}
public static async Task<Results<Ok, NotFound>> GetForecast(
string location,
IForecastService forecastService)
{
var result = _forecastService.GetForecast(location);
return result != null ? TypedResults.Ok(result) : TypedResults.NotFound();
}
}
Read on for a tutorial which organises code in a kind of Vertical slice architecture - slices rather than layers.
In case you prefer organising your code differently, or extending the sample further, there’s nothing stopping you from using Hexagonal Architecture / Ports and Adapters, the classic Layered Architecture, or whatever way of organising the project that you’d like.
Starting a Minimal API project from scratch
Make sure that you have .NET 8 installed then create a new Minimal API project via the shell:
dotnet new sln -o MinimalApiSample
cd MinimalApiSample
dotnet new web -n MinimalApiSample.Api
dotnet add MinimalApiSample.Api package System.Text.Json
dotnet add MinimalApiSample.Api package Swashbuckle.AspNetCore
dotnet add MinimalApiSample.Api package Microsoft.AspNetCore.OpenApi
dotnet sln add ./MinimalApiSample.Api/MinimalApiSample.Api.csproj
dotnet new nunit -o MinimalApiSample.Tests
dotnet add MinimalApiSample.Tests/ reference MinimalApiSample.Api
dotnet add MinimalApiSample.Tests package Moq
dotnet add MinimalApiSample.Tests package RichardSzalay.MockHttp
dotnet add MinimalApiSample.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet sln add ./MinimalApiSample.Tests/MinimalApiSample.Tests.csproj
For this article, we’ll run on port 5025
. Open launchSettings.json
in the MinimalApiSample.Api
project, and
replace it with the following:
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5025",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
If you open up Program.cs
in the MinimalApiSample.Api
project, you’ll see something like this:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Run the MinimalApiSample.Api
project:
dotnet watch --project MinimalApiSample.Api
Then run curl http://localhost:5025
to ensure that the project works as expected. You should see a result which looks like:
Adding a basic Minimal API GET endpoint
Create a new folder in the MinimalApiSample.Api
project called WeatherForecasts
. This will be our vertical slice folder to contain the things related to fetching weather forecasts.
Create a WeatherForecastResult.cs
file inside the WeatherForecasts
folder. We’ll use WeatherForecastResult as our API response type:
namespace MinimalApiSample.Api.WeatherForecasts;
public class WeatherForecastResult
{
public DateTime Time { get; set; }
public double TemperatureC { get; set; }
}
Create a WeatherForecastEndpoints.cs
file inside the WeatherForecasts
folder:
using Microsoft.AspNetCore.Http.HttpResults;
namespace MinimalApiSample.Api.WeatherForecasts;
public static class WeatherForecastEndpoints
{
public static void AddWeatherForecastEndpoints(this WebApplication app)
{
app.MapGet("/", GetForecast);
}
public static async Task<Results<Ok<WeatherForecastResult>, ProblemHttpResult>> GetForecast()
{
var result = new WeatherForecastResult()
{
Time = DateTime.Now,
TemperatureC = 25,
};
return TypedResults.Ok(result);
}
}
Update Program.cs
:
using MinimalApiSample.Api.WeatherForecasts;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.AddWeatherForecastEndpoints();
app.Run();
Restart MinimalApiSample.Api
, then run curl http://localhost:5025
to see the latest result:
Dependency Injection and Parameter Binding
We’ll use the built-in dependency injection framework for this step. As your project grows, you may want to consider some alternatives such as Scrutor which supports assembly scanning, or Simple Injector which overcomes some of the deficiencies of the built-in ASP.NET Core DI Abstraction
To fetch weather forecast data for this tutorial, we’ll use the Open-Meteo Weather Forecast API.
To verify that the API is accessible, and works as expected the following CURL command to ensure that you’re able to successfully fetch data from the API:
curl "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=temperature_2m"
And you should see a response that looks something like this:
Once you have verified that the Open-Meteo API is working as expected, create a file called WeatherForecastService.cs
inside the WeatherForecasts
folder:
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MinimalApiSample.Api.WeatherForecasts;
public class WeatherForecastService(IHttpClientFactory httpClientFactory)
{
public class OpenMeteoForecastResponse
{
[JsonPropertyName("longitude")]
public double Longitude { get; set; }
[JsonPropertyName("latitude")]
public double Latitude { get; set; }
[JsonPropertyName("current")]
public OpenMeteoForecastCurrent Current { get; set; }
}
public class OpenMeteoForecastCurrent
{
[JsonPropertyName("temperature_2m")]
public double Temperature { get; set; }
}
public async Task<OpenMeteoForecastResponse> GetForecast(double latitude, double longitude)
{
var httpRequestMessage = new HttpRequestMessage(
HttpMethod.Get,
BuildUrl(latitude, longitude)
);
var httpClient = httpClientFactory.CreateClient("WeatherForecast");
var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage);
if (!httpResponseMessage.IsSuccessStatusCode)
{
throw new Exception("Failed to get forecast");
}
await using var response = await httpResponseMessage.Content.ReadAsStreamAsync();
var forecastResponse = await JsonSerializer.DeserializeAsync<OpenMeteoForecastResponse>(response);
if (forecastResponse == null)
{
throw new Exception("Error deserializing forecast response");
}
return forecastResponse;
}
public static Uri BuildUrl(double latitude, double longitude)
{
return new Uri($"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m");
}
}
In WeatherForecastService, we’re using a primary constructor. A classic constructor will work just as well if you prefer.
Add a new file in the WeatherForecasts
folder called ServiceCollectionExtensions.cs
. Use this file to set up all dependency injection for your WeatherForecasts feature/slice:
namespace MinimalApiSample.Api.WeatherForecasts;
public static class ServiceCollectionExtensions
{
public static void AddWeatherForecastServices(this IServiceCollection services)
{
services.AddSingleton<WeatherForecastService>();
}
}
Update WeatherForecastEndpoints.cs
to start using WeatherForecastService:
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace MinimalApiSample.Api.WeatherForecasts;
public static class WeatherForecastEndpoints
{
public static void AddWeatherForecastEndpoints(this WebApplication app)
{
app.MapGet("/forecast", GetForecast);
}
public static async Task<Results<Ok<WeatherForecastResult>, ProblemHttpResult>> GetForecast(
double latitude,
double longitude,
WeatherForecastService weatherForecastService)
{
var forecast = await weatherForecastService.GetForecast(latitude, longitude);
return TypedResults.Ok(new WeatherForecastResult
{
TemperatureC = forecast.Current.Temperature
});
}
}
Note that WeatherForecastService is being passed as a parameter to GetForecast, and will automatically be wired up via dependency injection.
If you don’t like the implicit magicness of parameter binding and would prefer something more explicit, you can use the FromQuery attribute to bind latitude
and longitude
, then use FromServices attribute for weatherForecastService. For example:
public static async Task<Results<Ok<WeatherForecastResult>, ProblemHttpResult>> GetForecast(
[FromQuery(Name="latitude")] double latitude,
[FromQuery(Name="longitude")] double longitude,
[FromServices] WeatherForecastService weatherForecastService)
{
...
}
Update Program.cs
:
using MinimalApiSample.Api.WeatherForecasts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
builder.Services.AddWeatherForecastServices();
var app = builder.Build();
app.AddWeatherForecastEndpoints();
app.Run();
Testing Minimal API endpoints
Now let’s create a basic test for the weather forecast endpoint, mocking out the external dependency to the Open Meteo API.
I mentioned in the beginning of the blog post that controllers can be harder to test as the number of dependencies grows. Here’s an example of how to get the WeatherForecastEndpoints.GetForecast
endpoint method.
Note that unlike the old way of instantiating a controller, etc, we can just call the GetForecast
method, and pass
in relevant parameters.
using Microsoft.AspNetCore.Http.HttpResults;
using Moq;
using RichardSzalay.MockHttp;
using MinimalApiSample.Api.WeatherForecasts;
namespace MinimalApiSample.Tests;
public class WeatherForecastTests
{
private string sampleResponse =
"""
{
"generationtime_ms": 0.01704692840576172,
"utc_offset_seconds": 0,
"timezone": "GMT",
"timezone_abbreviation": "GMT",
"elevation": 38,
"current_units": {
"time": "iso8601",
"interval": "seconds",
"temperature_2m": "°C"
},
"current": {
"time": "2024-07-03T09:30",
"interval": 900,
"temperature_2m": 16.3
}
}
""";
[Test]
public async Task It_returns_the_expected_temperature()
{
var latitude = 22.4;
var longitude = 23.5;
// Arrange
// Mock external dependency to Open-Meteo Forecasts
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(WeatherForecastService.BuildUrl(latitude, longitude).ToString())
.Respond("application/json", sampleResponse);
// Mock CreateClient in IHttpClientFactory, and ensure it uses the mockHttp client
var clientFactory = new Mock<IHttpClientFactory>();
clientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(mockHttp.ToHttpClient());
// Act
var forecast = await WeatherForecastEndpoints.GetForecast(
latitude,
longitude,
new WeatherForecastService(clientFactory.Object)
);
// Assert
Assert.That(forecast.Result, Is.TypeOf<Ok<WeatherForecastResult>>());
var okResult = (Ok<WeatherForecastResult>)forecast.Result;
Assert.Multiple(() =>
{
Assert.That(okResult.Value, Is.Not.Null);
Assert.That(okResult.Value?.TemperatureC, Is.EqualTo(16.3));
});
}
}
The above sample is intended to illustrate just the basic happy path test case. There are lots more scenarios to test - in particular, the error scenarios, and as usual, consider testing WeatherForecastService in isolation, along with an integration test that runs against the actual API to make sure it actually works end-to-end.
More info in the official documentation: Unit and integration tests in Minimal API apps.
OpenAPI / Swagger via Swashbuckle.AspNetCore
First, make sure you’ve installed the Swashbuckle.AspNetCore
(for Swagger related extension methods) and Microsoft.AspNetCore.OpenApi
(for the WithOpenApi extension method) NuGet package into the project that contains Program.cs
.
Add Swagger to Program.cs
via:
using MinimalApiSample.Api.WeatherForecasts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpClient();
builder.Services.AddWeatherForecastServices();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.AddWeatherForecastEndpoints();
app.Run();
Then update the mapping in WeatherForecast/WeatherForecastEndpoints.cs
:
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
...
public static void AddWeatherForecastEndpoints(this WebApplication app)
{
app.MapGet("/forecast", GetForecast)
.WithName("GetWeatherForecast")
.WithOpenApi(x => new OpenApiOperation(x)
{
Summary = "Fetch the weather forecast for a given location"
});
}
...
Visit http://localhost:5025/swagger/index.html to view the swagger page, or run:
curl "http://localhost:5025/swagger/v1/swagger.json"
And you should see a valid OpenAPI Schema:
{
"openapi": "3.0.1",
"info": {
"title": "MinimalApiSample.Api",
"version": "1.0"
},
"paths": {
"/forecast": {
"get": {
"tags": [
"WeatherForecastEndpoints"
],
"summary": "Fetch the weather forecast for a given location",
"operationId": "GetWeatherForecast",
"parameters": [
{
"name": "latitude",
"in": "query",
"required": true,
"schema": {
"type": "number",
"format": "double"
}
},
{
"name": "longitude",
"in": "query",
"required": true,
"schema": {
"type": "number",
"format": "double"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WeatherForecastResult"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"WeatherForecastResult": {
"type": "object",
"properties": {
"TemperatureC": {
"type": "number",
"format": "double"
}
},
"additionalProperties": false
}
}
}
}
Note: Swashbuckle.AspNetCore is being removed from web api templates in .NET 9 in favor of a Microsoft implemented solution (NIH?). You can still use Swashbuckle.AspNetCore if you like, it just won’t be in the code generated by dotnet new webapi
… if you’d like an alternative, check out NSwag.
There’s lots more to learn in the official documentation: OpenAPI support in Minimal API apps.
Exception Handling
When using controllers, a common way to handle exceptions is to decorate your controllers with custom class that extends ExceptionFilterAttribute, and let exceptions thrown in your code bubble up to the controller. This allows you to always return a standardised response without having to handle exceptions per-action.
With Minimal APIs, you can handle this with exception handling middleware. In this case, we’ll configure exception handling middleware to returns RFC7807 (Problem Details) compliant error messages.
First, try calling the endpoint with an invalid parameter to see the existing behavior, and note that you receive a stack trace:
curl -i "http://localhost:5025/forecast?latitude=-91&longitude=0"
Latitude and longitude must be in the -90 to 90 range. Since 91 is out of range, the Open-Meteo API call has returned an error:
System.Exception: Failed to get forecast
at MinimalApiSample.Api.WeatherForecasts.WeatherForecastService.GetForecast(Double latitude, Double longitude) in C:\dev\test\WeatherForecast\MinimalApiSample.Api\WeatherForecasts\WeatherForecastService.cs:line 35
at MinimalApiSample.Api.WeatherForecasts.WeatherForecastEndpoints.GetForecast(Double latitude, Double longitude, WeatherForecastService weatherForecastService) in C:\dev\test\WeatherForecast\MinimalApiSample.Api\WeatherForecasts\WeatherForecastEndpoints.cs:line 20
at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
This is fine if we’re running in a development environment, but we don’t want to return detailed exception details to the client when our app runs in production. So how do you switch to the Problem Details format?
Update Program.cs
:
using MinimalApiSample.Api.WeatherForecasts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpClient();
builder.Services.AddWeatherForecastServices();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseExceptionHandler(applicationBuilder =>
{
applicationBuilder.Run(async context =>
{
await Results.Problem().ExecuteAsync(context);
});
});
app.AddWeatherForecastEndpoints();
app.Run();
Now, try calling the endpoint again with the same invalid latitude:
curl -i "http://localhost:5025/forecast?latitude=-91&longitude=0"
You should see an valid RFC7807 (Problem Details) compliant error message such as:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
If you’d like to return a more specific error, you can use TypedResults.Problem
:
public static async Task<Results<Ok<WeatherForecastResult>, ProblemHttpResult>> GetForecast(
double latitude,
double longitude,
WeatherForecastService weatherForecastService)
{
if (latitude < -90 || latitude > 90)
{
return TypedResults.Problem(
"The value for latitude is out of range. Must be between -90 and 90",
statusCode: StatusCodes.Status400BadRequest
);
}
var forecast = await weatherForecastService.GetForecast(latitude, longitude);
return TypedResults.Ok(new WeatherForecastResult
{
TemperatureC = forecast.Current.Temperature
});
}
Re-run the same curl command:
curl -i "http://localhost:5025/forecast?latitude=-91&longitude=0"
And you’ll see a much more descriptive error that looks like this:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Server: Kestrel
Transfer-Encoding: chunked
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "The value for latitude is out of range. Must be between -90 and 90"
}
If you’re interested in more specific error handling, take a look at the official documentation: How to handle errors in Minimal API apps.
There is also documentation on the various response types (including redirect, json, etc): How to create responses in Minimal API apps.
Migrating to Minimal APIs can be incremental
When it comes to using Minimal APIs, you don’t have to start from scratch - you can use them in your existing ASP.NET MVC / WebApi project. Here’s a basic example:
Create a new MVC project:
dotnet new mvc -n IncrementalMigration
Add a basic GET endpoint to the project’s Program.cs
via app.MapGet
:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapGet("/test", () => "Hello world from Minimal API");
app.Run();
Test the endpoint via CURL:
curl http://localhost:5113/test
You should see a response that looks like:
Want to read more?
Check out the official documentation on Minimal APIs, which will give you a much more in depth intro to Minimal APIs in .NET Core, or more specifically:
- Choose between controller-based APIs and Minimal APIs
- Minimal API quick reference
- Various ways that you can set up route handlers in Minimal APIs
- Exception handling in Minimal APIs
- OpenAPI support in Minimal API apps
- Reflection in AOT mode
Thanks for reading!
Enjoyed this article? Follow me on Twitter for more like this.
Do you have any questions, feedback, or anything that you think I’d be interested in? Please leave a comment below, or get in touch with me directly.