A URL shortener REST API built with ASP.NET Core 10, PostgreSQL, and Redis.
- .NET 10 / ASP.NET Core
- Entity Framework Core with PostgreSQL (Npgsql)
- Redis (StackExchange.Redis) for response caching
- Swagger UI (Swashbuckle)
- Docker Compose for local orchestration (API + PostgreSQL + Redis)
All routes are versioned via a URL segment (current version: 1.0). Every response is wrapped in a result envelope: { "isSuccess": bool, "value": ..., "errorMessage": string | null, "isNotFound": bool }.
| Method | Route | Description |
|---|---|---|
POST |
/api/v1.0 |
Shorten a URL |
GET |
/api/v1.0/all |
List all shortened URLs |
GET |
/api/v1.0/get/{code} |
Get URL metadata by short code |
GET |
/api/v1.0/{code} |
Redirect to the original URL (by short code or slug) |
DELETE |
/api/v1.0/{code} |
Delete a single shortened URL by short code |
DELETE |
/api/v1.0 |
Delete all shortened URLs |
Request body:
{
"url": "https://example.com/some/very/long/url",
"slug": "optional-custom-slug",
"days": 30
}days controls the Redis cache TTL for the entry and defaults to 30 if omitted.
Response 201 Created:
{
"isSuccess": true,
"value": {
"shortCode": "abc123def4",
"url": "https://example.com/some/very/long/url",
"slug": "optional-custom-slug"
},
"errorMessage": null,
"isNotFound": false
}Returns the metadata (shortCode, url, slug) for every shortened URL.
Returns the metadata (shortCode, url, slug) for the given short code.
Performs an HTTP redirect to the original URL associated with the given short code or slug. The lookup is cached in Redis (TTL set at creation time via days, keyed by slug if provided, otherwise short code) to avoid hitting PostgreSQL on every redirect.
Deletes the shortened URL matching the given short code from PostgreSQL and evicts its Redis cache entry. Runs inside a DB transaction.
Deletes every shortened URL from PostgreSQL and evicts all corresponding Redis cache entries. Runs inside a DB transaction.
The original URL is SHA-256 hashed into a 64-character hex digest, which is then sliced into two distinct fields:
| Field | Slice | Purpose |
|---|---|---|
| ShortCode | hashHex[0..10] — first 10 hex chars |
The actual lookup key. It's what GetByCode and GetUrlByCode match against, and what goes in the short URL itself (e.g. /api/v1.0/abc123def4). |
| KeyCode | hashHex[9..] — remaining ~55 hex chars |
A longer reference to the same hash, returned to the caller as a canonical/display identifier. It is not used for lookups anywhere — metadata only. |
Both slices come from the same digest and overlap by one character (index 9 is the last character of ShortCode and also the first character of KeyCode) — that's expected, not a bug, since ShortCode only needs to be short and unique enough to serve as a cache/DB key, while KeyCode exists purely as a longer reference for clients.
On creation, the original URL is also written to Redis under the slug (or ShortCode, if no slug was provided) so that subsequent redirects are served from cache instead of hitting PostgreSQL.
The solution follows Clean Architecture, split into four projects with dependencies pointing inward (API → Infrastructure/Application → Domain):
Shortenkai/
├── Shortenkai.Domain/ # No dependencies — core entities
│ └── Entities/
│ └── UrlEntity.cs # EF Core entity
├── Shortenkai.Application/ # Depends on Domain — use-case contracts
│ ├── Common/
│ │ └── FAResult.cs # Typed result wrapper (Success/NotFound/Failure)
│ ├── DTOs/
│ │ ├── ShortnedUrlRequestDto.cs # POST request shape
│ │ └── ShortenedUrlResponseDto.cs # API response shape
│ └── ServicesInterfaces/
│ └── IShortenkaiService.cs
├── Shortenkai.Infrastructure/ # Depends on Domain + Application — implementation details
│ ├── Database/
│ │ └── ShortenkaiUrlDb.cs # DbContext
│ └── Services/
│ ├── ShortenkaiService.cs # Business logic & hashing
│ └── CacheService.cs # Generic Redis cache wrapper
└── Shortenkai.API/ # Depends on Application + Infrastructure — HTTP host
├── Controllers/
│ └── UrlController.cs
├── Properties/
│ └── launchSettings.json
├── appsettings.json
└── Program.cs # App bootstrap & DI setup
- .NET 10 SDK
- PostgreSQL instance
- Redis instance (defaults to
localhost:6379, configured inProgram.cs)
- Clone the repository
- Set the database connection string via .NET User Secrets instead of editing
appsettings.jsondirectly — this keeps the password out of source control:
cd Shortenkai.API
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Host=localhost;Port=5433;Database=Shortenkai;Username=postgres;Password=<your-password>"- Start the app:
dotnet run --project Shortenkai.APISwagger UI is served at the root path (/) in development mode.
The full stack (API + PostgreSQL + Redis) is defined in docker-compose.yml, which builds the API from the Dockerfile (multi-stage: SDK image builds/publishes, then runs on the SDK runtime).
- Docker and Docker Compose
- Create a
.envfile in the project root (gitignored) with the variables consumed bydocker-compose.yml:
DB_USER=postgres
DB_PASSWORD=<your-password>
DB_NAME=Shortenkai
DB_DATA_PATH=./postgres_data
REDIS_PORT=6379
POSTGRESQL_PORT=5433
API_PORT=5279- Start the stack:
docker compose up --buildThis spins up three containers:
| Service | Container | Description |
|---|---|---|
web |
shortenkai_api |
The API, built from the Dockerfile, listening on ${API_PORT} (mapped to container port 8080) |
db |
shortenkai_db |
PostgreSQL, seeded on first run via sql/db_init.sql, with a healthcheck gating API startup |
cache |
shortenkai_cache |
Redis (redis-stack-server), exposed on ${REDIS_PORT} |
The API waits for db to report healthy before starting. Postgres data persists on the host at ${DB_DATA_PATH}.
Once running, Swagger UI is available at http://localhost:${API_PORT}/.
- Stop the stack:
docker compose downIf Redis is running in a Docker container, open a redis-cli session inside it:
docker exec -it <container_name_or_id> redis-cliNot sure of the name? Run
docker psand look for the Redis image.
All keys are prefixed with the configured InstanceName (Shortenkai:):
KEYS Shortenkai:*IDistributedCache (what CacheService wraps) does not store entries as plain Redis strings — Microsoft.Extensions.Caching.StackExchangeRedis stores each entry as a hash, with fields data (the serialized value) and absexp (absolute expiration, as .NET ticks). A plain GET on the key returns nothing useful; use HGETALL instead:
HGETALL "Shortenkai:abc123def4"Example output:
1) "absexp"
2) "638508096000000000"
3) "data"
4) "\"https://example.com/some/very/long/url\""
data holds the JSON produced by CacheService.SetAsync — since the cached value here is a string, it's serialized as a JSON string (hence the surrounding escaped quotes).