Everything you learned about DI, configuration, and logging was building toward this: a real HTTP API that a real client can call.
Every console app you've built so far has had exactly one user: whoever is sitting at the keyboard, typing into the same terminal the program runs in. Most real software doesn't work that way. A mobile app, a web front-end, another backend service — they all need to talk to your program over a network, using a shared, predictable protocol. That's what a REST API gives you: a set of URLs and HTTP verbs that any client, written in any language, can call to create, read, update, and delete data.
This project builds a small but genuinely real Task API — a backend for a to-do list — using ASP.NET Core's minimal API style. You'll pull together dependency injection, the options pattern for configuration, structured logging with ILogger<T>, and the standard REST verbs, all wired through the same DI container you learned about in Part V. Nothing here is a deep dive into ASP.NET Core itself (that's a full module later, in the Advanced tier) — the web layer stays intentionally thin. The teaching weight is on the intermediate-tier concepts you already have, now working together inside a real, runnable host.
Read every step, but type it yourself. Run the app after each stage and hit it with a browser or a REST client. By the end, you'll have a complete, working API — and a template for how a huge share of real-world .NET backends are structured.
Build a REST API for a task-tracking app. Clients should be able to create tasks, list them, fetch a single task, update one, mark it complete, and delete it — all over plain HTTP, using JSON as the data format.
appsettings.json, not hardcoded.WebApplication.CreateBuilder, app.MapGet/MapPost/etc., and the built-in DI container — all pragmatically, the way real teams reach for a lightweight host before they need the full weight of controllers, filters, and middleware pipelines. The formal deep dive into ASP.NET Core's architecture comes later, in the Advanced tier. Here, the web framework is just the delivery mechanism for concepts you already own: DI, configuration, and logging.
Before writing endpoints, decide what a task is, and who owns storing it. A task needs an identifier, a title, and a completed flag. Since the API mutates a task's state (marking it done, editing its title), a plain class with a mutable property makes more sense here than an immutable record — a lesson learned from the difference between records and classes back in Part VI of the Foundations tier.
public class TaskItem
{
public int Id { get; set; }
public required string Title { get; set; }
public bool IsComplete { get; set; }
}
Next, storage and business rules shouldn't live directly inside the endpoint delegates — that would tangle HTTP concerns (parsing a route, writing a response) with the actual logic of managing tasks. So we design a small repository behind an interface, exactly the DI pattern from Part V:
public interface ITaskRepository
{
IReadOnlyList<TaskItem> GetAll();
TaskItem? GetById(int id);
TaskItem Add(string title);
bool Update(int id, string title, bool isComplete);
bool Delete(int id);
}
The interface says nothing about HTTP, JSON, or ASP.NET Core — it's pure domain vocabulary. That means it's independently testable, and it means the minimal API endpoints will end up as thin adapters that translate HTTP into calls against this interface.
GET /tasks/7 hits the ASP.NET Core host/tasks/{id:int} extracts id = 7ITaskRepository; the container hands it the registered singletonGetById(7) returns a TaskItem? — found or null, then mapped to 200 OK or 404 Not Founddotnet new web -n TaskApi
cd TaskApi
dotnet new web scaffolds a minimal ASP.NET Core project — no controllers, no scaffolding ceremony, just a Program.cs with a WebApplication ready to configure. Open it and you'll see the same builder/app split you'll use in every ASP.NET Core project you ever write:
var builder = WebApplication.CreateBuilder(args);
// ... register services on builder.Services ...
var app = builder.Build();
// ... map endpoints on app ...
app.Run();
builder is where you configure everything before the app starts — services, configuration sources, logging providers. app is the running host, where you define what happens when a request arrives. This split should feel familiar: it's the same "configure, then run" shape as Host.CreateApplicationBuilder from the DI and configuration lessons, because under the hood, WebApplication.CreateBuilder is that generic host, with web-specific pieces (routing, Kestrel) added on top.
The brief calls for a configurable task limit. Add it to appsettings.json:
{
"TaskApi": {
"MaxTasks": 100
},
"Logging": {
"LogLevel": { "Default": "Information" }
}
}
Then bind it to a strongly-typed options class, exactly the pattern from the configuration lesson — never read raw strings out of IConfiguration by hand when a typed class will do:
public class TaskApiOptions
{
public int MaxTasks { get; set; } = 100;
}
// in Program.cs
builder.Services.Configure<TaskApiOptions>(
builder.Configuration.GetSection("TaskApi"));
Anything that later needs the limit — the repository, an endpoint — asks the DI container for IOptions<TaskApiOptions> and reads .Value.MaxTasks. Change the number in appsettings.json, no recompile required.
using Microsoft.Extensions.Options;
public class InMemoryTaskRepository(
IOptions<TaskApiOptions> options,
ILogger<InMemoryTaskRepository> logger) : ITaskRepository
{
private readonly List<TaskItem> _tasks = [];
private readonly Lock _lock = new();
private int _nextId = 1;
private readonly int _maxTasks = options.Value.MaxTasks;
public IReadOnlyList<TaskItem> GetAll()
{
lock (_lock) { return _tasks.ToList(); }
}
public TaskItem? GetById(int id)
{
lock (_lock) { return _tasks.FirstOrDefault(t => t.Id == id); }
}
public TaskItem Add(string title)
{
lock (_lock)
{
if (_tasks.Count >= _maxTasks)
{
logger.LogWarning("Task limit of {MaxTasks} reached; rejecting new task", _maxTasks);
throw new InvalidOperationException($"Cannot add more than {_maxTasks} tasks.");
}
var task = new TaskItem { Id = _nextId++, Title = title, IsComplete = false };
_tasks.Add(task);
logger.LogInformation("Created task {TaskId} — {Title}", task.Id, task.Title);
return task;
}
}
public bool Update(int id, string title, bool isComplete)
{
lock (_lock)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null)
{
logger.LogWarning("Update failed — task {TaskId} not found", id);
return false;
}
task.Title = title;
task.IsComplete = isComplete;
logger.LogInformation("Updated task {TaskId}", id);
return true;
}
}
public bool Delete(int id)
{
lock (_lock)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null) return false;
_tasks.Remove(task);
logger.LogInformation("Deleted task {TaskId}", id);
return true;
}
}
}
A few things worth slowing down on. This uses a primary constructor on the class itself — options and logger are captured as constructor parameters with no boilerplate field declarations. The Lock guards the list because, unlike a console app, an ASP.NET Core host can process multiple requests concurrently on different threads — two clients adding a task at the same moment must not corrupt _nextId or the list. And every meaningful operation logs a structured message with ILogger<T>, using the same {PlaceholderName} message-template style from the logging lesson — never string-interpolating values directly into the message.
builder.Services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
A Singleton because the whole point is that every request shares the same in-memory list — a Scoped or Transient repository would give each request its own empty list, and nothing would ever appear to persist between calls. (Once you move to EF Core in the next project, this changes — a DbContext is registered Scoped instead, because it wraps a real, per-request database connection.)
app.MapGet("/tasks", (ITaskRepository repo) => repo.GetAll());
app.MapGet("/tasks/{id:int}", (int id, ITaskRepository repo) =>
repo.GetById(id) is { } task
? Results.Ok(task)
: Results.NotFound());
app.MapPost("/tasks", (CreateTaskRequest request, ITaskRepository repo) =>
{
if (string.IsNullOrWhiteSpace(request.Title))
return Results.BadRequest("Title is required.");
try
{
var task = repo.Add(request.Title);
return Results.Created($"/tasks/{task.Id}", task);
}
catch (InvalidOperationException ex)
{
return Results.Conflict(ex.Message);
}
});
app.MapPut("/tasks/{id:int}", (int id, UpdateTaskRequest request, ITaskRepository repo) =>
repo.Update(id, request.Title, request.IsComplete)
? Results.NoContent()
: Results.NotFound());
app.MapDelete("/tasks/{id:int}", (int id, ITaskRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
record CreateTaskRequest(string Title);
record UpdateTaskRequest(string Title, bool IsComplete);
Every one of these is a small delegate, but notice what each parameter is: id comes from the route, request is bound automatically from the JSON request body, and ITaskRepository repo is injected straight into the endpoint — minimal APIs support constructor-style DI on the delegate's own parameter list, no controller class required. The is { } task pattern from Part VI's pattern matching lesson reads naturally here: "if GetById returned a non-null task, bind it to task."
Status codes matter, and each one is chosen deliberately: Results.Created (201) for a successful POST, including a Location-style path to the new resource; Results.NoContent (204) for a successful PUT/DELETE that has nothing more to say; Results.NotFound (404) when an ID doesn't exist; Results.BadRequest (400) for invalid input the client sent; Results.Conflict (409) when the request is well-formed but violates a business rule (the task limit). This is what makes an API genuinely RESTful rather than just "an HTTP endpoint that happens to return JSON" — the status code itself carries meaning a client can branch on without parsing the body.
CreateTaskRequest and UpdateTaskRequest are separate small records from TaskItem, on purpose. A client creating a task shouldn't be able to set its Id or IsComplete directly — those are server-owned. Accepting the exact shape of your domain model straight from the network is a common beginner mistake; a dedicated request shape keeps the boundary between "what a client may send" and "what your domain actually is" clean.
Here's the whole program, combined into a single Program.cs for readability (in a real project, TaskItem.cs, ITaskRepository.cs, and InMemoryTaskRepository.cs would be their own files).
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<TaskApiOptions>(
builder.Configuration.GetSection("TaskApi"));
builder.Services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
var app = builder.Build();
app.MapGet("/tasks", (ITaskRepository repo) => repo.GetAll());
app.MapGet("/tasks/{id:int}", (int id, ITaskRepository repo) =>
repo.GetById(id) is { } task ? Results.Ok(task) : Results.NotFound());
app.MapPost("/tasks", (CreateTaskRequest request, ITaskRepository repo) =>
{
if (string.IsNullOrWhiteSpace(request.Title))
return Results.BadRequest("Title is required.");
try
{
var task = repo.Add(request.Title);
return Results.Created($"/tasks/{task.Id}", task);
}
catch (InvalidOperationException ex)
{
return Results.Conflict(ex.Message);
}
});
app.MapPut("/tasks/{id:int}", (int id, UpdateTaskRequest request, ITaskRepository repo) =>
repo.Update(id, request.Title, request.IsComplete) ? Results.NoContent() : Results.NotFound());
app.MapDelete("/tasks/{id:int}", (int id, ITaskRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
app.Run();
// ── Configuration ──
public class TaskApiOptions
{
public int MaxTasks { get; set; } = 100;
}
// ── Model ──
public class TaskItem
{
public int Id { get; set; }
public required string Title { get; set; }
public bool IsComplete { get; set; }
}
record CreateTaskRequest(string Title);
record UpdateTaskRequest(string Title, bool IsComplete);
// ── Repository ──
public interface ITaskRepository
{
IReadOnlyList<TaskItem> GetAll();
TaskItem? GetById(int id);
TaskItem Add(string title);
bool Update(int id, string title, bool isComplete);
bool Delete(int id);
}
public class InMemoryTaskRepository(
IOptions<TaskApiOptions> options,
ILogger<InMemoryTaskRepository> logger) : ITaskRepository
{
private readonly List<TaskItem> _tasks = [];
private readonly Lock _lock = new();
private int _nextId = 1;
private readonly int _maxTasks = options.Value.MaxTasks;
public IReadOnlyList<TaskItem> GetAll()
{
lock (_lock) { return _tasks.ToList(); }
}
public TaskItem? GetById(int id)
{
lock (_lock) { return _tasks.FirstOrDefault(t => t.Id == id); }
}
public TaskItem Add(string title)
{
lock (_lock)
{
if (_tasks.Count >= _maxTasks)
{
logger.LogWarning("Task limit of {MaxTasks} reached; rejecting new task", _maxTasks);
throw new InvalidOperationException($"Cannot add more than {_maxTasks} tasks.");
}
var task = new TaskItem { Id = _nextId++, Title = title, IsComplete = false };
_tasks.Add(task);
logger.LogInformation("Created task {TaskId} — {Title}", task.Id, task.Title);
return task;
}
}
public bool Update(int id, string title, bool isComplete)
{
lock (_lock)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null)
{
logger.LogWarning("Update failed — task {TaskId} not found", id);
return false;
}
task.Title = title;
task.IsComplete = isComplete;
logger.LogInformation("Updated task {TaskId}", id);
return true;
}
}
public bool Delete(int id)
{
lock (_lock)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task is null) return false;
_tasks.Remove(task);
logger.LogInformation("Deleted task {TaskId}", id);
return true;
}
}
}
Run it with dotnet run, then try it with curl (or any REST client):
curl -X POST http://localhost:5000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Finish the REST API lesson"}'
# 201 Created — { "id": 1, "title": "Finish the REST API lesson", "isComplete": false }
curl http://localhost:5000/tasks
# 200 OK — [ { "id": 1, "title": "...", "isComplete": false } ]
curl -X PUT http://localhost:5000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title":"Finish the REST API lesson","isComplete":true}'
# 204 No Content
curl -X DELETE http://localhost:5000/tasks/1
# 204 No Content
Watch the console while you run these — every request produces a log line from the repository, exactly the way structured logging is meant to work: not decoration, but a real trail of what happened and when.
The API above satisfies the brief, but real APIs keep growing. Each challenge below builds on what's already there — try it before checking the hint.
Challenge 1 — Filter by completion stateEasy
Support GET /tasks?completed=true to return only completed (or only incomplete) tasks.
Add a nullable bool? completed parameter to the GET /tasks delegate — minimal APIs bind query string parameters automatically by matching parameter names. Then filter the result with a LINQ Where only when completed.HasValue.
Challenge 2 — A PATCH-style "mark complete" endpointEasy
Add POST /tasks/{id}/complete that flips a task's IsComplete to true without requiring the client to resend the whole task.
Add a MarkComplete(int id) method to ITaskRepository that looks up the task, sets IsComplete = true, logs it, and returns a bool for found/not-found — same pattern as Delete.
Challenge 3 — PaginationMedium
Support GET /tasks?page=2&pageSize=10 so clients never have to fetch the entire list at once.
Add int page = 1, int pageSize = 20 parameters (default values act as the fallback when the query string omits them). Apply LINQ's Skip((page - 1) * pageSize).Take(pageSize) to the full list before returning it.
Challenge 4 — Swap in async storageMedium
Change every method on ITaskRepository to return a Task<T> and be awaited from the endpoints, even though the in-memory implementation has no real I/O to await yet.
Make each interface method async Task<...> (or just return Task.FromResult(...) from a synchronous body). Update every app.MapGet/etc. delegate to async and add await before each repository call. This is intentionally busywork now — but it's exactly the shape you'll need once the next project swaps this repository for a real, awaited EF Core database call.
Challenge 5 — Validate with data annotationsHard
Instead of a manual string.IsNullOrWhiteSpace check, decorate CreateTaskRequest.Title with [Required] and a [MaxLength(200)] from System.ComponentModel.DataAnnotations, and reject a request that fails validation with a 400 that lists every broken rule.
Minimal APIs don't run data annotation validation automatically the way MVC controllers do — you either call Validator.TryValidateObject yourself at the top of the endpoint, or add the Microsoft.AspNetCore.Http.Abstractions-based validation filter available in newer ASP.NET Core versions. Either way, collect every ValidationResult and return them together in one Results.ValidationProblem(...) response, rather than stopping at the first failure.
WebApplication.CreateBuilder is the same builder/host pattern from the generic host lessons, with routing and Kestrel added — nothing about DI or configuration changes just because HTTP is involved.200, 201, 204, 400, 404, and 409 each mean something specific, and choosing correctly is what makes an API RESTful.ILogger<T> turns a black-box service into one you can actually observe while it runs.You've built a working REST API from scratch, using nothing but concepts you already had — DI, configuration, and logging, now serving real HTTP traffic. Next up: giving it a real database.
dotnetmadeeasy.com — Learn C# and .NET, the right way.