Skip to content

labib0x9/short

Repository files navigation

short — URL Shortener

A URL shortener service written in Go, built with a Domain-Driven Design (DDD) architecture. Uses raw net/http (no framework), PostgreSQL for persistence, Redis for caching and rate limiting, and RabbitMQ for async click analytics processing.

Project Structure

.
├── cmd                                 → entrypoint & wiring
│   ├── bootstrap                       → bootstrap cli
│   │   ├── cmd
│   │   └── main.go
│   └── short                           → server
│       └── main.go
├── config                              → environment config parser
├── internal
│   ├── app                             → application layer (business logic)
│   │   └── url
│   │       ├── service.go
│   ├── cron                            → Cron cleaner 
│   ├── domain                          → domain layer (entities, repository interfaces, domain errors)
│   │   ├── cache
│   │   ├── db
│   │   ├── queue
│   │   └── url
│   ├── infra                           → infrastructure layer
│   │   ├── postgres
│   │   │   ├── transaction_manager.go
│   │   ├── rabbitmq
│   │   └── redis
│   │       ├── cache
│   │       ├── rate_limitter
│   ├── transport                       → transposrt layer
│   │   └── http                        → HTTP server, middleware manager, route handlers
│   │       ├── handler
│   │       │   ├── static
│   │       │   │   ├── handler.go
│   │       │   │   └── routes.go
│   │       │   └── url
│   │       │       ├── handler.go
│   │       │       ├── routes.go
│   │       ├── middleware
│   │       │   ├── manager.go
│   │       └── server.go
│   ├── utils                           → helper functions (code generation, json response)
│   └── worker                          → async analytics consumer (RabbitMQ)
├── migrations                          → SQL migration files
├── tests                               → test files (k6 load test)
├── static                              → frontend code (claude generated)
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── .dockerignore
├── .env.example
├── .gitignore
└── README.md

Tech Stack

Concern Technology
Language Go 1.26
HTTP net/http (stdlib only, no framework)
Database PostgreSQL (sqlx, lib/pq)
Migrations golang-migrate
Cache Redis (go-redis/v9)
Message Queue RabbitMQ (amqp091-go)
Validation go-playground/validator
ID Generation google/uuid
User-Agent Parsing mileusna/useragent

Features

  • URL shortening — FNV-64 URL hash XOR'd into a UUID, base64url-encoded and truncated to 8 characters
  • RedirectGET /{code} with Redis cache layer; expired URLs served from cache to avoid DB hits
  • Click analytics — async pipeline via RabbitMQ; captures browser, OS, device type, referrer, and timestamp per click
  • Analytics endpointGET /{code}/stat returns aggregated browser/OS/device breakdown and total click count
  • Rate limiting — token bucket implemented as an atomic Lua script in Redis
  • Dead-letter queue — failed analytics messages are routed to analytics.queue.dead after exhausting retries
  • Graceful shutdown — SIGINT/SIGTERM handling with configurable drain timeout
  • Cleaner - Cron job to delete expired urls

API

Shorten a URL

POST /short
Content-Type: application/json

{
  "url": "https://example.com/some/long/path",
  "expire_at": "2025-12-31T00:00:00Z"   // optional
}
{
  "msg": "success",
  "code": "aB3kZ9",
  "short_url": "http://localhost:3000/aB3kZ9",
  "expire_at": "2025-12-31T00:00:00Z"
}

Redirect

GET /{code}
→ 302 redirect to original URL

Analytics

GET /{code}/stat
{
  "short": "aB3kZ9",
  "total_count": 142,
  "browser": { "Chrome": 98, "Firefox": 44 },
  "device":  { "Desktop": 120, "Mobile": 22 },
  "os":      { "Windows": 80, "macOS": 40, "Linux": 22 },
  "expire_at": null
}

Click Analytics Pipeline

sequenceDiagram
    participant User
    participant API as URL Service
    participant MQ as RabbitMQ
    participant Worker as Analytics Worker
    participant DB as PostgreSQL

    User->>API: GET /:code
    API-->>User: 302 Redirect

    API->>MQ: Publish ClickEvent (async)

    MQ->>Worker: Consume ClickEvent

    Worker->>Worker: Parse User-Agent
    Note right of Worker: Detect browser,\ndevice, OS

    Worker->>DB: Insert click record
    Worker->>DB: Increment total clicks
    Worker->>DB: Update last_clicked_at

    alt Success
        DB-->>Worker: OK
    else Failure
        Worker->>MQ: Retry (max 2x)
        MQ->>Worker: Requeue event

        alt Retry limit exceeded
            Worker->>MQ: Dead Letter Queue
        end
    end
Loading

The worker runs with configurable concurrency (default 10) using a semaphore pattern, and uses manual acknowledgement (autoAck: false) so no clicks are lost on crash.

Setup

Prerequisites

  • Docker

Environment

Copy .env.example to .env and fill in your values:

ADDR=127.0.0.1
PORT=3000
PREFIX=http://localhost:3000/
SERVICE_NAME=short-api

PG_USER=shortuser
PG_PASSWORD=secret
PG_PORT=5432
PG_ADDRESS=localhost
PG_NAME=urlshortener
PG_SSLMODE=disable

PG_SUPERUSER=postgres
PG_SUPERDB=postgres

REDIS_ADDR=localhost:6379

RMQ_ADDR=localhost:5672
RMQ_USER=guest
RMQ_PASS=guest

Run

docker compose up

Build And Run

docker compose up -d --build

Docker services

services:
  postgres:   → PostgreSQL
  redis:      → Redis 
  rabbitmq:   → RabbitMQ
  bootstrap:  → CLI to setup postgres, redis and rabbitmq
  api:        → API backend and frontend

Load Testing

Load tested with k6 using a constant-arrival-rate scenario mixing all three core endpoints (POST /short, GET /{code}, GET /{code}/stat) at realistic traffic proportions (80% reads, 20% writes).

Setup: 1000 req/s sustained for 60s, 150–500 max VUs, run via Docker Compose (API, Postgres, Redis, RabbitMQ, all containerized).

Run: shuf tests/get_urls.txt -o tests/get_urls.txt && k6 run -e RATE=1000 tests/load.js

With Rate Limiting Results (3 consecutive runs):

Run Throughput p90 p95 Failed/unexpected Dropped iterations
1 992 req/s 29.06ms 67.93ms 0.00% 475 (0.80%)
2 992 req/s 46.41ms 102.93ms 0.00% 365 (0.61%)
3 995 req/s 38.16ms 80.69ms 0.00% 285 (0.48%)
Avg 993 req/s ~38ms ~84ms 0.00% ~0.63%

Without Rate Limiting Results (3 consecutive runs): Commenting rate limiter on '../transport/server.go' file

Run Throughput p90 p95 Failed/unexpected Dropped iterations
1 981 req/s 147.01ms 248.89ms 0.00% 1109 (1.85%)
2 970 req/s 212.35ms 335.7ms 0.00% 1787 (2.98%)
3 920 req/s 444.13ms 691.12ms 0.00% 4605 (7.67%)
Avg 957 req/s ~267.8ms ~425.2ms 0.00% ~4.17%

Only 5xx codes are treated as a failure, others 4xx are valid as they are expected.

Future-Work

  • Upgrade token bucket rate limiting to sliding window
  • Query optimization

About

A minimal URL shortener built with Go, PostgreSQL, Redis and RabbitMQ

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors