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:

curl http://localhost:5025 result

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:

curl http://localhost:5025 dummy weather forecast

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&current=temperature_2m"

And you should see a response that looks something like this:

curl Open-Meteo response snippet

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}&current=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:

Hello world from a minimal API in an ASP.NET MVC project

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:

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.