Create ASP.NET Core Minimal API from Start till Finish [.NET 10]

Create ASP.NET Core Minimal API from Start till Finish [.NET 10]

Minimal APIs are used to create HTTP APIs in a quick span of time with minimum dependencies. They are best suited for microservices and apps that require only minimum files with minimum dependencies. In ASP.NET Core Minimal APIs we do not use controllers instead all the API codes are written in the Program.cs file.

What are the advantages of minimal API? Minimal APIs are lightweight and have a fast execution as compared to traditional controller-based API. They can be built very quickly and easier for new developers to understand their codes.

ASP.NET Core Minimal API Project

In this article we are going to create a Minimal API Project from start and finish it by adding CRUD operations. This project will be called DailyWork and will be used to manage the daily works of people like us. These works will be stored in InMemory database and we will use Entity Framework Core to perform database operations.

This tutorial is a part of the ASP.NET Core Web API series which contains 5 tutorials to master this area:

The project will contain the following Minimal APIs:

API Description Request Body Response Body
GET /works Get all the works that need to be done None Array of works
GET /works/complete Get all completed works None Array of works
GET /works/{id} Get a work by its id None A work with a given id
POST /works Add a new work Work that needs to be created The newly created work
PUT /works/{id} Update an existing work Work that needs to be updated None
DELETE /works/{id} The work to be deleted None None

Open Visual Studio and then create a new app by selecting ASP.NET Core Empty template. We use empty template since Minimal API uses minimum files & dependencies.

ASP.NET Core empty template

Name the app as DailyWork.

We covered the controller-based API in our earlier article – Create Web APIS. Do check it since it covers a lot of things that are needed when building professional apps.

Database & Entity Framework Core

From the Tools ➤ NuGet Package Manager ➤ Manage NuGet Packages for Solution install the Microsoft.EntityFrameworkCore.InMemory package for the in-memory database for our Minimal API.

Microsoft.EntityFrameworkCore.InMemory

Now create a class called Work.cs with the following code:

public class Work
{
    public int Id { get; set; }
    
    public string Name { get; set; }

    public string TimeStart { get; set; }

    public string TimeEnd { get; set; }

    public bool IsComplete { get; set; }
}

This class will manage all the work of a person. For example the work id, name, whether it is complete or not, start and end time.

Next, create Database Context which will coordinate with Entity Framework Core and the database. Name this class WorkDB.cs and add the following code to it.

class WorkDb : DbContext
{
    public WorkDb(DbContextOptions<WorkDb> options)
		: base(options) { }

    public DbSet<Work> Works => Set<Work>();
}

Now the final thing is to register the Database Context on the Program.cs and specify the database name for the app. The following highlighted code needs to be added.

using DailyWork;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<WorkDb>(opt => opt.UseInMemoryDatabase("WorkDatabase"));

var app = builder.Build();

app.Run();
We have given WorkDatabase as the name for the database. You are free to name it anything. So far so good, if you still get any confusion kindly check the source codes, the download button is given at the bottom of this tutorial.

Create Minimal APIs

Now we start adding our Minimal API to manage daily works of any person. Note that the Minimal APIs should be added inside the “builder.Build” and “app.Run” in the Program.cs class.

var app = builder.Build();

// Minimal API code

app.Run();

Minimal API Post Type – Create Work

First, we need to add the API that will create a new work on the database. So, add the following code to your Program.cs class:

app.MapPost("/works", async (Work work, WorkDb db) =>
{
    db.Works.Add(work);
    await db.SaveChangesAsync();

    return Results.Created($"/works/{work.Id}", work);
});

This API is an HTTP POST type with endpoint /works. It implements MapPost to match POST type request. The parameters include a Work class object and a Database Context object, and saves them to the database using Entity Framework Core.

Testing POST Endpoint

We can test the API through Postman. In Postman set –

  1. HTTP method to POST.
  2. URI to https://localhost:<port>/works
  3. In Body tab – select raw and set type to JSON.

In the request body enter the JSON:

{
  "name":"eat breakfast",
  "isComplete":true,
  "timeStart":"8:00:00",
  "timeEnd":"8:30:00"
}

Finally click the Send button.

The API will be called and a new work will be created. The Postman will show the created work json, see the screenshot below. Since it is the first work so id number 1 is given to it.

ASP.NET Core Minimal API Post

Congrats, our Minimal API is created and working properly. You can clearly see how quickly we have set it up, it hardly took just 5 minutes time. This is the power of Minimal API which you should definitely use in your apps.

Minimal API GET Type – Read Works

