Overview
A task manager API is the standard way to learn how ASP.NET Core turns HTTP requests into typed C# method calls, and how Entity Framework Core turns ordinary C# objects into rows in a database without hand-written SQL. A [HttpGet]-attributed method on a controller is not just a function — it is a routed endpoint the ASP.NET Core framework matches against an incoming request's URL and HTTP verb, extracts route/query/body values into strongly typed parameters for, and calls automatically.
By the end of this tutorial you will have a small but complete REST API: a TaskItem entity, an AppDbContext that EF Core uses to talk to a database, and a TasksController exposing GET, POST, PUT, and DELETE endpoints under /api/tasks. This project uses EF Core's **in-memory provider** — data lives only in the process's memory and resets every time the app restarts. That choice is deliberate: it means this tutorial needs nothing installed beyond the .NET SDK itself to run end to end. A real deployment would swap the single UseInMemoryDatabase(...) line in Step 3 for UseSqlServer(...) or UseNpgsql(...) against a real SQL Server or PostgreSQL database — every line of the TaskItem model and TasksController below would stay exactly the same, because EF Core's LINQ query API is provider-independent.
- A
TaskItementity withId,Title,IsComplete, andCreatedAtproperties. - An
AppDbContextexposing aDbSet<TaskItem>that EF Core maps to a table. - Minimal-hosting
Program.cs(top-level statements) registering the DbContext and controller routing. - A
TasksControllerwithGET /api/tasksandGET /api/tasks/{id}endpoints. - A
POST /api/tasksendpoint that creates a task and returns a201 Createdresponse. PUT /api/tasks/{id}andDELETE /api/tasks/{id}endpoints completing full CRUD.
Prerequisites
- C# fundamentals — classes, properties, and
async/await. - HTTP basics — the meaning of
GET,POST,PUT,DELETE, and status codes like200,201,204, and404. - Collections and LINQ basics —
List<T>and simple query methods like.Where()/.ToListAsync(). - Dependency injection basics — a constructor receiving an object it depends on rather than creating it itself.
- JSON basics — how a C# object maps to a JSON request/response body.
Project Structure
Running dotnet new webapi -o TaskManagerApi (or dotnet new webapi --use-controllers -o TaskManagerApi, depending on your SDK version, to guarantee the controller-based template rather than the minimal-API template) scaffolds a project with this layout: Program.cs at the root configures and starts the app; a Controllers/ folder holds one file per controller (TasksController.cs here); a Models/ folder holds plain data classes like TaskItem; appsettings.json holds configuration such as connection strings; and a .csproj file lists NuGet package references, including Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.InMemory once added for this project.
This tutorial also adds a Data/ folder for AppDbContext.cs, mirroring how a larger ASP.NET Core project typically separates "how the app talks to its database" (Data/) from "what shape the app's data takes" (Models/) and "how the app handles requests" (Controllers/). Every namespace below follows the folder it lives in — TaskManagerApi.Models, TaskManagerApi.Data, TaskManagerApi.Controllers — which is the standard convention dotnet new itself follows.
Step 1: Define the TaskItem Model
EF Core maps a plain C# class to a database table by convention: a DbSet<TaskItem> named Tasks on the DbContext becomes a table, and a property named Id (or TaskItemId) is automatically treated as the primary key with values generated by the database. Title defaults to string.Empty rather than being nullable, which keeps a task from ever existing in a half-formed state where its title is null.
namespace TaskManagerApi.Models;
// Represents one task row in the database. EF Core maps this class directly to// a table (named "Tasks", to match the DbSet property added in Step 2) using// nothing but naming convention — no attributes or configuration are required// for a class this simple.public class TaskItem{ public int Id { get; set; } // Primary key; EF Core recognizes "Id" by convention and makes it an identity column public string Title { get; set; } = string.Empty; // Non-nullable with a default so a task can never exist with a null title public bool IsComplete { get; set; } // Defaults to false, matching bool's own default value public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Set once when the object is constructed, never touched by updates}Step 2: Create the DbContext
A DbContext is EF Core's central class: it tracks every entity loaded through it in memory, translates LINQ queries against a DbSet<T> into the operations of whichever database provider Program.cs configures, and batches every tracked change into the database when SaveChangesAsync() is called. AppDbContext receives its configuration through DbContextOptions<AppDbContext> passed into the constructor — it never decides for itself which provider or connection string to use, that decision belongs entirely to Program.cs in Step 3.
using Microsoft.EntityFrameworkCore;using TaskManagerApi.Models;
namespace TaskManagerApi.Data;
public class AppDbContext : DbContext{ // DbContextOptions carries the provider + connection string chosen in Program.cs. // AppDbContext itself stays completely agnostic about where its data actually lives. public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<TaskItem> Tasks => Set<TaskItem>(); // One DbSet per table; "Tasks" becomes the queryable collection used everywhere below}Step 3: Configure Program.cs
WebApplication.CreateBuilder(args) sets up configuration, logging, and the dependency-injection container in one call — everything registered on builder.Services becomes something a controller's constructor can ask for later. AddDbContext<AppDbContext>() is where the in-memory provider is chosen for this tutorial; swapping to a real database in production means changing only the single line inside that call.
using Microsoft.EntityFrameworkCore;using TaskManagerApi.Data;
var builder = WebApplication.CreateBuilder(args); // Top-level statements: no explicit Main method needed, the compiler generates one
// EF Core's in-memory provider needs no real database server, which keeps this// tutorial runnable with nothing installed beyond the .NET SDK. A real deployment// would swap this one call for UseSqlServer(...) or UseNpgsql(...) against SQL// Server or PostgreSQL — nothing in the controller or model code would need to change.builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TaskManagerDb"));
builder.Services.AddControllers(); // Registers controller-based routing, so [HttpGet]/[HttpPost] attributes below get wired up
var app = builder.Build();
app.MapControllers(); // Maps every attribute-routed controller (like TasksController) found in the project's assembly
app.Run(); // Starts the built-in Kestrel web server and blocks until the process is stoppedStep 4: Build the GET Endpoints
[ApiController] turns on a set of conventions specific to Web APIs, including automatic 400 Bad Request responses when model validation fails, and automatic inference of where a parameter's value should come from (route, query string, or body). [Route("api/[controller]")] uses the [controller] token, which ASP.NET Core replaces with the class name minus its Controller suffix — TasksController becomes the route prefix api/tasks. The constructor receiving AppDbContext db is dependency injection in action: the framework itself creates a new TasksController per request and hands it the same AppDbContext instance registered back in Step 3.
using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using TaskManagerApi.Data;using TaskManagerApi.Models;
namespace TaskManagerApi.Controllers;
[ApiController] // Turns on automatic model validation and parameter-source inference for a Web API controller[Route("api/[controller]")] // [controller] becomes "Tasks" (the class name minus "Controller"), giving the route prefix /api/taskspublic class TasksController : ControllerBase{ private readonly AppDbContext _db; // Injected per-request by the DI container configured in Program.cs
public TasksController(AppDbContext db) { _db = db; }
[HttpGet] // GET /api/tasks public async Task<ActionResult<IEnumerable<TaskItem>>> GetAll() { return await _db.Tasks.ToListAsync(); // Async all the way down avoids blocking a thread while waiting on I/O }
[HttpGet("{id}")] // GET /api/tasks/5 public async Task<ActionResult<TaskItem>> GetById(int id) { var task = await _db.Tasks.FindAsync(id); // FindAsync checks EF Core's in-memory change tracker before querying the database if (task is null) { return NotFound(); // 404: no task with that id exists } return task; }}Step 5: Build the POST Endpoint
[ApiController]'s automatic model binding is what turns the raw JSON body of a POST request into a fully populated TaskItem parameter, with no manual deserialization code anywhere in this method. CreatedAtAction() builds a proper 201 Created response: its second and third arguments generate a Location header pointing back at the new resource's GetById URL, which is the behavior the HTTP spec expects from a successful resource-creation request rather than a bare 200 OK.
[HttpPost] // POST /api/taskspublic async Task<ActionResult<TaskItem>> Create(TaskItem task){ _db.Tasks.Add(task); // Starts tracking the new entity with a state of "Added" await _db.SaveChangesAsync(); // Flushes every tracked change to the database in one round trip, generating task.Id
// 201 Created, with a Location header pointing at GET /api/tasks/{task.Id} — the // conventional response shape for "a new resource now exists at this URL". return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);}Step 6: Build the PUT and DELETE Endpoints
Update() loads the existing tracked entity with FindAsync() and mutates its properties directly rather than replacing it outright — EF Core's change tracker notices those property assignments automatically, so SaveChangesAsync() knows exactly which UPDATE to issue without being told explicitly. Both endpoints return 204 No Content on success, the conventional response for "the operation succeeded and there is nothing meaningful to send back."
[HttpPut("{id}")] // PUT /api/tasks/5public async Task<IActionResult> Update(int id, TaskItem updated){ var task = await _db.Tasks.FindAsync(id); if (task is null) { return NotFound(); } task.Title = updated.Title; // Mutate the tracked entity's properties directly instead of replacing it task.IsComplete = updated.IsComplete; // EF Core's change tracker detects these assignments automatically await _db.SaveChangesAsync(); return NoContent(); // 204: the update succeeded, nothing meaningful to return}
[HttpDelete("{id}")] // DELETE /api/tasks/5public async Task<IActionResult> Delete(int id){ var task = await _db.Tasks.FindAsync(id); if (task is null) { return NotFound(); } _db.Tasks.Remove(task); // Marks the entity as "Deleted" in the change tracker await _db.SaveChangesAsync(); // Issues the actual DELETE against the database return NoContent();}Complete Code
Here is the full project across its four files, ready to run with dotnet run after installing the Microsoft.EntityFrameworkCore.InMemory NuGet package (dotnet add package Microsoft.EntityFrameworkCore.InMemory).
namespace TaskManagerApi.Models;
public class TaskItem{ public int Id { get; set; } public string Title { get; set; } = string.Empty; public bool IsComplete { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow;}using Microsoft.EntityFrameworkCore;using TaskManagerApi.Models;
namespace TaskManagerApi.Data;
public class AppDbContext : DbContext{ public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<TaskItem> Tasks => Set<TaskItem>();}using Microsoft.EntityFrameworkCore;using TaskManagerApi.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TaskManagerDb"));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using TaskManagerApi.Data;using TaskManagerApi.Models;
namespace TaskManagerApi.Controllers;
[ApiController][Route("api/[controller]")]public class TasksController : ControllerBase{ private readonly AppDbContext _db;
public TasksController(AppDbContext db) { _db = db; }
[HttpGet] public async Task<ActionResult<IEnumerable<TaskItem>>> GetAll() { return await _db.Tasks.ToListAsync(); }
[HttpGet("{id}")] public async Task<ActionResult<TaskItem>> GetById(int id) { var task = await _db.Tasks.FindAsync(id); if (task is null) { return NotFound(); } return task; }
[HttpPost] public async Task<ActionResult<TaskItem>> Create(TaskItem task) { _db.Tasks.Add(task); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetById), new { id = task.Id }, task); }
[HttpPut("{id}")] public async Task<IActionResult> Update(int id, TaskItem updated) { var task = await _db.Tasks.FindAsync(id); if (task is null) { return NotFound(); } task.Title = updated.Title; task.IsComplete = updated.IsComplete; await _db.SaveChangesAsync(); return NoContent(); }
[HttpDelete("{id}")] public async Task<IActionResult> Delete(int id) { var task = await _db.Tasks.FindAsync(id); if (task is null) { return NotFound(); } _db.Tasks.Remove(task); await _db.SaveChangesAsync(); return NoContent(); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add
[Required]/[StringLength]data annotations toTaskItem.Titleso[ApiController]rejects an invalidPOST/PUTbody with an automatic400 Bad Requestbefore your code even runs. - Swap
UseInMemoryDatabase("TaskManagerDb")forUseSqlite("Data Source=tasks.db")(with theMicrosoft.EntityFrameworkCore.Sqlitepackage) so tasks survive an app restart. - Add a
GET /api/tasks?isComplete=truefilter using a query-string parameter and a.Where()clause. - Add Swagger/OpenAPI support with
Swashbuckle.AspNetCoreso the API is explorable and testable from a browser. - Add a
DueDateproperty toTaskItemand aGET /api/tasks/overdueendpoint that filters tasks whose due date has passed.
Summary
You built a working REST API where ASP.NET Core routes HTTP requests to strongly typed controller methods, and EF Core translates ordinary C# objects and LINQ queries into database operations without a single hand-written SQL statement. The GetAll/GetById/Create/Update/Delete shape you wrote in TasksController is the same shape almost every CRUD API in ASP.NET Core follows, whether it is backed by EF Core's in-memory provider for a tutorial or a production SQL Server database.