Task Management REST API
Build a task management REST API in C#: clean architecture, JWT authentication, EF Core and tests you can run.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Advanced Project — Clean Architecture, JWT Auth, EF Core, Testing
🧠 Project Overview
Every API that stores something on behalf of a signed-in person has two hard problems, and neither of them is CRUD. The first is that HTTP has no memory: each request arrives knowing nothing about the last one, so the caller has to carry proof of who they are on every single call. The second is that proving identity is only half the job. An endpoint that hands back a task because the caller is logged in, rather than because the task belongs to them, is a data breach that returns 200 OK. This project is about getting both halves right and keeping them in one place.
What you are building is a task management REST API in ASP.NET Core, arranged in Clean Architecture layers. A TodoItem entity owns the business rules and knows nothing about HTTP or databases. A handler per use case — create, complete, delete, query — sits above it and does the coordinating. A thin controller sits above that and does nothing except translate an HTTP request into one of those use cases. Underneath, EF Core turns entities into rows, and a JWT issued at login is what tells every layer whose data it is looking at.
When it works, the whole thing behaves like a product. You register, log in, and receive a signed token. You send that token as a Bearer header on a POST with a title and a priority, and you get back 201 Created with the new id. You list your tasks and get only your own rows, filtered and paged. You complete one and get 204 No Content, and the record now carries a completion timestamp it did not have before. Then you take that same id, send it with a different user's token, and the API tells you it does not exist — because as far as that user is concerned, it does not.
Everyone who has built a back end has built this. It is the skeleton under task apps and issue trackers, but far more importantly it is the same skeleton as an orders API, a bookings API, or an internal service that stores records per customer. Swap the noun and the structure does not change: authenticate, scope the data to the caller, validate at the edge, enforce the rules in the domain, and keep the controller too boring to hide a bug in.
Treat the three code blocks below as one slice through that stack rather than a finished program. They deliberately show one layer each — entity, controller, test — so you can see how little each layer needs to know about the others. Several supporting types they reference are yours to write, and the page names each one as it comes up.
🎯 Requirements
- • CRUD operations for tasks with filtering and pagination
- • JWT authentication — register, login, refresh tokens
- • Role-based authorization (Admin vs User)
- • EF Core with repository pattern and migrations
- • Input validation with FluentValidation
- • Unit tests for all use case handlers
- • Swagger/OpenAPI documentation
🧩 Core Concepts
Clean Architecture, and which way the arrows point
Clean Architecture is one rule wearing a lot of diagrams: dependencies point inwards, towards the business rules, never outwards towards the framework. The domain project references nothing. The application layer references the domain. The API and the database code reference the application. The practical test is whether you could delete the web project and still compile your rules — if you can, the boundary is real, and swapping SQL Server for Postgres or REST for a message queue becomes an outer-layer job instead of a rewrite.
Stateless authentication and what a token actually is
A JWT is three base64 segments — a header, a set of claims, and a signature — joined by dots. The server does not remember issuing it; it re-verifies the signature on every request, and if the signature checks out it trusts the claims inside. That is why the payload is signed but not secret: anyone holding the token can read the claims, so a token carries an id and a role, never a password or anything you would mind seeing in a log file.
Authentication is not authorization
Authentication answers "who is this", authorization answers "may they do this to this particular row". The [Authorize] attribute only does the first. If you stop there, any signed-in user can pass any task id to your delete endpoint and it will work — the class of flaw usually called broken object level authorization, and the most common serious bug in hand-written APIs. Every command and query on this page carries a user id for exactly that reason.
Commands, queries and a mediator
Instead of one fat service class with a method per endpoint, each operation becomes a small message — a command that changes something, or a query that reads something — plus a handler that processes it. MediatR is a library that finds the right handler for a given message, so the controller only has to construct the message and send it. The payoff is that every use case is a separate testable class with an obvious name, and cross-cutting work like validation or logging can be added as a pipeline step that runs before every handler.
Entities that enforce their own rules
The alternative to a domain entity is an anaemic bag of public properties with the rules spread across whichever service happened to need them. Here the rules live inside the object: the setters are private, so the only way to change a task is to call a method, and every method checks before it mutates. A rule that cannot be bypassed is worth far more than a rule that is merely written down in three services.
The repository interface belongs to the domain
ITodoRepository is declared in the inner layers and implemented in the outer one. That inversion is what lets the tests in Step 3 run with no database at all, and it is why the interface should speak in domain terms — add this task, fetch this user's tasks — rather than exposing queryables and letting callers assemble SQL shaped by accident.
DTOs, because the entity is not the contract
CreateTodoDto exists so that the shape of your JSON and the shape of your database rows can change independently. It also closes a real hole: if a request binds straight onto an entity, a caller can post fields you never meant to expose, such as an id or someone else's user id, and the model binder will happily fill them in. A DTO with exactly the fields a client may send makes that impossible by construction.
Status codes are part of your API
REST clients branch on the status line long before they read your body. 201 with a Location header means "created, and here it is"; 204 means "done, nothing to say"; 400 means the request was malformed; 401 means "you are not signed in"; 403 means "you are, but no"; 404 means it is not there — and, deliberately, is also what you return for a resource that exists but is not the caller's, so the API does not confirm which ids other people own.
Step 1: Domain Entity
Start with the domain entity — it enforces business rules and is independent of any framework. Read the property list first and notice what is absent: there is not one public setter. Every property is private set, which means no code outside this class can change a title, flip a completion flag, or reassign an owner. The only doors into the object are the three methods at the bottom, and each of them checks the rules before it touches anything.
The static Create method is a factory, and it is written that way because a constructor is a poor place to refuse. Create validates the title before it builds anything, trims it, stamps CreatedAt in UTC, generates the id, and records the owner. The result is a guarantee you can lean on everywhere else in the system: if a TodoItem exists, it has a non-empty title and an owner, because there is no other way for one to come into being.
MarkComplete is where a task stops being a data structure and starts being a model of something real. It refuses to complete an already completed task, and it sets CompletedAt in the same breath as IsCompleted, so the flag and the timestamp can never disagree. Two fields that must move together are exactly the kind of thing that drifts apart when callers set properties directly, and exactly what a method prevents.
Description being declared as string? while Title is not is a deliberate signal, not a typo. With nullable reference types switched on, the question mark is you telling the compiler — and the next reader — that a missing description is normal and a missing title is a bug. The ?.Trim() on the description follows from that: trim it if it is there, leave it null if it is not.
// Domain/Entities/TodoItem.cs
public class TodoItem
{
public Guid Id { get; private set; }
public string Title { get; private set; } = "";
public string? Description { get; private set; }
public bool IsCompleted { get; private set; }
public Priority Priority { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? CompletedAt { get; private set; }
public string UserId { get; private set; } = "";
public static TodoItem Create(string title, string? description,
Priority priority, string userId)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Title is required");
return new TodoItem
{
Id = Guid.NewGuid(),
Title = title.Trim(),
Description = description?.Trim(),
Priority = priority,
UserId = userId,
CreatedAt = DateTime.UtcNow,
IsCompleted = false
};
}
public void MarkComplete()
{
if (IsCompleted) throw new DomainException("Already completed");
IsCompleted = true;
CompletedAt = DateTime.UtcNow;
}
public void Update(string title, string? description, Priority priority)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Title is required");
Title = title.Trim();
Description = description?.Trim();
Priority = priority;
}
}
public enum Priority { Low, Medium, High, Critical }The mistake this design prevents is the one nearly every first API makes: validating the title in the controller. Do that and the rule lives at the edge, so the next entry point — an import job, a background worker, a second endpoint written in a hurry — writes a blank task straight past it. Validating at the edge for a fast error message is fine and desirable; the domain check is the one that has to be there when the edge is bypassed.
Two gaps here are worth arguing with, and you should. Because the class declares no constructor of its own, C# supplies a public parameterless one, so new TodoItem() compiles anywhere and produces an object with an empty id and no owner — the exact invalid state the factory was built to prevent. Add a private parameterless constructor to close it; EF Core is happy to use a non-public one when it materialises rows. And Update will cheerfully rewrite a task that is already completed, which may be what you want, but it should be a decision you made rather than one you inherited.
One more, and it bites later rather than now: MarkComplete throwing on a second call makes the completion endpoint non-idempotent. A phone on a bad connection retries; a user double-taps; the second call fails on a task that is already in the state the caller asked for. Decide early whether that should be a silent success, a 409 Conflict, or a rule you keep — but decide, because the client will hit it.
Step 2: API Controller
The controller is thin — it delegates to use case handlers via MediatR and extracts the user ID from the JWT claims. Read the four actions and notice that not one of them contains a business rule. There is no title check, no comparison, no branch on priority. Each action does the same four things: read the caller's id, build a message, send it, and translate the outcome into an HTTP response. If you deleted this class, no rule would be lost — which is the test of whether a controller is the right thickness.
The attributes at the top do a surprising amount of work. [ApiController] turns on conventions worth knowing: model state is validated automatically, so a request that fails validation is rejected with a 400 and a problem-details body before your action runs at all, and binding sources are inferred, so a complex parameter is read from the request body unless you say otherwise. [Route("api/[controller]")] substitutes the class name minus its Controller suffix, giving these endpoints an api/todo prefix. [Authorize] applied to the class covers every action inside it, which is the right default — opting one endpoint out later is a visible decision, whereas forgetting to opt one in is invisible.
That inferred body binding is precisely why GetAll marks its filter [FromQuery]. TodoFilter is a complex type, so without the attribute the framework would look for it in the body of a GET request and find nothing. With it, the filter's properties are populated from the query string, which is how paging and filtering belong in a URL that a client can bookmark, cache and share.
The line repeated in all four actions is the important one: User.FindFirst(ClaimTypes.NameIdentifier) pulls the user id out of the validated token and passes it into the message. The client never sends a user id and could not be trusted with one if it did. This is what makes the handler able to scope its query, and it is the difference between an API where you see your tasks and an API where you see everybody's.
The route constraints are load-bearing too. Writing the id segment as a guid-constrained parameter means a request with a non-guid id fails to match the route at all and comes back as a 404, so your handler never receives garbage. Note the effect on status codes: a malformed id reads as "no such route" rather than "bad request", which is worth knowing before you spend an afternoon wondering why a typo produced a 404.
// Api/Controllers/TodoController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TodoController : ControllerBase
{
private readonly IMediator _mediator;
public TodoController(IMediator mediator) => _mediator = mediator;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] TodoFilter filter)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var result = await _mediator.Send(
new GetTodosQuery(userId, filter));
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateTodoDto dto)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var id = await _mediator.Send(
new CreateTodoCommand(dto.Title, dto.Description,
dto.Priority, userId));
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:guid}/complete")]
public async Task<IActionResult> Complete(Guid id)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
await _mediator.Send(new CompleteTodoCommand(id, userId));
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
await _mediator.Send(new DeleteTodoCommand(id, userId));
return NoContent();
}
}The mistake to avoid here is taking the id of the acting user from anything the client controls — a body field, a query parameter, a custom header. It looks harmless in development, where you are the only user, and it means anyone can act as anyone the moment the API is public. The token is the only trustworthy source, because the server verified its signature; everything else in the request is just something a stranger typed.
The null-forgiving ! on the claim lookup is a promise to the compiler that this claim is always present, and promises like that are worth checking. Whether the subject claim arrives under ClaimTypes.NameIdentifier depends on the inbound claim-type mapping your token handler is using; projects that clear the default map keep the original short claim names instead, in which case this lookup returns null and the ! converts a clear configuration mistake into a null reference exception and a 500. Read the id once in a small helper that fails loudly and deliberately, and use it in all four actions.
CreatedAtAction is doing the right thing for the wrong target. It builds a Location header by generating a URL for the named action, but GetAll's route has no id segment for the value to fill, so the id ends up appended to the collection URL rather than pointing at the new resource. Add a get-by-id action and name that instead; you want a Location a client can actually follow.
Finally, returning IActionResult everywhere tells Swagger nothing about what comes back, so your generated documentation shows no response schema. Either declare the response types with the produces-response-type attribute or return the generic action-result form so the shape is inferred. Documentation that is generated from the code stays true; documentation that is written beside it does not.
Step 3: Unit Tests
Test every use case handler with Moq. Verify that domain rules are enforced and repositories are called correctly. These tests are fast because of a decision made two steps ago: the handler depends on an interface, not on EF Core, so the constructor here hands it a stand-in and the whole suite runs in memory with no database, no server and no configuration file.
A mock is a fake implementation that also records what was done to it. Mock<ITodoRepository> generates a class implementing your interface; .Object is the instance you inject; Setup says what a given call should return; and Verify asserts afterwards that a call was or was not made. The setup returning a completed task matters because the handler awaits that call — a fake that hands back nothing awaitable would fail for reasons that have nothing to do with your code.
Look at what the first test actually asserts. It does not merely check that AddAsync was called; it checks that it was called once, with a TodoItem whose title and priority are the ones from the command. That is the difference between "something happened" and "the right thing happened", and it is the assertion that catches a handler which quietly drops a field on the way through.
The second test is the more valuable of the pair, and it is the one beginners leave out. It proves two separate things: that an empty title is refused with your own DomainException rather than some generic failure, and — through the Times.Never check — that nothing was written on the way to that refusal. A rejection test without that second half can pass while the handler saves a broken row and then throws.
// Tests/TodoServiceTests.cs
using Moq;
using Xunit;
public class CreateTodoHandlerTests
{
private readonly Mock<ITodoRepository> _mockRepo;
private readonly CreateTodoHandler _handler;
public CreateTodoHandlerTests()
{
_mockRepo = new Mock<ITodoRepository>();
_handler = new CreateTodoHandler(_mockRepo.Object);
}
[Fact]
public async Task Handle_ValidCommand_CreatesTodo()
{
var command = new CreateTodoCommand(
"Buy groceries", "Milk and eggs", Priority.Medium, "user-1");
_mockRepo.Setup(r => r.AddAsync(It.IsAny<TodoItem>()))
.Returns(Task.CompletedTask);
var result = await _handler.Handle(command, CancellationToken.None);
Assert.NotEqual(Guid.Empty, result);
_mockRepo.Verify(r => r.AddAsync(It.Is<TodoItem>(t =>
t.Title == "Buy groceries" &&
t.Priority == Priority.Medium)), Times.Once);
}
[Fact]
public async Task Handle_EmptyTitle_ThrowsDomainException()
{
var command = new CreateTodoCommand("", null, Priority.Low, "user-1");
await Assert.ThrowsAsync<DomainException>(
() => _handler.Handle(command, CancellationToken.None));
_mockRepo.Verify(r => r.AddAsync(It.IsAny<TodoItem>()), Times.Never);
}
}The mistake here is testing the framework instead of your logic. Tests that spin up a web host to confirm that a route maps or that model binding works are testing ASP.NET Core, which already has its own tests. Point yours at the decisions you wrote: what happens with an empty title, what happens when the task belongs to someone else, what happens when the same task is completed twice. Add integration tests later for wiring, but keep them a thin layer on top rather than the bulk of the suite.
Notice too where the rule being exercised by the second test actually lives. The exception comes out of TodoItem.Create, not out of the handler, so this test would still pass if the handler contained no validation at all — which is exactly the design you want, but it means the test tells you the system refuses empty titles without telling you where. Test the entity directly as well, and you will know which layer broke the moment one of them does.
The assertion that the returned id is not the empty guid is weaker than it looks: it proves a guid was produced, not that it belongs to the item that was saved. Capture the entity that reaches the repository with a Moq callback and compare its id to the returned value, so the test fails if the handler ever returns a different id from the one it stored.
Then write the tests this pair is missing, because they are the ones covering the risk that matters. Completing a task that belongs to another user must not succeed. Deleting an id that does not exist must produce whatever your API has decided that means. Completing an already-completed task must do the thing you chose in Step 1. Each is a handful of lines, and together they are worth more than any coverage percentage.
📋 Common Errors
CS0246: The type or namespace name 'DomainException' could not be found
The most common first error on this page, and it is not a bug. The entity in Step 1 throws DomainException, and the code here does not define it — it is yours. Write a small class deriving from Exception with a constructor taking a message. The same error appears for TodoFilter, CreateTodoDto, the command and query types and ITodoRepository, all of which are types this slice references and expects you to supply.
CS0246: The type or namespace name 'IMediator' could not be found
Different cause, same message: this one is a missing package rather than a missing file. MediatR is a NuGet dependency, and installing it is only half the job — its handlers also have to be registered with the dependency injection container at startup, or the types will compile and then fail to resolve at runtime.
CS0272: The property or indexer 'TodoItem.IsCompleted' cannot be used in this context because the set accessor is inaccessible
You tried to assign a property from outside the entity, usually from a handler taking a shortcut. This is the design working, not an obstacle to route around. Call MarkComplete or Update instead, and if you need a change neither of them expresses, add a new method with a name that says what it means rather than widening the setter.
InvalidOperationException: Unable to resolve service for type 'MediatR.IMediator' while attempting to activate 'TodoController'
A runtime error on the first request rather than a compile error, and the shape of it is worth memorising because you will see it for every service you forget. The controller asks the container for a dependency the container was never told about. Register MediatR and your repository implementation during startup; the same message with ITodoRepository in it means you registered the interface's implementation nowhere or in the wrong lifetime scope.
401 Unauthorized on every endpoint, even with a valid token
Nearly always one of three things. The authentication middleware is missing or placed after the authorization middleware in the pipeline, so nothing has established an identity by the time the check runs. Or the token's issuer, audience or signing key does not match what the bearer options were configured to accept. Or the header is malformed — it must be the word Bearer, a single space, then the token. Turning on detailed token validation logging tells you which of the three in seconds.
403 Forbidden when you expected 401
These mean different things and the distinction saves debugging time. A 401 says the request carried no usable identity. A 403 says it did, and that identity is not allowed — so your token is valid and the role or policy requirement failed. When you add the Admin role, check that the role claim in the token uses the claim type your authorization policy is actually looking for.
NullReferenceException: Object reference not set to an instance of an object
Thrown from the claim lookup in the controller, and it surfaces to the client as a 500. The claim you asked for is not in the token under that name — either it was never issued, or the inbound claim-type mapping in effect is not the one you assumed. The null-forgiving operator silenced the compiler warning that would have pointed at this line before it ran.
400 Bad Request with a validation problem-details body you did not write
That is the automatic model validation that comes with the ApiController attribute, and it runs before your action. If a field you consider optional is being rejected, the cause is usually a non-nullable reference property on your DTO, which the framework treats as required. Make genuinely optional fields nullable and the response goes away.
Moq.MockException: Expected invocation on the mock once, but was 0 times
Your handler did not call the repository method the way the test expected. When the invocation count is zero but you can see the call in the handler, the usual culprit is the argument matcher: an It.Is predicate that does not match the object actually passed, often because Create trimmed the title or the priority defaulted. Loosen the matcher to It.IsAny to confirm the call happens at all, then tighten it back until you find the field that differs.
InvalidOperationException: No suitable constructor was found for entity type 'TodoItem'
From EF Core, when it needs to materialise rows into entities and cannot see a constructor it can call. It appears the moment you add a parameterised constructor and remove the parameterless one. Give the entity a private parameterless constructor — EF Core will use a non-public one, and doing so also closes the invalid-object hole described in Step 1.
A user can read or delete another user's task
Not an exception, and by a wide margin the worst thing on this list, because nothing at all goes wrong at runtime. It means a handler looked a task up by id and acted on it without comparing its UserId to the caller's. The fix belongs in the handler and the query, never in the controller: load by id and owner together, and return a not-found result when the pair does not match. Write the test that proves it before you write anything else.
🚀 Enhancement Ideas
1. Add the get-by-id endpoint the create action is missing
Add an action that takes a guid-constrained id, returns the task when it belongs to the caller, and returns not-found otherwise. Then point CreatedAtAction at it so a POST hands back a Location header a client can actually follow. This is the smallest change on the list and it fixes a real defect in the code above.
2. Make validation a pipeline step instead of a habit
Write a FluentValidation validator per command, then register a MediatR pipeline behaviour that runs any validator matching the incoming message before its handler. Validation then happens for every use case automatically, including the ones you write next year and forget to wire up. Keep the domain checks anyway — the pipeline gives good error messages, the entity gives guarantees.
3. Map exceptions to responses in one place
Add exception-handling middleware that turns DomainException into a 400 with a problem-details body, a not-found exception into a 404, and everything else into a 500 that logs the detail but does not return it. Try-catch blocks in controllers are how error formats drift apart across an API; one handler at the edge keeps every failure looking the same to clients.