.NET Academy

Capstone 2: build TaskFlow

Implement the TaskFlow API step by step — solution setup, EF Core with PostgreSQL, endpoints, validation, authentication, background email and tests.

40 min read

You will learn

  • Set up a multi-project solution with PostgreSQL in Docker
  • Implement entities, migrations and CRUD endpoints with validation
  • Secure the API with JWT authentication and membership-based authorization
  • Send notifications in the background and cover the API with tests

How to use this lesson

This is a build guide, not copy-paste code. Each milestone lists what to build, pointers to the lessons that cover it, and the essential snippets. Commit after every milestone, and keep dotnet test green.

Milestone 1 — Solution setup

mkdir TaskFlow && cd TaskFlow
git init
dotnet new gitignore
dotnet new sln -n TaskFlow
 
dotnet new web -o src/TaskFlow.Api
dotnet new xunit -o tests/TaskFlow.Api.Tests
dotnet sln add src/TaskFlow.Api tests/TaskFlow.Api.Tests
dotnet add tests/TaskFlow.Api.Tests reference src/TaskFlow.Api
 
cd src/TaskFlow.Api
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Start PostgreSQL for development:

docker run -d --name taskflow-db -e POSTGRES_PASSWORD=dev-only -e POSTGRES_DB=taskflow -p 5432:5432 postgres:16
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:TaskFlow" "Host=localhost;Database=taskflow;Username=postgres;Password=dev-only"

Covered in: The dotnet CLI · NuGet · Configuration · Docker.

Milestone 2 — Data model and migrations

Create the entities from your design and a DbContext:

public class TaskFlowDb(DbContextOptions<TaskFlowDb> options) : DbContext(options)
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Project> Projects => Set<Project>();
    public DbSet<ProjectMember> Members => Set<ProjectMember>();
    public DbSet<TaskItem> Tasks => Set<TaskItem>();
    public DbSet<Comment> Comments => Set<Comment>();
 
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<User>().HasIndex(u => u.Email).IsUnique();
        b.Entity<ProjectMember>().HasKey(m => new { m.ProjectId, m.UserId });
        b.Entity<TaskItem>().HasIndex(t => new { t.ProjectId, t.Status });
        b.Entity<TaskItem>().HasIndex(t => new { t.AssigneeId, t.Status, t.DueDate });
        b.Entity<TaskItem>().Property(t => t.Title).HasMaxLength(200);
        b.Entity<TaskItem>().Property(t => t.Status).HasConversion<string>();
    }
}
builder.Services.AddDbContext<TaskFlowDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("TaskFlow")));
dotnet ef migrations add InitialCreate
dotnet ef database update

Covered in: EF Core basics · Relationships · Indexes and transactions.

Milestone 3 — Endpoints

Implement the API from your design, one feature at a time (projects → tasks → comments → "my tasks"). Organize with route groups and feature files:

public static class TaskEndpoints
{
    public static void MapTaskEndpoints(this IEndpointRouteBuilder app)
    {
        var tasks = app.MapGroup("/projects/{projectId:int}/tasks").RequireAuthorization();
 
        tasks.MapGet("/", async (int projectId, TaskStatus? status, int limit, int? after,
                                 TaskFlowDb db, CancellationToken ct) =>
        {
            limit = Math.Clamp(limit == 0 ? 20 : limit, 1, 100);
            var items = await db.Tasks.AsNoTracking()
                .Where(t => t.ProjectId == projectId && (status == null || t.Status == status))
                .Where(t => after == null || t.Id > after)
                .OrderBy(t => t.Id)
                .Take(limit)
                .Select(t => new TaskDto(t.Id, t.Title, t.Status, t.DueDate, t.AssigneeId))
                .ToListAsync(ct);
            return TypedResults.Ok(items);
        });
 
        // POST, PATCH, DELETE …
    }
}

Requirements for every endpoint:

  • DTOs in and out — never expose entities directly.
  • Validation (title required, ≤ 200 chars, due date not in the past…) → 400 with Problem Details.
  • Correct status codes: 201 + Location on create, 204 on delete, 404 for unknown ids.
  • CancellationToken passed to EF Core.

Covered in: Designing REST APIs · Minimal APIs · Controllers (validation) · Cancellation.

Milestone 4 — Authentication and authorization

  1. Add JWT bearer authentication. For development, issue tokens with dotnet user-jwts create — or implement /auth/register and /auth/login with ASP.NET Core Identity's password hasher and your own token issuing (keep the signing key in user secrets).
  2. Require authentication on everything except register/login.
  3. Enforce membership: a user may only access projects they're a member of. Return 404 (not 403) for projects they can't see, so ids can't be probed.
  4. Only the owner may delete a project or remove members — a policy or a resource-based check.
static async Task<bool> IsMemberAsync(TaskFlowDb db, int projectId, ClaimsPrincipal user, CancellationToken ct) =>
    await db.Members.AnyAsync(m => m.ProjectId == projectId && m.UserId == user.GetUserId(), ct);

Covered in: Authentication · OAuth & OIDC · Web security · ASP.NET Core auth.

Milestone 5 — Background notifications

Assigning a task must not wait for the email server:

  1. Define IEmailSender with a development implementation that logs instead of sending.
  2. When a task's assignee changes, write a TaskAssigned message to a bounded Channel<T> (singleton).
  3. A BackgroundService reads the channel and sends the email, with a retry (exponential backoff) and logging.
  4. Stretch: replace the channel with an outbox table written in the same transaction, so no notification is lost on a crash.

Covered in: Thread safety and channels · Queues and background jobs · Resilience · Dependency injection.

Milestone 6 — Tests

  • Unit tests for pure logic (validation rules, status transitions).
  • Integration tests with WebApplicationFactory<Program> (add public partial class Program; at the end of Program.cs) and a real PostgreSQL via Testcontainers:
    • creating a task returns 201 and a Location header,
    • invalid input returns 400,
    • a non-member gets 404,
    • assigning a task enqueues exactly one notification (replace IEmailSender with a spy).
dotnet add tests/TaskFlow.Api.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add tests/TaskFlow.Api.Tests package Testcontainers.PostgreSql
dotnet test

Covered in: Unit testing · Test doubles · TDD and integration testing.

Milestone 7 — Polish

  • Structured logging for important events (task created, assigned, deleted).
  • /health endpoint with a database check.
  • OpenAPI document and a .http file with example requests.
  • Rate limiting on /auth/login.
  • Review your queries in the SQL log: no N+1, indexes used.

Stuck?

Go back to the lesson listed under each milestone, or try the idea in this course's playground first. Building it yourself — including the debugging — is where the learning happens.

Checklist

  • Solution, projects and PostgreSQL running locally; secrets in user secrets
  • Entities, DbContext, indexes and migrations applied
  • All endpoints from the design with DTOs, validation and correct status codes
  • JWT authentication, membership checks and owner-only operations
  • Assignment emails sent from a background service with retries
  • Unit and integration tests passing with dotnet test
  • Health check, logging, OpenAPI and rate limiting

Mark this lesson complete, then continue to Capstone 3: ship.

Finished reading? Mark the lesson as complete to track your progress.