Here we have to create 3 API of GET type that will read the works. These are:

  1. Read All Works
  2. Read Completed Works
  3. Read a Work by it’s id

Add these 3 API codes to your Program class.

app.MapGet("/works", async (WorkDb db) =>
	await db.Works.ToListAsync());

app.MapGet("/works/complete", async (WorkDb db) =>
	await db.Works.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/works/{id}", async (int id, WorkDb db) =>
	await db.Works.FindAsync(id)
		is Work work
			? Results.Ok(work)
			: Results.NotFound());

These 3 Minimal API implement MapGet method to match HTTP GET requests, however their endpoints are different. The API with /works endpoint simply returns all the works from the database.

The /works/complete API uses a “Where” condition to get all the completed works and returns them as a list in json format.

The remaining /works/{id} endpoint API, gets the work id in the URI itself and then uses FindAsync method to fetch it from the database.

Before we go to testing kindly pay attention that the app uses an in-memory database which gets destroyed when the app is restarted. The work you created earlier might be already lost so create it once again by calling the POST API again and then try the GET requests.
Testing GET Endpoints

First testing theGET /works Minimal API.

In Postman set –

  1. HTTP method to GET.
  2. URI to https://localhost:<port>/works
  3. Click Send button.

It produces a response similar to the following:

[
    {
        "id": 1,
        "name": "eat breakfast",
        "timeStart": "8:00:00",
        "timeEnd": "8:30:00",
        "isComplete": true
    }
]

See the postman image below.

HTTP GET Postman

Now testing theGET /works/complete Minimal API. Here we change the URI to https://localhost:<port>/works/complete, and click the Send button.

The output will show only the works that have the isComplete field “true”. Check this by adding a few works with isComplete field “false”. When you call the complete method, you will see only the completed works.

To test theGET /works{id} Minimal API.

In Postman set –

  1. HTTP method to GET.
  2. URI to https://localhost:<port>/works/1
  3. Click Send button.

It will give the following response.

{
    "id": 1,
    "name": "eat breakfast",
    "timeStart": "8:00:00",
    "timeEnd": "8:30:00",
    "isComplete": true
}

Minimal API PUT Type – Update a Work

Add the Update Minimal API which has a PUT endpoint /works/{id} which it implements using MapPut. The code is given below.

