Production-style ASP.NET Core 8 Web API built to showcase backend performance engineering concepts, not generic CRUD.
- Large product/search dataset with 100k seeded rows
- Slow vs optimized EF Core query paths
- PostgreSQL indexing examples, including composite and trigram indexes
- Cursor-based keyset pagination
- Redis application caching
- ASP.NET Core response caching
- Rate limiting and throttling
- Polly retry plus circuit breaker fallback for a dependency call
- Structured logs with Serilog
- Docker Compose for API, PostgreSQL, and Redis
- xUnit tests for application services and controllers
- k6 load-testing scripts and benchmark documentation placeholders
src/
HighPerformanceDotNetApi.Api HTTP API, Swagger, middleware, controllers
HighPerformanceDotNetApi.Application Use cases, DTOs, contracts
HighPerformanceDotNetApi.Infrastructure EF Core, PostgreSQL, Redis, Polly clients
HighPerformanceDotNetApi.Domain Product domain model
tests/
HighPerformanceDotNetApi.Application.Tests
HighPerformanceDotNetApi.Api.Tests
This starts the API, PostgreSQL, and Redis:
docker compose up --buildThe API starts at:
- Swagger:
http://localhost:8080/swagger - Optimized search:
http://localhost:8080/api/products/search/optimized?term=product&category=Laptops&pageSize=50 - Slow search:
http://localhost:8080/api/products/search/slow?term=product&category=Laptops&pageSize=50 - Cached hot products:
http://localhost:8080/api/products/hot/cached?count=25 - Non-cached hot products:
http://localhost:8080/api/products/hot/non-cached?count=25
On startup the API applies migrations and seeds 100,000 products. The first boot can take a little while because the seed is intentionally large.
Stop the stack with:
docker compose downReset the database and seed from scratch with:
docker compose down -v
docker compose up --buildStart only the dependencies:
docker compose up -d postgres redisRun the API from the repo root:
dotnet run --project src/HighPerformanceDotNetApi.Api --launch-profile httpThe local API starts at:
- Swagger:
http://localhost:5080/swagger - Optimized search:
http://localhost:5080/api/products/search/optimized?term=product&category=Laptops&pageSize=50 - Slow search:
http://localhost:5080/api/products/search/slow?term=product&category=Laptops&pageSize=50 - Cached hot products:
http://localhost:5080/api/products/hot/cached?count=25
The local API uses these default connection strings from appsettings.json:
- PostgreSQL:
localhost:55432 - Redis:
localhost:6379
- The API automatically applies EF Core migrations.
- The product seeder inserts 100,000 rows the first time it sees an empty database.
- If Swagger opens before seeding finishes, wait for the API logs to show the seed progress completing.
- Do not run the Docker API and local API on the same port. Docker uses
8080; localdotnet runuses5080.
/api/products/search/slow intentionally loads active products into memory, then applies filtering and ordering. This demonstrates the common performance failure mode of moving too much work from PostgreSQL into the application process.
/api/products/search/optimized uses:
AsNoTracking()for read-only queries- Server-side filtering
- Projection into a compact DTO
- Keyset pagination with an encoded cursor
- Composite indexes for category, price, active state, rating, and ID
Use the elapsedMilliseconds field in the JSON response. Swagger/browser timing includes UI rendering, connection reuse, and response transfer, so it can hide query-level differences.
curl -s "http://localhost:8080/api/products/search/optimized?pageSize=50"
curl -s "http://localhost:8080/api/products/search/slow?pageSize=50"For a more realistic filtered search:
curl -s "http://localhost:8080/api/products/search/optimized?term=Performance%20Product&category=Laptops&minPrice=100&pageSize=50"
curl -s "http://localhost:8080/api/products/search/slow?term=Performance%20Product&category=Laptops&minPrice=100&pageSize=50"Typical local Docker result after warm-up:
- Optimized search: single-digit to low double-digit milliseconds
- Slow search: hundreds of milliseconds because it materializes and filters a much larger set in memory
/api/products/hot/non-cached always reads from PostgreSQL.
/api/products/hot/cached uses Redis for application-level caching and ASP.NET Core response caching for repeated HTTP responses.
/api/products/rate-limited uses a strict fixed-window limiter. Search endpoints use a sliding-window limiter to smooth bursts while allowing normal browsing traffic.
/api/products/pricing/{sku} calls a simulated external pricing dependency through HttpClient with retry and circuit breaker policies. When the dependency fails, the client returns a fallback result with isFallback = true.
The initial migration creates:
- Unique SKU index
ix_products_category_price_idfor filtered product searchix_products_active_rating_idfor top-rated hot-product queriesix_products_created_atfor time-ordered explorationix_products_name_trgmfor text search examples using PostgreSQLpg_trgm
For benchmark runs, disable API rate limiting first. Otherwise k6 will measure throttling and queueing instead of query performance.
RATE_LIMITING_ENABLED=false docker compose up -d --buildThen run:
k6 run --summary-export load-tests/results/search-summary.json load-tests/k6-search-comparison.js
k6 run --summary-export load-tests/results/cache-summary.json load-tests/k6-cache-comparison.jsThe search comparison script runs optimized traffic first and the slow endpoint second. That keeps the intentionally inefficient endpoint from saturating the API while the optimized endpoint is being measured.
The cache comparison script warms the cached endpoint first, then runs cached and non-cached traffic in separate phases. This avoids measuring the initial cache-fill stampede as steady-state cache latency.
Open the browser dashboard after running the benchmarks:
http://localhost:8080/benchmarks
Turn rate limiting back on for the feature demo with:
docker compose up -d --buildBenchmark notes live in docs/benchmark-results.md. TODO sections are included there for real screenshots, query plans, p95 latency, RPS, and Redis hit-rate evidence.
dotnet testBenchmark tables, screenshot placeholders, and PostgreSQL query-plan notes live in docs/benchmark-results.md.