LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
C# & .NETIntermediate~2.5 hours

Task Manager Web API

Build a full CRUD REST API for managing tasks, backed by Entity Framework Core.

ASP.NET CoreEF CoreWeb API

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.

What You'll Build
  • 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.

Models/TaskItem.cs
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.

Data/AppDbContext.cs
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.

Program.cs
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 stopped

Step 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.

Controllers/TasksController.cs
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/tasks
public 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/tasks
public 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/5
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; // 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/5
public 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).

Models/TaskItem.cs
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;
}
Data/AppDbContext.cs
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>();
}
Program.cs
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();
Controllers/TasksController.cs
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

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.