Skip to content

mohamedmahmoud345/ShortLink

Repository files navigation

ShortLink — Polyglot URL Shortener

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.

Architecture

System Overview

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
Loading

Redirect Flow (Cache-Aside Pattern)

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
Loading

Admin Flow (Link Management)

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
Loading

Why This Architecture?

Why two services instead of one?

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.

Why Go for the redirector?

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.

Why C# for the admin API?

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.

Why cache-aside instead of write-through?

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.

Why a shared database?

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.


Performance

Go Redirector (10 concurrent VUs, k6 load test)

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).


Tech Stack

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

Features

C# Admin API

  • 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()

Go Redirector

  • 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

Quick Start

# 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

First use

# 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"}'

Testing

C# Integration Tests (61 tests)

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

Go Unit Tests (7 tests)

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.


Project Structure

├── 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

Environment Variables

C# API (shortlink-api)

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

Go Redirector (redirector)

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

CI/CD

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)

License

MIT

About

ShortLink is a polyglot URL shortener that splits concerns between a C# admin API (auth, CRUD, analytics) and a Go redirector (edge performance).

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages