A distributed URL shortener built with a dual-stack architecture: an ASP.NET Core 9 admin API (Clean Architecture + CQRS with MediatR) for secure link management and analytics, and a Go redirector optimized for low-latency public redirects with Redis caching.
graph LR
U["User"] -->|"manage links"| C["C# Admin API (:5000)"]
U -->|"redirect"| G["Go Redirector (:8080)"]
subgraph "Docker Compose"
C --> S["SQL Server (source of truth)"]
C -->|"invalidate cache"| R["Redis (cache-aside)"]
G -->|"cache hit"| R
G -->|"cache miss"| S
G -->|"write cache"| R
end
sequenceDiagram
participant User
participant Go
participant Cache
participant Database
User->>Go: GET /abc123
alt Cache Hit
Go->>Cache: GET link:abc123
Cache-->>Go: https://example.com
Go-->>User: 302 Redirect
Go->>Go: Log click (async)
else Cache Miss
Go->>Cache: GET link:abc123
Cache-->>Go: miss
Go->>Database: SELECT OriginalLink
Database-->>Go: https://example.com
Go->>Cache: SET link:abc123 (TTL)
Go-->>User: 302 Redirect
Go->>Go: Log click (async)
end
sequenceDiagram
participant User
participant API
participant Database
participant Cache
Note over User,Cache: Create Link
User->>API: POST /api/v1/shorturl
API->>Database: INSERT ShortUrl
Database-->>API: created
API-->>User: 201 Created
Note over User,Cache: Update Link
User->>API: PUT /api/v1/shorturl/{id}
API->>Database: UPDATE OriginalLink
API->>Cache: DEL link:{shortCode}
Database-->>API: updated
API-->>User: 204 No Content
Note over User,Cache: Delete Link
User->>API: DELETE /api/v1/shorturl/{id}
API->>Database: SET IsActive = 0
API->>Cache: DEL link:{shortCode}
Database-->>API: deactivated
API-->>User: 204 No Content
The redirect hot path and the admin API have fundamentally different requirements. The redirector needs to handle high traffic with sub-50ms latency — it does one thing: look up a short code and redirect. Adding authentication middleware, validation pipelines, and admin logic to the same process would add overhead to every single redirect request.
Splitting them keeps the redirector lean, while the C# API can be as rich as needed with zero performance impact on the public path.
Go's concurrency model is a natural fit. The async analytics pipeline — capturing click events without blocking the redirect response — is exactly what goroutines handle well. Go also starts fast, has a tiny memory footprint per request, and compiles to a single static binary, making the Docker image ~15MB.
The admin side is where complexity lives: authentication, authorization, ownership rules, validation pipelines, analytics queries. ASP.NET Core's ecosystem (MediatR, FluentValidation, EF Core, Identity) handles this cleanly. Clean Architecture and CQRS make sense here because the domain has real business rules worth isolating. Building this in Go would mean recreating that infrastructure from scratch.
Write-through would require the C# API to populate Redis on every link mutation, coupling the services more tightly. Cache-aside keeps the redirector self-sufficient — it populates its own cache on miss, and the admin API only needs to invalidate on change. A cold Redis instance recovers naturally under real traffic with no manual warming step.
The redirector reads directly from SQL Server on a cache miss rather than calling the C# API. This eliminates an extra network hop on the hot path and removes the admin API as a runtime dependency for the redirector. A single source of truth also avoids the dual-write consistency problems that come with separate databases.
| Metric | Value |
|---|---|
| Throughput | 2,063 req/s |
| P50 latency | 4.27 ms |
| P95 latency | 7.63 ms |
| Max latency | 27.6 ms |
| Success rate | 100% (20,637 / 20,637) |
Tested with 10 concurrent virtual users over 10 seconds against a local Docker Compose deployment (Go redirector → Redis → SQL Server).
| Layer | Technology |
|---|---|
| C# API | ASP.NET Core 9, Clean Architecture, CQRS (MediatR), EF Core, FluentValidation |
| Go Service | Chi router, go-redis/v9, tollbooth (rate limiter), go-mssqldb |
| Database | SQL Server (Azure SQL Edge) |
| Cache | Redis (StackExchange.Redis, go-redis) |
| Auth | ASP.NET Core Identity + JWT Bearer |
| Testing | xUnit, FluentAssertions, Testcontainers (MsSql + Redis), Go table-driven tests |
| DevOps | Docker, Docker Compose, GitHub Actions |
- JWT authentication with ASP.NET Core Identity (register/login)
- Role-based authorization (Admin / User) with admin endpoints
- Link CRUD with ownership enforcement — users can only modify their own links
- CQRS with MediatR — commands and queries separated with validation pipelines
- Redis cache invalidation — link updates/deletes clear the cache key
link:{shortCode} - Rate limiting — 60 requests per minute per user (fixed window)
- Click analytics — daily clicks, top referrers, country stats, device stats
- API versioning — URL path based (
/api/v1/) - Global exception handling with ProblemDetails
- Auto database migration on startup via
MigrateAsync()
- Cache-aside pattern — checks Redis first, falls back to SQL Server on miss, populates cache
- ~4ms median redirect latency (P95 7.6ms at 2,063 req/s)
- TTL-bounded caching — min of link expiration or 24h max
- Per-IP rate limiting — 20 requests per second via tollbooth
- Async analytics pipeline — click events captured via goroutines with zero user-facing latency
- Health endpoint (
GET /healthz) - Fail-open on Redis errors — redirects continue serving from database if Redis is unavailable
# Clone the repository
git clone https://github.com/mohamedmahmoud345/ShortLink.git
cd ShortLink
# Start all services (API, Redirector, SQL Server, Redis)
docker compose up -d
# Verify everything is running
docker compose ps
# C# API: http://localhost:5000
# Go Redirector: http://localhost:8080
# Swagger UI: http://localhost:5000/swagger# Register a new user
curl -X POST http://localhost:5000/api/v1/account/register \
-H "Content-Type: application/json" \
-d '{"userName":"testuser","email":"test@example.com","password":"SecurePass123!"}'
# Login to get JWT token
curl -X POST http://localhost:5000/api/v1/account/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass123!"}'
# Create a short link (use the token from login response)
curl -X POST http://localhost:5000/api/v1/shorturl \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"url":"https://example.com"}'dotnet test Tests/ShortLink.IntegrationTests/Tests use Testcontainers to spin up real SQL Server and Redis containers, then run tests against them. Covers:
| Test file | Count | Scope |
|---|---|---|
AuthTests.cs |
13 | Register, login, JWT validation, role access |
ShortUrlTests.cs |
25 | CRUD, ownership, refresh, inactive links, admin endpoints |
ClickEventTests.cs |
23 | Record clicks, analytics queries, internal token auth |
cd go/redirector
go test -v ./internal/http/Table-driven tests with interface mocks — no external dependencies needed. Runs in under 40ms. Covers cache hit, cache miss, cache fail-open, expired links, DB errors.
├── src/
│ ├── ShortLink.Api/ # ASP.NET Core Web API
│ │ ├── Controllers/ # API endpoints
│ │ ├── DTOs/ # Request/response DTOs
│ │ ├── Filters/ # InternalOnly filter
│ │ └── Middlewares/ # Global exception handler
│ ├── ShortLink.Application/ # CQRS (MediatR), services, DTOs
│ │ ├── Features/ # Commands + Queries per domain
│ │ │ ├── Account/ # Register, Login
│ │ │ ├── ShortUrl/ # CRUD, Refresh, Queries
│ │ │ ├── ClickEvent/ # Record, Analytics queries
│ │ │ └── Admin/ # Admin queries
│ │ └── Services/ # Interfaces (ICacheService, etc.)
│ ├── ShortLink.Domain/ # Entities, interfaces, enums
│ └── ShortLink.Infrastructure/ # EF Core, repositories, services
│ ├── Data/ # DbContext, configurations, seeding
│ ├── Repositories/ # ShortUrl, ClickEvent, Admin
│ ├── Services/ # Auth, Cache, GeoIp
│ └── Migrations/ # EF Core migrations
├── go/
│ └── redirector/ # Go redirect service
│ ├── cmd/redirector/ # Entry point
│ └── internal/
│ ├── http/ # Handlers + tests
│ ├── cache/ # Redis client
│ ├── analytics/ # Click event client
│ └── config/ # Environment config
├── Tests/
│ └── ShortLink.IntegrationTests/ # xUnit + Testcontainers
├── docker-compose.yml # 4-service orchestration
└── .github/workflows/ # CI pipelines
| Variable | Default | Description |
|---|---|---|
ConnectionStrings__conStr |
required | SQL Server connection string |
ConnectionStrings__RedisConnection |
required | Redis connection string |
Jwt__SecretKey |
required | JWT signing key |
Jwt__Issuer |
ShortLink |
JWT issuer |
Jwt__Audience |
ShortLink |
JWT audience |
SeedAdmin__Email |
optional | Admin email for seeding |
SeedAdmin__Password |
optional | Admin password for seeding |
INTERNAL_SECURE_TOKEN |
required | Shared secret for Go → C# analytics |
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP listen port |
CON_STR |
required | SQL Server connection string (URL format) |
REDIS_ADDRESS |
required | Redis host:port |
REDIS_PASSWORD |
"" |
Redis password |
INTERNAL_SECURE_TOKEN |
required | Must match C# API token |
CS_API_URL |
required | C# analytics endpoint URL |
Two GitHub Actions workflows:
| Workflow | Trigger | Steps |
|---|---|---|
| .NET CI | Changes to src/, Tests/ |
dotnet restore → build → test (61 tests) |
| Go CI | Changes to go/ |
go mod download → build → test (7 tests) |
MIT