Skip to content
Educora
Advanced25 min16 / 16

A Web API with ASP.NET Core

Build a CRUD API on top of EF Core with ASP.NET Core: minimal APIs and controllers, dependency injection, middleware, configuration, validation and OpenAPI.

Check yourself
In this lesson you will learn
  • Create a Web API project and explain the structure of Program.cs (services, middleware, endpoints)
  • Write CRUD endpoints with EF Core in both minimal API and controller style
  • Choose DI lifetimes, read configuration and test the API with curl and OpenAPI

In the previous lesson you learned to keep data in a database with EF Core. Now let's open it to the world: a mobile app, a website or another server should be able to ask for the list of students and add a new student over HTTP. ASP.NET Core is the fast, cross-platform, open-source web framework of .NET. In this lesson you will use it to build a full CRUD API on top of EF Core and learn dependency injection, middleware, configuration and the OpenAPI document.

  1. 1
    Create the project

    dotnet new webapi -n SchoolApi creates a minimal API by default; add --use-controllers for controllers.

  2. 2
    Add EF Core

    Add the Microsoft.EntityFrameworkCore.Sqlite package and write the model and context classes.

  3. 3
    Configuration and database

    Put the connection string into appsettings.json, then run dotnet ef migrations add and dotnet ef database update as in the previous lesson.

  4. 4
    Run and test

    dotnet run --urls http://localhost:5080 starts the server; send requests with curl, an .http file or Swagger UI.

Terminal
dotnet new webapi -n SchoolApi
cd SchoolApi
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet run --urls http://localhost:5080
Expected output
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5080
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
Sample output (shortened): the built-in Kestrel server listens on port 5080

Program.cs: services and middleware

In a minimal API the whole application is set up in Program.cs, which has three parts. First, services are registered in builder.Services — this is .NET's built-in dependency injection (DI) container: AddDbContext adds the SchoolContext, AddOpenApi the document generator and AddValidation attribute-based validation. Then builder.Build() creates the app and the middleware pipeline is set up. Finally come the endpoints — methods bound to URLs.

C#
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// 1. services for dependency injection
builder.Services.AddDbContext<SchoolContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("School")));
builder.Services.AddOpenApi();
builder.Services.AddValidation();              // .NET 10: checks [Required], [Range]...

var app = builder.Build();

// 2. the middleware pipeline
if (app.Environment.IsDevelopment())
    app.MapOpenApi();                          // the document at /openapi/v1.json

app.Use(async (context, next) =>               // our own middleware: request timing
{
    var watch = System.Diagnostics.Stopwatch.StartNew();
    await next(context);
    app.Logger.LogInformation("{Method} {Path} -> {Status} in {Ms} ms", context.Request.Method,
        context.Request.Path, context.Response.StatusCode, watch.ElapsedMilliseconds);
});
Program.cs, part 1: services and middleware (requires the EF Core and OpenAPI packages)

Middleware is a link in the chain that every HTTP request passes through. Each link can look at the request, change it, answer it itself or call next to pass the request to the next link. Our own middleware above starts a stopwatch, waits for the rest of the chain with await next(context) and then logs the status and the time taken. The order of the links matters and is usually this:

  1. UseExceptionHandler — catches errors from all the links below it
  2. UseHttpsRedirection — redirects HTTP requests to HTTPS
  3. UseCors — decides which websites may call the API
  4. UseAuthentication — “who are you?”
  5. UseAuthorization — “what are you allowed to do?”
  6. Endpoints: MapGet, MapPost, MapControllers

CRUD endpoints with EF Core

C#
public class Student
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public int Score { get; set; }
}

public class SchoolContext(DbContextOptions<SchoolContext> options) : DbContext(options)
{
    public DbSet<Student> Students => Set<Student>();
}

public record StudentDto([Required] string Name, [Range(0, 100)] int Score);
A simplified model, a context that receives its options from DI, and a DTO for input
C#
// 3. endpoints
var students = app.MapGroup("/api/students");

students.MapGet("/", async (SchoolContext db) =>
    await db.Students.AsNoTracking().OrderBy(s => s.Id).ToListAsync());

students.MapGet("/{id:int}", async Task<Results<Ok<Student>, NotFound>> (int id, SchoolContext db) =>
    await db.Students.FindAsync(id) is Student s ? TypedResults.Ok(s) : TypedResults.NotFound());

students.MapPost("/", async (StudentDto dto, SchoolContext db) =>
{
    var student = new Student { Name = dto.Name, Score = dto.Score };
    db.Students.Add(student);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/api/students/{student.Id}", student);
});

students.MapPut("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, StudentDto dto, SchoolContext db) =>
    await db.Students.Where(s => s.Id == id)
        .ExecuteUpdateAsync(set => set.SetProperty(s => s.Name, dto.Name).SetProperty(s => s.Score, dto.Score)) == 0
        ? TypedResults.NotFound() : TypedResults.NoContent());

students.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, SchoolContext db) =>
    await db.Students.Where(s => s.Id == id).ExecuteDeleteAsync() == 0
        ? TypedResults.NotFound() : TypedResults.NoContent());

app.Run();
Program.cs, part 2: five endpoints — read, create, update, delete