app.MapPut("/works/{id}", async (int id, Work work, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = work.Name;
    todo.TimeStart = work.TimeStart;
    todo.TimeEnd = work.TimeEnd;
    todo.IsComplete = work.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

A successful response returns 204 (No Content). The API requires the client to send the entire updated work in the request.

Note that a PUT request requires the client to send the entire updated entity, not just the changes. So we have added all the fields of the Work class inside the MapPut method.

Testing PUT Endpoint

In Postman set –

  1. HTTP method to PUT.
  2. URI to https://localhost:<port>/works/1
  3. In Body tab – select raw and set type to JSON.

In the request body enter the JSON:

{
  "id": 1,
  "name":"eat supper",
  "timeStart":"9:00:00",
  "timeEnd":"9:30:00",
  "isComplete":false
}

Note that we changed all the fields that is – name to “eat supper”, timeStart to “9:00:00”, timeEnd to “9:30:00” and isComplete to “false”.

Click the Send button to update this work.

Check Postman image below.

HTTP PUT Postman

Now make a new GET request to see the every field values are updated.

It is not necessary to change all the fields like what we did earlier. For example if you want to change only the name to “eat supper” and keep the remaining fields as they are then the json should be.

{
  "id": 1,
  "name":"eat supper",
  "isComplete":true,
  "timeStart":"8:00:00",
  "timeEnd":"8:30:00"
}

Note that the isComplete, timeStart and timeEnd field values are send the original ones (since we don’t want them to be changed).

Minimal API PATCH Type – Update a Work

HTTP PATCH method only updates the fields provided in the request that is it enables partial updates. So this means clients send only the fields that need to be changed. We can thus say that HTTP PATCH is lighter and faster that HTTP PUT and you should use it more often than the PUT.

For working with Patch create a new DTO called WorkDto.cs as shown below.

public class WorkDto
{
    public string Name { get; set; }

    public string TimeStart { get; set; }

    public string TimeEnd { get; set; }

    public bool? IsComplete { get; set; }
}

Next, to the Program.cs class add the HTTP PATCH code as given below.

app.MapPatch("/works/{id}", async (int id, WorkDto workDto, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    if (workDto.Name is not null) todo.Name = workDto.Name;
    if (workDto.IsComplete is not null) todo.IsComplete = workDto.IsComplete.Value;
    if (workDto.TimeStart is not null) todo.TimeStart = workDto.TimeStart;
    if (workDto.TimeEnd is not null) todo.TimeEnd = workDto.TimeEnd;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

The PATCH endpoint uses a WorkDto.cs with nullable properties to support partial updates correctly. Nullable properties make it possible to distinguish between fields that were omitted from the request (null) and fields that were intentionally provided with a value, including false for boolean properties. Without nullable types, a non-nullable bool would default to false, making it impossible to determine whether the client explicitly set the value or simply left the field out. This could unintentionally overwrite an existing true value during an update.

Strings fields are by default nullable in .NET so no need to make them nullables – like public string? Name { get; set; }.

Testing PATCH Endpoint

In Postman set –

  1. HTTP method to PATCH.
  2. URI to https://localhost:/works/1
  3. In Body tab – select raw and set type to JSON.

In the request body enter the JSON:

{
  "name": "study",
  "isComplete":false
}

Note that we are only sending 2 fields – name and isComplete in the json with the new values. So only these 2 fields will be updated by Patch. It is up to you how many fields you want to change with the patch method.

Click the Send button to update this work.

Check Postman image below.

HTTP PATCH Postman

Minimal API DELETE Type – Delete a Work

Finally we add the DELETE Minimal API that uses MapDelete method to delete a given work whose id is sent in the uri and returns status 204 on successful deletion. It’s code is shown below.

app.MapDelete("/works/{id}", async (int id, WorkDb db) =>
{
    if (await db.Works.FindAsync(id) is Work work)
    {
        db.Works.Remove(work);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }
    return Results.NotFound();
});
Testing DELETE Endpoint

In Postman set –

  1. HTTP method to DELETE.
  2. URI to https://localhost:<port>/works/1
  3. Click Send button.

This will delete the work with id 1 from the database.

MapGroup – grouping related endpoints

The MapGroup() method is used to group related endpoints under a common URL prefix and apply shared configurations. Thus allowing you to define routes efficiently, reduce code duplication, and manage security or metadata rules globally for that group.

This is how we can use MapGroup in Minimal API.

var workGroup = app.MapGroup("/works");

workGroup.MapPost("/", async (Work work, WorkDb db) =>
{
    db.Works.Add(work);
    await db.SaveChangesAsync();

    return Results.Created($"/works/{work.Id}", work);
});

workGroup.MapGet("/", async (WorkDb db) =>
    await db.Works.ToListAsync());

workGroup.MapGet("/complete", async (WorkDb db) =>
    await db.Works.Where(t => t.IsComplete).ToListAsync());

workGroup.MapGet("/{id}", async (int id, WorkDb db) =>
    await db.Works.FindAsync(id)
        is Work work
            ? Results.Ok(work)
            : Results.NotFound());

workGroup.MapPut("/{id}", async (int id, Work work, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = work.Name;
    todo.TimeStart = work.TimeStart;
    todo.TimeEnd = work.TimeEnd;
    todo.IsComplete = work.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

workGroup.MapPatch("/{id}", async (int id, WorkDto workDto, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    if (workDto.Name is not null) todo.Name = workDto.Name;
    if (workDto.IsComplete is not null) todo.IsComplete = workDto.IsComplete.Value;
    if (workDto.TimeStart is not null) todo.TimeStart = workDto.TimeStart;
    if (workDto.TimeEnd is not null) todo.TimeEnd = workDto.TimeEnd;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

workGroup.MapDelete("/{id}", async (int id, WorkDb db) =>
{
    if (await db.Works.FindAsync(id) is Work work)
    {
        db.Works.Remove(work);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

The preceding code has the following changes:

  1. Adds var var workGroup = app.MapGroup("/works"); to set up the group using the URL prefix /works.
  2. Changes all the app.Map methods to workGroup.Map.
  3. Removes the URL prefix /works from the Map method calls.

Chaining MapGroup().RequireAuthorization() allows you to secure an entire group of endpoints simultaneously, preventing the need to add authorization rules to each route individually. For example in project we would like only authorized users to execute the POST, PUT, PATCH and DELETE verbs so we can use RequireAuthorization with MapGroup as shown below. Also note that the GET verb does not require authentication so we will use AllowAnonymous method.

using DailyWork;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<WorkDb>(opt => opt.UseInMemoryDatabase("WorkDatabase"));

// 1. Register required authentication and authorization services

builder.Services.AddAuthentication().AddJwtBearer(); 
builder.Services.AddAuthorization();

var app = builder.Build();

// 2. Ensure both middlewares are added to the HTTP pipeline

app.UseAuthentication();
app.UseAuthorization();

// 3. Create an authorized route group

var protectedGroup = app.MapGroup("/works").RequireAuthorization(); // Requires a valid user for all endpoints below

// These endpoints automatically inherit the group's authorization rule

protectedGroup.MapPost("/", async (Work work, WorkDb db) =>
{
    db.Works.Add(work);
    await db.SaveChangesAsync();

    return Results.Created($"/works/{work.Id}", work);
});

protectedGroup.MapPut("/{id}", async (int id, Work work, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = work.Name;
    todo.TimeStart = work.TimeStart;
    todo.TimeEnd = work.TimeEnd;
    todo.IsComplete = work.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

protectedGroup.MapPatch("/{id}", async (int id, WorkDto workDto, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return Results.NotFound();

    if (workDto.Name is not null) todo.Name = workDto.Name;
    if (workDto.IsComplete is not null) todo.IsComplete = workDto.IsComplete.Value;
    if (workDto.TimeStart is not null) todo.TimeStart = workDto.TimeStart;
    if (workDto.TimeEnd is not null) todo.TimeEnd = workDto.TimeEnd;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

protectedGroup.MapDelete("/{id}", async (int id, WorkDb db) =>
{
    if (await db.Works.FindAsync(id) is Work work)
    {
        db.Works.Remove(work);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

// 4. Override protection for a GET endpoints. No authentication is needed for HTTP GET 

protectedGroup.MapGet("/", async (WorkDb db) =>
    await db.Works.ToListAsync()).AllowAnonymous();

protectedGroup.MapGet("/complete", async (WorkDb db) =>
    await db.Works.Where(t => t.IsComplete).ToListAsync()).AllowAnonymous();

protectedGroup.MapGet("/works/{id}", async (int id, WorkDb db) =>
{       
    var found = await db.Works.FindAsync(id);
    return found is Work work ? Results.Ok(work) : Results.NotFound();
}).AllowAnonymous();

app.Run();

TypedResults – strongly-typed HTTP responses

With TypedResults we can return strongly-typed HTTP responses. This thing is missing in “Results” method which we used earlier. TypedResults improve code readability, unit testing, and reduce the chance of runtime errors. The implementation type automatically supplies the response type metadata used by OpenAPI to document the endpoint.

The following endpoint successfully responds with a 200 OK status code and the expected JSON payload.

app.MapGet("/hello", () => Results.Ok(new Message() { Text = "Hello World!" }))
    .Produces<Message>();

The Produces extension method is typically used to document an endpoint’s response. When TypedResults is used instead of Results, however, this step is not required because TypedResults automatically provides the metadata needed for OpenAPI to describe the endpoint. See the code below:

app.MapGet("/hello", () => TypedResults.Ok(new Message() { Text = "Hello World!" }));

Return different types with TypedResults

To use TypedResults, the return type must be fully declared, the declaration requires wrapping the return type in a Task

.
app.MapGet("/Products/{id}", async Task<Results<Ok<Product>, NotFound>> (int id, ProductDb db) =>
   await db.Products.FindAsync(id)
    is Product product
       ? TypedResults.Ok(product)
       : TypedResults.NotFound());

The following method compiles successfully because both “Results.Ok” and “Results.NotFound” return “IResult”. Although the underlying objects are different concrete types, they share the same declared return type.

app.MapGet("/Products/{id}", async (int id, ProductDb db) =>
    await db.Products.FindAsync(id)
        is Product product
            ? Results.Ok(product)
            : Results.NotFound());

The following method does not compile because “TypedResults.Ok” and “TypedResults.NotFound” return different concrete types. Since there is no common inferred return type, the compiler cannot determine the appropriate type automatically.

app.MapGet("/Products/{id}", async (int id, ProductDb db) =>
     await db.Products.FindAsync(id)
     is Product product
        ? TypedResults.Ok(product)
        : TypedResults.NotFound());

Minimal API with TypedResults

With TypeResults our Minimal API can be written as shown below.

app.MapPost("/works", async Task<Results<IResult, BadRequest<string>>> (Work work, WorkDb db) =>
{
    try
    {
        db.Works.Add(work);
        await db.SaveChangesAsync();
        return TypedResults.Created($"/works/{work.Id}", work);
    }
    catch (Exception e)
    {
        return TypedResults.BadRequest(e.Message);
    }
});

app.MapGet("/works", async Task<IResult> (WorkDb db) =>
{
    return TypedResults.Ok(await db.Works.ToArrayAsync());
});

app.MapGet("/works/complete", async Task<IResult> (WorkDb db) =>
{
    return TypedResults.Ok(await db.Works.Where(t => t.IsComplete).ToListAsync());
});

app.MapGet("/works/{id}", async Task<IResult> (int id, WorkDb db) =>
    await db.Works.FindAsync(id)
        is Work work
            ? TypedResults.Ok(work)
            : TypedResults.NotFound());

app.MapPut("/works/{id}", async Task<IResult> (int id, Work work, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = work.Name;
    todo.TimeStart = work.TimeStart;
    todo.TimeEnd = work.TimeEnd;
    todo.IsComplete = work.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
});

app.MapPatch("/works/{id}", async Task<IResult> (int id, WorkDto workDto, WorkDb db) =>
{
    var todo = await db.Works.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    if (workDto.Name is not null) todo.Name = workDto.Name;
    if (workDto.IsComplete is not null) todo.IsComplete = workDto.IsComplete.Value;
    if (workDto.TimeStart is not null) todo.TimeStart = workDto.TimeStart;
    if (workDto.TimeEnd is not null) todo.TimeEnd = workDto.TimeEnd;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
});

app.MapDelete("/works/{id}", async Task<IResult> (int id, WorkDb db) =>
{
    if (await db.Works.FindAsync(id) is Work work)
    {
        db.Works.Remove(work);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
});

Prevent Over-Posting in Minimal API

Over-posting, also known as mass assignment, occurs when a client intentionally or unintentionally includes additional fields in an HTTP POST or PUT request that should not be modifiable. While traditional Minimal APIs reduces this risk through the use of dedicated Data Transfer Objects (DTOs). This approach ensures that only the intended data is accepted and processed, reducing the risk of unauthorized updates.

DTO will provide the following benefits:

  • Prevent over-posting.
  • Hide secret properties that clients should not view.
  • Omit somee properties to reduce payload size.

We have a Work.cs class that we have been using to build minimal api.

public class Work
{
    public int Id { get; set; }
    
    public string Name { get; set; }

    public string TimeStart { get; set; }

    public string TimeEnd { get; set; }

    public bool IsComplete { get; set; }
}

Update it to include a new Secret string value as shown below.

public class Work
{
    public int Id { get; set; }
    
    public string Name { get; set; }

    public string TimeStart { get; set; }

    public string TimeEnd { get; set; }

    public bool IsComplete { get; set; }

    public string? Secret { get; set; }
}

This Secret field is never to be exposed to the client as it is only for administrative purpose. So we create a new DTO called WorkItemDTO.cs as shown below.

public class WorkItemDto
{
    public int Id { get; set; }
    public string Name { get; set; }

    public string TimeStart { get; set; }

    public string TimeEnd { get; set; }

    public bool IsComplete { get; set; }

    public WorkItemDto() { }
    public WorkItemDto(Work work) =>
    (Id, Name, TimeStart, TimeEnd, IsComplete) = (work.Id, work.Name, work.TimeStart, work.TimeEnd, work.IsComplete);
}

The DTO does not have the Secret field and in it’s constructor we are binding it’s field’s values with the work class fields (leaving aside the Secret field).

And that’s it we can now use this DTO in minimal API code.

Check the below code where the MapPost method is now using this DTO to create a new Work on the database. Notice the Secret field is never used.

app.MapPost("/works", async (WorkItemDto wiDto, WorkDb db) =>
{
    var work = new Work
    {
        Name = wiDto.Name,
        TimeStart = wiDto.TimeStart,
        TimeEnd = wiDto.TimeEnd,
        IsComplete = wiDto.IsComplete,
    };

    db.Works.Add(work);
    await db.SaveChangesAsync();

    wiDto = new WorkItemDto(work);

    return Results.Created($"/works/{work.Id}", wiDto);
});

The link to download the full source code of this tutorial is given below:

Download

Conclusion

This completes the ASP.NET Core Minimal APIs where we have covered all the types GET, POST, PUT and DELETE. This hardly took no more than 20 minutes to learn and implement them in our app. I hope you enjoyed learning this great concept.

SHARE THIS ARTICLE

  • linkedin
  • reddit
yogihosting

ABOUT THE AUTHOR

I hope you enjoyed reading this tutorial. If it helped you then consider buying a cup of coffee for me. This will help me in writing more such good tutorials for the readers. Thank you. Buy Me A Coffee donate

Leave a Reply

Your email address will not be published. Required fields are marked *