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 `TaskItem` entity with `Id`, `Title`, `IsComplete`, and `CreatedAt` properties.
- An `AppDbContext` exposing a `DbSet<TaskItem>` that EF Core maps to a table.
- Minimal-hosting `Program.cs` (top-level statements) registering the DbContext and controller routing.
- A `TasksController` with `GET /api/tasks` and `GET /api/tasks/{id}` endpoints.
- A `POST /api/tasks` endpoint that creates a task and returns a `201 Created` response.
- `PUT /api/tasks/{id}` and `DELETE /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 like `200`, `201`, `204`, and `404`.
- 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 to `TaskItem.Title` so `[ApiController]` rejects an invalid `POST`/`PUT` body with an automatic `400 Bad Request` before your code even runs.
- Swap `UseInMemoryDatabase("TaskManagerDb")` for `UseSqlite("Data Source=tasks.db")` (with the `Microsoft.EntityFrameworkCore.Sqlite` package) so tasks survive an app restart.
- Add a `GET /api/tasks?isComplete=true` filter using a query-string parameter and a `.Where()` clause.
- Add Swagger/OpenAPI support with `Swashbuckle.AspNetCore` so the API is explorable and testable from a browser.
- Add a `DueDate` property to `TaskItem` and a `GET /api/tasks/overdue` endpoint 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.