A REST API for multi-user task management built with .NET 9, Clean Architecture, and PostgreSQL.
Demonstrate proficiency in:
- REST API Design
- Authentication with JWT
- Entity Framework Core persistence
- Clean Architecture patterns
- Docker containerization
- Unit and integration testing
The project follows Clean Architecture with four distinct layers:
TaskFlow.Domain/ β Entities, Enums, Interfaces
TaskFlow.Application/ β DTOs, Services, Business Logic
TaskFlow.Infrastructure/ β EF Core, Repositories, JWT
TaskFlow.API/ β Controllers, Endpoints
| Category | Technology |
|---|---|
| Framework | .NET 9.0 |
| API | ASP.NET Core Web API |
| Database | PostgreSQL |
| ORM | Entity Framework Core (migrations) |
| Authentication | JWT Bearer |
| Documentation | Swagger/OpenAPI (Development only) |
| Container | Docker |
| Testing | xUnit + FluentAssertions, SQLite in-memory for integration tests |
| CI | GitHub Actions |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/register |
Register new user (201 Created with a token) |
| POST | /api/auth/login |
Login and get JWT |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/projects |
List user's projects (paged) |
| POST | /api/projects |
Create project |
| GET | /api/projects/{id} |
Get project details |
| PUT | /api/projects/{id} |
Update project |
| DELETE | /api/projects/{id} |
Soft delete project |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/projects/{id}/tasks |
List tasks (paged, filter by status) |
| POST | /api/projects/{id}/tasks |
Create task |
| GET | /api/projects/{id}/tasks/{taskId} |
Get task details |
| PUT | /api/projects/{id}/tasks/{taskId} |
Update task |
| PATCH | /api/projects/{id}/tasks/{taskId}/complete |
Mark task complete |
| DELETE | /api/projects/{id}/tasks/{taskId} |
Soft delete task |
| Method | Endpoint | Description |
|---|---|---|
| GET | /health/live |
Liveness: the process is up (no dependency checks) |
| GET | /health/ready |
Readiness: the database is reachable (503 otherwise) |
Both health endpoints are anonymous and are not rate limited.
Every error is an RFC 7807 application/problem+json response.
| Status | When |
|---|---|
| 400 | Validation failed (body is a ValidationProblemDetails with an errors map), invalid status value, invalid paging values |
| 401 | Missing/invalid token, a token whose user no longer exists, or wrong credentials (the message is the same for a wrong password and an unknown email) |
| 404 | Resource does not exist, is soft-deleted, or belongs to another user (never 403, so ids of other users' data are not revealed) |
| 409 | Email already registered (comparison is case-insensitive) |
| 429 | Auth rate limit exceeded (includes a Retry-After header) |
| 500 | Unexpected error. The body is generic outside Development; details are only written to the server log |
| Field | Rule |
|---|---|
| Required, valid address, max 254 characters. Stored lower-cased and trimmed | |
| Password (register) | 8 characters minimum, 72 UTF-8 bytes maximum (BCrypt ignores anything longer; multi-byte characters use several bytes) |
| Password (login) | Max 128 characters (lenient so older accounts can still log in) |
| User name | Optional, max 100 characters |
| Project name / description | Name required, max 100; description max 1000. On update, a provided name cannot be blank |
| Task title / description | Title required, max 200; description max 2000. On update, a provided title cannot be blank |
| Task status | Exactly Pending, InProgress or Completed (case-insensitive). Numbers and unknown values are rejected with 400 |
Enums are written as their names in every response, the same names requests and filters accept: a task's status is "Pending", "InProgress" or "Completed" (never 0, 1, 2), and a user's role is "User" or "Admin". For example: {"id":1,"projectId":1,"title":"First Task","status":"Pending", ...}.
GET /api/projects/{id}/tasks?status=pending,inprogressaccepts one or more comma-separated statuses. An invalid value returns400; it never falls back to "all tasks".- Both list endpoints accept
page(default1) andpageSize(default50, maximum100). The body stays a plain JSON array; the total number of items is in theX-Total-Countresponse header (exposed to CORS clients). Invalid values return400.
- .NET 9.0 SDK
- Docker & Docker Compose (for the container setup)
- PostgreSQL (only for running the API outside Docker)
-
Copy environment template:
cp .env.example .env
-
Edit
.envwith your values:POSTGRES_PASSWORD=YourSecurePassword123! JWT_SECRET_KEY=YourSuperSecretKeyThatIsAtLeast32CharactersLong! ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com # Optional, only when the API runs behind a reverse proxy (see "Reverse proxy") FORWARDED_HEADERS_KNOWN_PROXIES= FORWARDED_HEADERS_KNOWN_NETWORKS=
.env.examplein the repository may still lackPOSTGRES_PASSWORD,JWT_SECRET_KEYand the twoFORWARDED_HEADERS_*variables. Add them to it by hand. -
Generate a secure JWT key:
# Linux/Mac openssl rand -base64 32 # Windows (PowerShell) [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
# Build and start containers
docker compose up --build
# API: http://localhost:5000
# Health: http://localhost:5000/health/readydocker composerefuses to start unlessPOSTGRES_PASSWORDandJWT_SECRET_KEYare set.- The compose file runs the API with
ASPNETCORE_ENVIRONMENT=Production, so Swagger is not available in this setup. - PostgreSQL is reachable only from the API container: port 5432 is not published on the host. For debugging, add a loopback-only mapping (
127.0.0.1:5432:5432) to thepostgresservice. - The API image runs as a non-root user and has a container health check on
/health/live.
You need a reachable PostgreSQL database and two required environment variables.
# Linux/Mac
export JWT_SECRET_KEY="a-secret-of-at-least-32-bytes-................"
export DATABASE_CONNECTION_STRING="Host=localhost;Database=taskflow;Username=postgres;Password=..."
# Windows (PowerShell)
$env:JWT_SECRET_KEY="a-secret-of-at-least-32-bytes-................"
$env:DATABASE_CONNECTION_STRING="Host=localhost;Database=taskflow;Username=postgres;Password=..."
dotnet restore
dotnet run --project TaskFlow.API- With the bundled launch profile the environment is
Development: the API listens onhttp://localhost:5219and Swagger UI is at/swagger. Without a launch profile (and withoutASPNETCORE_URLS) it listens on port5000inProduction, without Swagger. - Swagger is registered only when the environment is
Development. - Pending migrations are applied automatically at startup (see below); you do not need
dotnet ef database update.
The API calls Database.MigrateAsync() at startup: it creates the database if needed and applies every pending EF Core migration. Earlier versions called EnsureCreated() instead.
A database created by that old code has all the tables but no __EFMigrationsHistory table, so the first MigrateAsync() fails with relation "Users" already exists. Pick one:
- Throw the data away (development):
docker compose down -vdeletes thepostgres_datavolume; the next start creates a fresh, migrated database. - Keep the data (baseline): first check that the existing schema matches the
InitialCreatemigration, then mark it as applied and let startup apply the rest:CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( "MigrationId" character varying(150) NOT NULL PRIMARY KEY, "ProductVersion" character varying(32) NOT NULL ); INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260225200821_InitialCreate', '9.0.0');
Creating a new migration: dotnet ef migrations add <Name> --project TaskFlow.Infrastructure --startup-project TaskFlow.API.
- No hardcoded secrets - All sensitive data via environment variables.
- JWT - HS256. Startup fails if
JWT_SECRET_KEYis missing or shorter than 32 bytes. Token lifetime is configurable withJWT_EXPIRATION_MINUTES(default1440, i.e. 24 hours; allowed range 1 to 43200, anything else falls back to the default with a warning). Issuer and audience are validated; clock skew is 1 minute. - Token revocation by user deletion - On every authenticated request the API checks that the user named in the token still exists, so the token of a deleted user is rejected with
401immediately instead of staying valid until it expires. This costs one extra primary-key query (SELECT ... WHERE "Id" = ...) per authenticated request. Nothing else is revoked: there is no logout and no token blacklist, so a token stays valid until it expires as long as its user exists. - Password hashing - BCrypt. Login does the same amount of hashing work whether or not the email exists, and returns one generic
401. - Auth rate limiting -
POST /api/auth/loginand/api/auth/registershare a per-client fixed-window limit (default 10 requests per 60 seconds; see the table below). Over the limit:429withRetry-After. - CORS protection - Configurable allowed origins (
ALLOWED_ORIGINS). - Security headers - X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy.
- Ownership isolation - Every project and task query is scoped to the authenticated user; other users' data returns
404. - Soft delete - Projects and tasks are marked as deleted, not physically removed, and are excluded from every query.
- Error handling - A global handler turns exceptions into ProblemDetails. Unexpected errors return a generic
500body and are logged; the exception message is only included in the response in theDevelopmentenvironment.
Roles are not enforced. Users have a
Role(UserorAdmin), and it is stored, returned in responses and included as a claim in the JWT. But no endpoint uses[Authorize(Roles = ...)], and registration always creates aUser. Today every authenticated user has the same permissions: access is decided by ownership only.
Behind a proxy every request appears to come from the proxy's address, so all clients would share one rate-limit bucket. X-Forwarded-For is ignored unless you configure the proxies you trust (it is client-controlled, so trusting it blindly would let a client dodge the limit):
| Setting | Environment variable | Meaning |
|---|---|---|
ForwardedHeaders:KnownProxies |
FORWARDED_HEADERS_KNOWN_PROXIES (compose) / ForwardedHeaders__KnownProxies |
Comma-separated proxy IP addresses |
ForwardedHeaders:KnownNetworks |
FORWARDED_HEADERS_KNOWN_NETWORKS (compose) / ForwardedHeaders__KnownNetworks |
Comma-separated CIDR ranges, e.g. 10.0.0.0/8 |
An invalid entry stops the API at startup.
| Variable | Required | Description | Example / default |
|---|---|---|---|
JWT_SECRET_KEY |
Yes | Signing key, at least 32 bytes (UTF-8) | openssl rand -base64 32 |
DATABASE_CONNECTION_STRING |
Yes | PostgreSQL connection string | Host=postgres;Database=taskflow;Username=postgres;Password=... |
POSTGRES_PASSWORD |
Docker Compose | PostgreSQL password (also used to build the connection string in compose) | YourSecurePassword123! |
JWT_ISSUER |
No | JWT issuer | TaskFlowAPI |
JWT_AUDIENCE |
No | JWT audience | TaskFlowAPI |
JWT_EXPIRATION_MINUTES |
No | Token lifetime in minutes | 1440 |
ALLOWED_ORIGINS |
No | Comma-separated CORS origins | http://localhost:3000,http://localhost:5000,http://localhost:8080 (compose default: http://localhost:3000) |
RateLimiting__Auth__PermitLimit |
No | Auth requests allowed per window, per client | 10 |
RateLimiting__Auth__WindowSeconds |
No | Auth rate-limit window | 60 |
FORWARDED_HEADERS_KNOWN_PROXIES |
No | Trusted proxy IPs (compose variable) | empty |
FORWARDED_HEADERS_KNOWN_NETWORKS |
No | Trusted proxy CIDR ranges (compose variable) | empty |
ASPNETCORE_ENVIRONMENT |
No | Development enables Swagger and detailed 500 messages |
Production in compose |
The rate limit and forwarded-header settings are ordinary ASP.NET Core configuration: they can also be set in appsettings.json (RateLimiting:Auth:PermitLimit, ...). Non-numeric or non-positive rate-limit values fall back to the defaults.
TaskFlowAPI/
βββ TaskFlow.Domain/ # Core business entities
β βββ Entities/ # User, Project, TaskItem
β βββ Enums/ # Role, TaskStatus
β βββ Interfaces/ # Repository and unit-of-work interfaces
βββ TaskFlow.Application/ # Business logic
β βββ DTOs/ # Request/Response objects and validation
β βββ Services/ # Auth, Project, Task services
βββ TaskFlow.Infrastructure/ # Data access
β βββ Data/ # DbContext, unit of work
β βββ Configurations/ # EF configurations
β βββ Migrations/ # EF Core migrations
β βββ Repositories/ # Repository implementations
β βββ Security/ # JWT and password hashing
βββ TaskFlow.API/ # Web API
β βββ Controllers/ # API controllers
β βββ Program.cs # App configuration
βββ TaskFlow.*.Tests/ # Unit and integration tests
βββ TaskFlow.TestSupport/ # Shared test helpers
βββ .github/workflows/ # CI pipeline and scheduled vulnerability check
βββ .github/dependabot.yml # Weekly dependency updates
βββ docker-compose.yml # Docker services
βββ .env.example # Environment template
βββ README.md
# 1. Register (201 Created; the response already contains a token)
curl -X POST http://localhost:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"Pass1234!","name":"User"}'
# 2. Login (copy token from response)
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"Pass1234!"}'
# 3. Create Project (replace TOKEN)
curl -X POST http://localhost:5000/api/projects \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"My Project"}'
# 4. Create Task
curl -X POST http://localhost:5000/api/projects/1/tasks \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"First Task"}'
# 5. List pending tasks, second page of 10 (total count is in the X-Total-Count header)
curl -i "http://localhost:5000/api/projects/1/tasks?status=pending&page=2&pageSize=10" \
-H "Authorization: Bearer TOKEN"# Run all tests
dotnet test
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"| Project | Covers |
|---|---|
TaskFlow.Domain.Tests |
Entity rules and guards, email normalization, paging values |
TaskFlow.Application.Tests |
Services (ownership, status parsing, auth flow) against in-memory fakes; request validation rules |
TaskFlow.Infrastructure.Tests |
Repositories, soft delete, unit of work and BCrypt/JWT settings against SQLite in-memory |
TaskFlow.API.Tests |
Error handler, paging, rate limiting, forwarded headers and health checks in small hosts, plus integration tests (Integration/) that boot the real application through WebApplicationFactory<Program> and drive it over HTTP: register/login, token validation (including deleted users), validation, ownership isolation, filters, pagination, soft delete, health, rate limiting |
The integration tests need no Docker or PostgreSQL. They replace the database with SQLite in-memory and set Database:MigrateOnStartup=false so the schema is created from the model: the migrations contain PostgreSQL-specific SQL (for example the lower("Email") index) that SQLite cannot run. The migrations themselves are therefore not exercised by the test suite.
.github/workflows/ci.yml runs on every push and pull request: restore, build and test in Release (job build-test), and a docker build of the API image (job docker-build).
ci.yml deliberately downgrades the NuGet audit warnings (NU1901-NU1904) so that a newly published advisory does not break every build. Known-vulnerable packages are reported by a separate scheduled workflow, .github/workflows/vulnerabilities.yml (Mondays 06:00 UTC, or run it manually with workflow_dispatch): it runs dotnet list package --vulnerable --include-transitive and fails when the output contains any advisory. Dependabot (.github/dependabot.yml) opens weekly update pull requests for NuGet, GitHub Actions and the Docker base images.
If you run a version from before the hardening work, note:
-
Breaking response contract: enums are now serialized as strings. A task's
statuswas a number (0,1,2) and is now"Pending","InProgress"or"Completed"; a user'srole(in theuserobject of the auth responses) was a number (0,1) and is now"User"or"Admin". Clients that read the numeric values must be updated. Requests are unchanged: they already used the names, and numbers are still rejected with400. -
POST /api/auth/registerreturns201 Created(it used to return200), and an already registered email returns409 Conflict(it used to be a400with a{ "message": ... }body). -
Errors are RFC 7807 ProblemDetails (
application/problem+json) instead of ad-hoc bodies; validation failures are400. -
Requests are validated (lengths, email format, password 8 to 72 bytes) and task
statusmust be one ofPending,InProgress,Completed. An invalid status filter is now a400instead of returning every task. -
List endpoints are paged: at most 100 items per request (default 50). The total is in
X-Total-Count. -
Emails are stored lower-cased and matched case-insensitively.
-
JWT_SECRET_KEYmust be at least 32 bytes; the API refuses to start otherwise. Clock skew changed from the framework default of 5 minutes to 1 minute. -
A token whose user no longer exists in the database is rejected with
401. Every authenticated request now runs one extra query to check this. -
loginandregisterare rate limited (default 10 per minute per client address), so scripts and load tests may see429. -
docker-compose.ymlrequiresPOSTGRES_PASSWORDandJWT_SECRET_KEY, no longer publishes port 5432, and runs the API as a non-root user. -
Startup applies migrations instead of
EnsureCreated(). A database created by an older version needs a one-time reset or baseline (see "Database Migrations"). -
New endpoints:
/health/liveand/health/ready.
- Set secure environment variables (
JWT_SECRET_KEY,POSTGRES_PASSWORD,DATABASE_CONNECTION_STRING) - Use HTTPS (configure a reverse proxy like Nginx) and list the proxy in
FORWARDED_HEADERS_KNOWN_PROXIES/FORWARDED_HEADERS_KNOWN_NETWORKS - Set
ASPNETCORE_ENVIRONMENT=Production(Swagger stays off and 500 responses stay generic) - Configure your domain in
ALLOWED_ORIGINS - Use a strong
JWT_SECRET_KEY(64+ characters recommended) - Enable auto-restart with
restart: unless-stopped - Probe
/health/readyfor readiness and/health/livefor liveness
MIT License