A microservices-based task management API built with .NET 10, designed as an exercise in applying real-world backend patterns: Clean Architecture, CQRS, JWT authentication, a reverse-proxy API gateway, containerized SQL Server, and full testing including integration tests with real databases via Testcontainers.
This is my first end-to-end backend project built to learn, not copied from a template. Everything here (architecture decisions, test setup, CI, Docker wiring) was figured out step by step. The goal was to ship something that mirrors how a production service is actually structured, not a toy CRUD app.
- Architecture
- Tech Stack
- Key Features
- Project Structure
- API Reference
- API Docs Preview
- Installation
- Running with Docker
- Testing
- CI/CD
- What I Learned
- Roadmap
- Acknowledgements
Three independently deployable services sit behind a YARP reverse-proxy gateway. Each service owns its own database and follows Clean Architecture (Domain -> Application -> Infrastructure -> Api).
flowchart TD
Client([Client])
Gateway[Gateway.Api<br/>YARP Reverse Proxy]
Identity[IdentityService.Api<br/>Register / Login<br/>JWT Issuer]
Tasks[TasksService.Api<br/>Todo CRUD<br/>JWT-protected]
DB[(SQL Server<br/>Dockerized)]
Client --> Gateway
Gateway --> Identity
Gateway --> Tasks
Identity --> DB
Tasks --> DB
Each service is layered:
- Domain: entities, interfaces, no external dependencies
- Application: CQRS handlers (MediatR), DTOs, validators, mapping profiles
- Infrastructure: EF Core
DbContext, repositories, JWT provider, external service implementations - Api: controllers, middleware, DI composition root
| Concern | Choice |
|---|---|
| Runtime | .NET 10 / C# 13 |
| API Gateway | YARP (Yet Another Reverse Proxy) |
| Auth | ASP.NET Core Identity + JWT Bearer |
| CQRS / Mediator | MediatR |
| Validation | FluentValidation |
| ORM | Entity Framework Core 10 |
| Database | SQL Server 2022 (containerized) |
| Mapping | AutoMapper |
| Logging | Serilog |
| API Docs | Scalar (OpenAPI) |
| Unit Tests | xUnit, Moq, FluentAssertions |
| Integration Tests | WebApplicationFactory, Testcontainers.MsSql |
| Build Automation | Nuke Build |
| CI | GitHub Actions |
| Containerization | Docker + Docker Compose |
- Clean Architecture: enforced per service. Domain has zero dependencies; Api depends on Application; Infrastructure plugs in at composition root.
- CQRS with MediatR: every use case is a
CommandorQuerywith its own handler. No fat controllers. - JWT authentication: issued by
IdentityService, consumed byTasksServicethrough shared validation parameters. - FluentValidation pipeline: requests validated before reaching handlers via a MediatR behavior.
- Global exception handling: using
IExceptionHandlerreturning RFC 7807ProblemDetails. - Pagination: on list endpoints (
pageNumber,pageSize) with total count metadata. - Result<T> pattern: handlers return explicit success/failure instead of throwing for control flow.
- Secrets management: User Secrets for local dev,
.env+docker-compose.override.ymlfor Docker; nothing sensitive committed. - Fail-fast configuration: services validate connection strings and JWT keys at startup, not per-request.
- Integration tests with real SQL Server: via Testcontainers not InMemory fakes. Tests spin up a disposable SQL Server container per test class.
TaskManagerAPI/
├── Gateway.Api/ # YARP reverse proxy
├── Common/ # Shared Result<T> type
│
├── IdentityService.Api/ # Auth endpoints, DI composition root
├── IdentityService.Application/ # CQRS handlers, DTOs, validators
├── IdentityService.Domain/ # User entity, service interfaces
├── IdentityService.Infrastructure/ # EF Core, ASP.NET Identity, JWT provider
├── IdentityService.UnitTests/
├── IdentityService.IntegrationTests/
│
├── TasksService.Api/ # Todo endpoints (JWT-protected)
├── TasksService.Application/ # CQRS handlers, pagination, validators
├── TasksService.Domain/ # TodoItem entity, repository interface
├── TasksService.Infrastructure/ # EF Core DbContext, repository impl
├── TasksService.UnitTests/
├── TasksService.IntegrationTests/
│
├── build/ # Nuke build automation
├── docker-compose.yml
├── docker-compose.override.example.yml
└── .env.example
Full interactive docs are available at /scalar/v1 on each service when running locally.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
— | Create a new user account |
| POST | /api/auth/login |
— | Authenticate and receive a JWT |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/todos?pageNumber=1&pageSize=10 |
JWT | List current user's todos (paginated) |
| GET | /api/todos/{id} |
JWT | Get a single todo |
| POST | /api/todos |
JWT | Create a todo |
| PUT | /api/todos/{id} |
JWT | Update a todo |
| DELETE | /api/todos/{id} |
JWT | Delete a todo |
All TasksService routes are also reachable through the gateway at http://localhost:5001/tasks/....
Interactive Scalar documentation is generated from the OpenAPI spec at /scalar/v1 for each service.
- .NET 10 SDK
- Docker Desktop
- (Optional) Visual Studio 2022+ or JetBrains Rider
git clone https://github.com/LolghElmo/TaskManagerAPI.git
cd TaskManagerAPICopy the example env file and fill in a password + JWT key:
cp .env.example .env
cp docker-compose.override.example.yml docker-compose.override.ymlEdit .env:
SA_PASSWORD=Your_Strong_Password_123!
JWT_SECRET_KEY=a-long-random-string-at-least-32-charactersFor local (non-Docker) runs, use User Secrets instead:
cd IdentityService.Api
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Database=IdentityDb;Trusted_Connection=True;TrustServerCertificate=True"
dotnet user-secrets set "JwtSettings:SecretKey" "a-long-random-string-at-least-32-characters"Repeat for TasksService.Api with its own connection string (e.g. Database=TasksDb) and the same JwtSettings:SecretKey so it can validate tokens issued by IdentityService.
Spin up everything (SQL Server + all three services) with one command:
docker compose up --buildServices will be available at:
- Gateway:
http://localhost:5001 - IdentityService:
http://localhost:5002 - TasksService:
http://localhost:5003
The SQL Server container runs a healthcheck, and both API services wait for service_healthy before starting — no more race-condition crashes on first boot.
Tear down:
docker compose down # keeps the volume
docker compose down -v # wipes the database volumedotnet testThe suite currently runs 20 tests across 4 projects (15 unit, 5 integration), split into two styles:
- Unit tests (
*.UnitTests): handlers tested in isolation with Moq'd repositories, mappers, and loggers. Fast, no I/O. - Integration tests (
*.IntegrationTests): spin up a real SQL Server container via Testcontainers, boot the actual API throughWebApplicationFactory<Program>, and hit endpoints over HTTP. Catches the bugs unit tests can't: EF migrations, JSON serialization, auth middleware, DI wiring.
Integration tests set environment variables (ConnectionStrings__DefaultConnection, JwtSettings__SecretKey) in IAsyncLifetime.InitializeAsync before the host is built — this is the key to making WebApplicationFactory work with Testcontainers.
Continuous integration runs on every push via GitHub Actions. The workflow is generated from a typed Nuke build using the [GitHubActions] attribute with no hand-written YAML.
Run the same pipeline locally:
./build.sh # or build.cmd / build.ps1Targets: Clean -> Restore -> Compile -> Test.
A few things this project taught me that I wouldn't have picked up from a tutorial:
- Testcontainers +
WebApplicationFactory: environment variables for connection strings have to be set inIAsyncLifetime.InitializeAsyncbefore the host builds.ConfigureAppConfigurationis too late under minimal hosting. - Result over exceptions: using an explicit result type for expected failures (login failed, todo not found) made handlers easier to test and removed most of the try/catch noise from controllers.
- YARP route transforms: without them, the gateway leaks the internal service topology straight to the client. Route prefixes matter.
- Fail-fast config validation: checking connection strings and JWT keys with
string.IsNullOrWhiteSpaceat startup surfaces config problems immediately instead of as cryptic 500s on the first request. - Nuke generating GitHub Actions: the
[GitHubActions]attribute replaces hand-written YAML entirely the same Test target runs locally and on CI, so "works on my machine" is no longer a thing. - Docker healthchecks +
depends_on: service_healthy: without them, services race SQL Server on first boot and crashloop until it's ready.
Things I'd add next:
- Refresh tokens + token revocation
- Redis-backed distributed cache for
GET /api/todos - Health check endpoints (
/health,/health/ready) - Structured request logging with correlation IDs across services
- RabbitMQ/MassTransit for an async "task assigned" event
- OpenTelemetry + Jaeger/Seq for distributed tracing
- Deploy to Azure Container Apps
This project was built as my first full-stack backend. I leaned on these resources to learn the patterns:
- Clean Architecture with ASP.NET Core 10
- How to Implement the CQRS Pattern in Clean Architecture (from scratch)
- Intro to MediatR — Implementing CQRS and Mediator Patterns
- ASP.NET Core Integration Testing Tutorial
- The Best Way To Use Docker For Integration Testing In .NET
- Microsoft Docs — ASP.NET Core, EF Core, Identity
- Testcontainers for .NET documentation
- YARP official samples

