Skip to content

Minimal API

Note: Requires .NET 6+

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IService, Service>();
builder.Services.AddScoped<IService, Service>();
builder.Services.AddTransient<IService, Service>();

var app = builder.Build();

// [...]

app.Run();
//or
app.RunAsync();

Application Settings

App settings are loaded (in order) from:

  1. appsettings.json
  2. appsettings.<Environment>.json
  3. User Secrets

The environment is controlled by the env var ASPNETCORE_ENVIRONMENT. If a setting is present in multiple locations, the last one is used and overrides the previous ones.

User Secrets

User secrets are specific to each machine and can be initialized with dotnet user-secrets init. Each application is linked with it's settings by a guid.

The settings are stored in:

  • %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json (Windows)
  • ~/.microsoft/usersecrets/<user_secrets_id>/secrets.json (Linux/macOS)

Setting a value is done with dotnet user-secrets set <key> <value>, keys can be nested by separating each level with : or __.

Swagger

C#
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// [...]

app.UseSwagger();
app.UseSwaggerUI();

// add returned content metadata to Swagger
app.MapGet("/route", Handler).Produces<Type>(statusCode);

// add request body contents metadata to Swagger
app.MapPost("/route", Handler).Accepts<Type>(contentType);

MVC

C#
builder.Services.AddControllersWithViews();
//or
builder.Services.AddControllers();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    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.MapControllers();
// or
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

Routing, Handlers & Results

To define routes and handlers using Minimal APIs, use the Map(Get|Post|Put|Delete) methods.

C#
1
2
3
4
5
6
7
8
9
// the dependencies are passed as parameters in the handler delegate
app.MapGet("/route/{id}", (IService service, int id) => {

    return entity is not null ? Results.Ok(entity) : Results.NotFound();
});

// pass delegate to use default values
app.MapGet("/search/{id}", Search);
IResult Search(int id, int? page = 1, int? pageSize = 10) { /* ... */ }

Route Groups

The MapGroup() extension method, which helps organize groups of endpoints with a common prefix.
It allows for customizing entire groups of endpoints with a singe call to methods like RequireAuthorization() and WithMetadata().

C#
1
2
3
4
5
6
var group = app.MapGroup("<route-prefix>");

group.MapGet("/", GetAllTodos);  // route: /<route-prefix>
group.MapGet("/{id}", GetTodo);  // route: /<route-prefix>/{id}

// [...]

TypedResults

The Microsoft.AspNetCore.Http.TypedResults static class is the “typed” equivalent of the existing Microsoft.AspNetCore.Http.Results class.
It's possible to use TypedResults in minimal APIs to create instances of the in-framework IResult-implementing types and preserve the concrete type information.

C#
1
2
3
4
public static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}
C#
[Fact]
public async Task GetAllTodos_ReturnsOkOfObjectResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check the returned result type is correct
    Assert.IsType<Ok<Todo[]>>(result);
}

Multiple Result Types

The Results<TResult1, TResult2, TResultN> generic union types, along with the TypesResults class, can be used to declare that a route handler returns multiple IResult-implementing concrete types.