MapGroup lets you write the common /api/students prefix once. An endpoint's parameters are filled in automatically: id from the URL ({id:int} accepts only whole numbers), StudentDto from the JSON body and SchoolContext from the DI container. TypedResults return the right statuses — Created gives 201 and a Location header, NoContent 204, NotFound 404 — and the Results<...> type documents every possible answer, so OpenAPI sees them too. Names in JSON become camelCase automatically: Name → name.

JSON
{
  "ConnectionStrings": {
    "School": "Data Source=school.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}
appsettings.json: GetConnectionString("School") reads this value; appsettings.Development.json and environment variables can override it

Testing with curl

Terminal
curl -X POST localhost:5080/api/students -H 'Content-Type: application/json' -d '{"name":"Aysel","score":95}'
curl -X POST localhost:5080/api/students -H 'Content-Type: application/json' -d '{"name":"Murad","score":78}'
curl localhost:5080/api/students
curl -s -o /dev/null -w '%{http_code}\n' -X PUT localhost:5080/api/students/2 \
     -H 'Content-Type: application/json' -d '{"name":"Murad","score":84}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:5080/api/students/2
curl -s -o /dev/null -w '%{http_code}\n' localhost:5080/api/students/2
curl -X POST localhost:5080/api/students -H 'Content-Type: application/json' -d '{"name":"Elvin","score":120}'
Expected output
{"id":1,"name":"Aysel","score":95}
{"id":2,"name":"Murad","score":78}
[{"id":1,"name":"Aysel","score":95},{"id":2,"name":"Murad","score":78}]
204
204
404
{"title":"One or more validation errors occurred.","errors":{"Score":["The field Score must be between 0 and 100."]}}
The output was checked on .NET 10 with an in-memory version of the same API; with an empty SQLite database the answers are the same. The last request failed validation and got a 400

Controllers, DI lifetimes and OpenAPI

Minimal APIs are handy for small and medium services. Large projects often use controllers: endpoints are grouped in classes derived from ControllerBase, and routes are written with attributes. dotnet new webapi --use-controllers creates such a project. Both styles use the same DI, middleware and EF Core, and they can even be combined in one project.

C#
[ApiController]
[Route("api/[controller]")]                      // -> /api/students
public class StudentsController(SchoolContext db) : ControllerBase
{
    [HttpGet]
    public async Task<List<Student>> GetAll() =>
        await db.Students.AsNoTracking().OrderBy(s => s.Id).ToListAsync();

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Student>> Get(int id) =>
        await db.Students.FindAsync(id) is Student s ? s : NotFound();

    [HttpPost]
    public async Task<ActionResult<Student>> Create(StudentDto dto)
    {
        var student = new Student { Name = dto.Name, Score = dto.Score };
        db.Students.Add(student);
        await db.SaveChangesAsync();
        return CreatedAtAction(nameof(Get), new { id = student.Id }, student);
    }
}
// Program.cs: builder.Services.AddControllers(); ... app.MapControllers();
The same API with a controller: SchoolContext comes through the primary constructor, and [ApiController] returns 400 automatically on validation errors
MethodLifetimeUsed for
AddSingletonone instance for the whole appconfiguration, an in-memory cache
AddScopedone instance per HTTP requestDbContext and the services that use it
AddTransienta new instance every timelightweight, stateless helpers
Wrong: a singleton captures a scoped service
builder.Services.AddDbContext<SchoolContext>(...);   // scoped: one per request
builder.Services.AddSingleton<ReportService>();      // ReportService needs SchoolContext
// error at startup (Development):
// Cannot consume scoped service '...SchoolContext' from singleton '...ReportService'.
Right: matching lifetimes
builder.Services.AddDbContext<SchoolContext>(...);   // scoped: one per request
builder.Services.AddScoped<ReportService>();         // the same lifetime as its dependency
In Development mode ASP.NET Core catches this mistake already at startup; otherwise one DbContext would be shared by all requests

OpenAPI (formerly called Swagger) is a machine-readable description of an API: which URLs exist, which JSON they expect and which statuses they return. Since .NET 9 the template uses AddOpenApi() and MapOpenApi(), and the document is served at /openapi/v1.json. For an interactive page in the browser, add the Swashbuckle.AspNetCore.SwaggerUI package and write app.UseSwaggerUI(o => o.SwaggerEndpoint("/openapi/v1.json", "School API")): on the /swagger page you can try every endpoint with a button. Client code for mobile apps can also be generated from this document automatically.

Key points

  • Program.cs: services (DI) → Build() → middleware → endpoints → Run().
  • Minimal APIs: MapGroup, MapGet/MapPost/MapPut/MapDelete; parameters come from the URL, the body and DI, and TypedResults return 200/201/204/404.
  • DI lifetimes: Singleton, Scoped (DbContext), Transient; never inject a scoped service into a singleton.
  • Configuration comes from appsettings.json, environment-specific files and environment variables; GetConnectionString reads the connection string.
  • DTOs and AddValidation for input; the OpenAPI document is at /openapi/v1.json, and Swagger UI is added with a package.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
With which lifetime does AddDbContext register the DbContext?