C#
1
2
3
4
5
6
7
// Declare that the lambda returns multiple IResult types
app.MapGet("/todos/{id}", async Results<Ok<Todo>, NotFound> (int id, TodoDb db)
{
    return await db.Todos.FindAsync(id) is Todo todo
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

Filters

C#
public class ExampleFilter : IRouteHandlerFilter
{
    public async ValueTask<object?> InvokeAsync(RouteHandlerInvocationContext context, RouteHandlerFilterDelegate next)
    {
        // before endpoint call
        var result = next(context);
        /// after endpoint call
        return result;
    }
}
C#
app.MapPost("/route", Handler).AddFilter<ExampleFilter>();

Context

With Minimal APIs it's possible to access the contextual information by passing one of the following types as a parameter to your handler delegate:

  • HttpContext
  • HttpRequest
  • HttpResponse
  • ClaimsPrincipal
  • CancellationToken (RequestAborted)
C#
1
2
3
app.MapGet("/hello", (ClaimsPrincipal user) => {
    return "Hello " + user.FindFirstValue("sub");
});

OpenAPI

The Microsoft.AspNetCore.OpenApi package exposes a WithOpenApi extension method that generates an OpenApiOperation derived from a given endpoint’s route handler and metadata.

C#
1
2
3
4
5
6
7
8
app.MapGet("/todos/{id}", (int id) => ...)
    .WithOpenApi();

app.MapGet("/todos/{id}", (int id) => ...)
    .WithOpenApi(operation => {
        operation.Summary = "Retrieve a Todo given its ID";
        operation.Parameters[0].AllowEmptyValue = false;
    });

Validation

Using Minimal Validation by Damian Edwards.
Alternatively it's possible to use Fluent Validation.

C#
app.MapPost("/widgets", (Widget widget) => {
    var isValid = MinimalValidation.TryValidate(widget, out var errors);

    if(isValid)
    {
        return Results.Created($"/widgets/{widget.Name}", widget);
    }

    return Results.BadRequest(errors);
});

class Widget
{
    [Required, MinLength(3)]
    public string? Name { get; set; }

    public override string? ToString() => Name;
}

JSON Serialization

C#
1
2
3
4
5
// Microsoft.AspNetCore.Http.Json.JsonOptions
builder.Services.Configure<JsonOptions>(opt =>
{
    opt.SerializerOptions.PropertyNamingPolicy = new SnakeCaseNamingPolicy();
});

Authorization

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();

builder.Services.AddAuthorization();
// or
builder.Services.AddAuthorization(options =>
{
    // for all endpoints
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
      .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
      .RequireAuthenticatedUser();
})
// or
builder.Authentication.AddJwtBearer();  // will automatically add required middlewares

// [...]

app.UseAuthentication();
app.UseAuthorization(); // must come before routes

// [...]

app.MapGet("/alcohol", () => Results.Ok()).RequireAuthorization("<policy>");  // on specific endpoints
app.MapGet("/free-for-all", () => Results.Ok()).AllowAnonymous();
app.MapGet("/special-secret", () => "This is a special secret!")
    .RequireAuthorization(p => p.RequireClaim("scope", "myapi:secrets"));

Local JWT Tokens

The user-jwts tool is similar in concept to the existing user-secrets tools, in that it can be used to manage values for the app that are only valid for the current user (the developer) on the current machine.
In fact, the user-jwts tool utilizes the user-secrets infrastructure to manage the key that the JWTs will be signed with, ensuring it’s stored safely in the user profile.

Bash
dotnet user-jwts create  # configure a dev JWT fot the current user

Output Caching

C#
builder.Services.AddOutputCaching();  // no special options
builder.Services.AddOutputCaching(options => 
{
    options => options.AddBasePolicy(x => x.NoCache())  // no cache policy

    Func<OutputCacheContext, bool> predicate = /* discriminate requests */
    options.AddBasePolicy(x => x.With(predicate).CachePolicy());
    options.AddBasePolicy("<policy-name>", x => x.CachePolicy());  // named policy
});

// [...]

app.UseOutputCaching();  // following middlewares can use output cache

// [...]

app.MapGet("/<route>", RouteHandler).CacheOutput();  // cache forever
app.MapGet("/<route>", RouteHandler).CacheOutput().Expire(timespan);

app.MapGet("/<route>", RouteHandler).CacheOutput(x => x.CachePolicy());
app.MapGet("/<route>", RouteHandler).CacheOutput("<policy-name>");

app.MapGet("/<route>", RouteHandler).CacheOutput(x => x.VaryByHeader(/* headers list */));
app.MapGet("/<route>", RouteHandler).CacheOutput(x => x.VaryByQuery(/* query key */));
app.MapGet("/<route>", RouteHandler).CacheOutput(x => x.VaryByValue());

app.MapGet("/<route>", [OutputCache(/* options */)]RouteHandler);

Cache Eviction

C#
1
2
3
4
5
6
app.MapGet("/<route-one>", RouteHandler).CacheOutput(x => x.Tag("<tag>"));  // tag cache portion

app.MapGet("/<route-two>", (IOutputCacheStore cache, CancellationToken token) => 
{
    await cache.EvictByTag("<tag>", token);  // invalidate a portion of the cache
});

Custom Cache Policy

C#
app.MapGet("/<route-one>", RouteHandler).CacheOutput(x => x.AddCachePolicy<CustomCachePolicy>());
C#
1
2
3
4
5
6
7
8
class CustomCachePolicy : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) { }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) { }

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) { }
}

Options Pattern

The options pattern uses classes to provide strongly-typed access to groups of related settings.

JSON
{
    "SecretKey": "Secret key value",
    "TransientFaultHandlingOptions": {
        "Enabled": true,
        "AutoRetryDelay": "00:00:07"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    }
}
C#
1
2
3
4
5
6
// options model for binding
public class TransientFaultHandlingOptions
{
    public bool Enabled { get; set; }
    public TimeSpan AutoRetryDelay { get; set; }
}
C#
1
2
3
// setup the options
builder.Services.Configure<TransientFaultHandlingOptions>(builder.Configuration.GetSection<TransientFaultHandlingOptions>(nameof(Options)));
builder.Services.Configure<TransientFaultHandlingOptions>(builder.Configuration.GetSection<TransientFaultHandlingOptions>(key));
C#
1
2
3
4
5
6
class DependsOnOptions
{
  private readonly IOptions<TransientFaultHandlingOptions> _options;

  public DependsOnOptions(IOptions<TransientFaultHandlingOptions> options) => _options = options;
}

Options interfaces

IOptions<TOptions>:

  • Does not support:
  • Reading of configuration data after the app has started.
  • Named options
  • Is registered as a Singleton and can be injected into any service lifetime.

IOptionsSnapshot<TOptions>:

  • Is useful in scenarios where options should be recomputed on every injection resolution, in scoped or transient lifetimes.
  • Is registered as Scoped and therefore cannot be injected into a Singleton service.
  • Supports named options

IOptionsMonitor<TOptions>:

  • Is used to retrieve options and manage options notifications for TOptions instances.
  • Is registered as a Singleton and can be injected into any service lifetime.
  • Supports:
  • Change notifications
  • Named options
  • Reloadable configuration
  • Selective options invalidation (IOptionsMonitorCache<TOptions>)