Skip to content
← সিস্টেম ডিজাইন · উন্নত · 25 মিনিট · 12 / 26 EN

কেস স্টাডি: স্কেলে URL Shortener

base62 encoding, Snowflake ID, caching, rate limiting এবং analytics সহ একটি production URL shortener ডিজাইন করে বানান।

system designURL shortenerSnowflake IDbase62analytics pipeline

গল্পে বুঝি

ইবনে সিনার ভাইয়ের বিয়ের অনুষ্ঠান, গেটের পাশেই কোট-ব্যাগ জমা রাখার একটা কাউন্টার। আল-খোয়ারিজমি ভেতরে ঢোকার আগে তার বিশাল ভারী ব্যাগটা কাউন্টারে জমা দিল, বিনিময়ে হাতে পেল ছোট্ট একটা নম্বরওয়ালা টোকেন — “৪৭”। কাউন্টারের ছেলেটা তার খাতায় টুকে রাখল: টোকেন ৪৭ মানে ৩ নম্বর তাকের ব্যাগ। আল-খোয়ারিজমি নিশ্চিন্তে গিয়ে খাওয়াদাওয়া করল, পকেটে শুধু ওই ছোট টোকেন। ফেরার সময় সে টোকেনটা দেখাতেই ছেলেটা খাতা দেখে সঙ্গে সঙ্গে ৩ নম্বর তাক থেকে ব্যাগটা এনে হাতে ধরিয়ে দিল — এক সেকেন্ডও লাগল না।

মজার ব্যাপার হলো, কোনো দুইজনের টোকেন এক হতে পারবে না — এক হলেই তো ভুল ব্যাগ চলে যাবে, তাই প্রতিটা টোকেন আলাদা। আর অনুষ্ঠানের হোস্ট ফাতিমা আল-ফিহরি যেহেতু বারবার ভেতর-বাইরে করছে, ছেলেটা তার টোকেনটা আর খাতায় খুঁজতেও যায় না — মুখস্থ হয়ে গেছে কোন তাকে, চোখের পলকে বের করে দেয়।

আসলে এটাই একটা URL shortener। ভারী ব্যাগটা হলো লম্বা URL, ছোট টোকেনটা হলো short code, আর কাউন্টারের খাতাটা হলো key-value store যেখানে টোকেন-থেকে-URL এর mapping রাখা থাকে। টোকেন দেখিয়ে ব্যাগ ফেরত পাওয়াটাই lookup আর redirect, প্রতিটা টোকেন আলাদা রাখাটাই unique code generation, আর হোস্টের টোকেন মুখস্থ রাখাটাই hot link-এর cache। bit.ly বা TinyURL ঠিক এভাবেই একটা লম্বা লিংককে ছোট code-এ বদলে দেয়, আর কেউ ওই ছোট লিংকে ক্লিক করলে মুহূর্তেই মূল ঠিকানায় পৌঁছে দেয়।

ইন্টারভিউয়ের ক্লাসিক প্রশ্ন — এবার সত্যিকারভাবে

URL shortener প্রতিটি system design interview-তে আসে। কিন্তু বেশিরভাগ ব্যাখ্যা “একটা hash ব্যবহার করো” বলেই থেমে যায়। এখানে আমরা পুরো সিস্টেমটা বানাব: distributed ID generation, encoding, caching, rate limiting, analytics এবং storage — সবই production-ready।

বাস্তব জীবনের উদাহরণ

থিয়েটারের একটি coat check-এর মতো — আপনি একটা লম্বা URL জমা দেন আর একটা ছোট ticket number পান। পরে ticket দেখালে আপনার মূল URL ফেরত পান।

প্রয়োজনীয়তা

  • Functional: short URL বানানো, মূল URL-এ redirect করা, click analytics track করা
  • Non-functional: প্রতি সেকেন্ডে 1000 URL creation, প্রতি সেকেন্ডে 100K redirect, 99.9% uptime, 10ms-এর নিচে redirect latency
  • Storage: 100M URL, ~10GB data + analytics
URL Shortener Architecture

সম্পূর্ণ সিস্টেম

import http from 'node:http';
import crypto from 'node:crypto';

// ===========================================
// 1. SNOWFLAKE ID GENERATOR
// ===========================================
class SnowflakeIDGenerator {
	private sequence = 0n;
	private lastTimestamp = -1n;
	private readonly epoch = 1700000000000n; // custom epoch
	private readonly workerIdBits = 10n;
	private readonly sequenceBits = 12n;
	private readonly maxSequence = (1n << this.sequenceBits) - 1n;
	private readonly workerIdShift = this.sequenceBits;
	private readonly timestampShift = this.sequenceBits + this.workerIdBits;

	constructor(private readonly workerId: bigint) {
		if (workerId < 0n || workerId >= 1n << this.workerIdBits) {
			throw new Error(`Worker ID must be between 0 and ${(1n << this.workerIdBits) - 1n}`);
		}
	}

	generate(): bigint {
		let timestamp = BigInt(Date.now()) - this.epoch;

		if (timestamp === this.lastTimestamp) {
			this.sequence = (this.sequence + 1n) & this.maxSequence;
			if (this.sequence === 0n) {
				// Wait for next millisecond
				while (timestamp <= this.lastTimestamp) {
					timestamp = BigInt(Date.now()) - this.epoch;
				}
			}
		} else {
			this.sequence = 0n;
		}

		this.lastTimestamp = timestamp;

		return (
			(timestamp << this.timestampShift) | (this.workerId << this.workerIdShift) | this.sequence
		);
	}
}

// ===========================================
// 2. BASE62 ENCODER
// ===========================================
const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

function base62Encode(num: bigint): string {
	if (num === 0n) return BASE62_CHARS[0];
	let result = '';
	while (num > 0n) {
		result = BASE62_CHARS[Number(num % 62n)] + result;
		num = num / 62n;
	}
	return result;
}

function base62Decode(str: string): bigint {
	let result = 0n;
	for (const char of str) {
		result = result * 62n + BigInt(BASE62_CHARS.indexOf(char));
	}
	return result;
}

// ===========================================
// 3. STORAGE (in-memory, replace with PG)
// ===========================================
interface URLRecord {
	id: string;
	shortCode: string;
	originalUrl: string;
	userId: string;
	createdAt: string;
	expiresAt: string | null;
	clickCount: number;
}

// In production: PostgreSQL table
// CREATE TABLE urls (
//   id BIGINT PRIMARY KEY,
//   short_code VARCHAR(11) UNIQUE NOT NULL,
//   original_url TEXT NOT NULL,
//   user_id VARCHAR(50),
//   created_at TIMESTAMPTZ DEFAULT NOW(),
//   expires_at TIMESTAMPTZ,
//   click_count BIGINT DEFAULT 0
// );
// CREATE INDEX idx_urls_short_code ON urls(short_code);
// CREATE INDEX idx_urls_user_id ON urls(user_id);

const urlStore = new Map<string, URLRecord>();
const codeToId = new Map<string, string>();

// ===========================================
// 4. CACHE (in-memory, replace with Redis)
// ===========================================
class LRUCache {
	private cache = new Map<string, { value: string; expiry: number }>();
	private maxSize: number;

	constructor(maxSize = 10000) {
		this.maxSize = maxSize;
	}

	get(key: string): string | null {
		const entry = this.cache.get(key);
		if (!entry) return null;
		if (Date.now() > entry.expiry) {
			this.cache.delete(key);
			return null;
		}
		// Move to end (most recently used)
		this.cache.delete(key);
		this.cache.set(key, entry);
		return entry.value;
	}

	set(key: string, value: string, ttlMs: number): void {
		if (this.cache.size >= this.maxSize) {
			// Evict oldest entry
			const firstKey = this.cache.keys().next().value;
			if (firstKey) this.cache.delete(firstKey);
		}
		this.cache.set(key, { value, expiry: Date.now() + ttlMs });
	}

	delete(key: string): void {
		this.cache.delete(key);
	}
}

// ===========================================
// 5. RATE LIMITER (in-memory sliding window)
// ===========================================
class RateLimiter {
	private windows = new Map<string, number[]>();

	isAllowed(key: string, maxRequests: number, windowMs: number): boolean {
		const now = Date.now();
		const timestamps = this.windows.get(key) || [];

		// Remove expired entries
		const valid = timestamps.filter((t) => t > now - windowMs);
		valid.push(now);
		this.windows.set(key, valid);

		return valid.length <= maxRequests;
	}
}

// ===========================================
// 6. ANALYTICS (async click tracking)
// ===========================================
interface ClickEvent {
	shortCode: string;
	timestamp: string;
	ip: string;
	userAgent: string;
	referer: string;
}

class AnalyticsCollector {
	private buffer: ClickEvent[] = [];
	private flushInterval: ReturnType<typeof setInterval>;

	constructor() {
		// Flush every 5 seconds (in production: write to Kafka/SQS)
		this.flushInterval = setInterval(() => this.flush(), 5000);
	}

	track(event: ClickEvent): void {
		this.buffer.push(event);
	}

	private flush(): void {
		if (this.buffer.length === 0) return;
		const batch = this.buffer.splice(0);
		// In production: send to analytics pipeline
		console.log(`[Analytics] Flushed ${batch.length} click events`);
	}

	stop(): void {
		clearInterval(this.flushInterval);
		this.flush();
	}
}

// ===========================================
// 7. URL SHORTENER SERVICE
// ===========================================
class URLShortenerService {
	private idGen: SnowflakeIDGenerator;
	private cache: LRUCache;
	private rateLimiter: RateLimiter;
	private analytics: AnalyticsCollector;
	private readonly baseUrl: string;

	constructor(workerId: number, baseUrl: string) {
		this.idGen = new SnowflakeIDGenerator(BigInt(workerId));
		this.cache = new LRUCache(50000);
		this.rateLimiter = new RateLimiter();
		this.analytics = new AnalyticsCollector();
		this.baseUrl = baseUrl;
	}

	// Create short URL
	async createShortURL(
		originalUrl: string,
		userId: string,
		expiresIn?: number
	): Promise<{
		shortUrl: string;
		shortCode: string;
	}> {
		// Validate URL
		try {
			new URL(originalUrl);
		} catch {
			throw new Error('Invalid URL format');
		}

		// Check for existing URL (deduplication)
		for (const record of urlStore.values()) {
			if (record.originalUrl === originalUrl && record.userId === userId) {
				return {
					shortUrl: `${this.baseUrl}/${record.shortCode}`,
					shortCode: record.shortCode
				};
			}
		}

		// Generate unique ID using Snowflake
		const id = this.idGen.generate();
		const shortCode = base62Encode(id);

		const record: URLRecord = {
			id: id.toString(),
			shortCode,
			originalUrl,
			userId,
			createdAt: new Date().toISOString(),
			expiresAt: expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null,
			clickCount: 0
		};

		// Store in DB
		urlStore.set(record.id, record);
		codeToId.set(shortCode, record.id);

		// Pre-warm cache
		this.cache.set(shortCode, originalUrl, 3600000); // 1 hour

		return {
			shortUrl: `${this.baseUrl}/${shortCode}`,
			shortCode
		};
	}

	// Resolve short URL (redirect)
	async resolve(
		shortCode: string,
		ip: string,
		userAgent: string,
		referer: string
	): Promise<string | null> {
		// 1. Check cache
		const cached = this.cache.get(shortCode);
		if (cached) {
			this.trackClick(shortCode, ip, userAgent, referer);
			return cached;
		}

		// 2. Check database
		const id = codeToId.get(shortCode);
		if (!id) return null;

		const record = urlStore.get(id);
		if (!record) return null;

		// Check expiration
		if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
			return null;
		}

		// Update cache
		this.cache.set(shortCode, record.originalUrl, 3600000);

		// Track click (async, don't block redirect)
		this.trackClick(shortCode, ip, userAgent, referer);
		record.clickCount++;

		return record.originalUrl;
	}

	private trackClick(shortCode: string, ip: string, userAgent: string, referer: string): void {
		this.analytics.track({
			shortCode,
			timestamp: new Date().toISOString(),
			ip,
			userAgent,
			referer
		});
	}

	// Get analytics
	async getStats(shortCode: string): Promise<URLRecord | null> {
		const id = codeToId.get(shortCode);
		if (!id) return null;
		return urlStore.get(id) || null;
	}

	checkRateLimit(ip: string): boolean {
		return this.rateLimiter.isAllowed(ip, 10, 60000); // 10 req/min
	}

	shutdown(): void {
		this.analytics.stop();
	}
}

// ===========================================
// 8. HTTP SERVER
// ===========================================
const service = new URLShortenerService(
	parseInt(process.env.WORKER_ID || '1'),
	process.env.BASE_URL || 'http://localhost:3000'
);

function parseBody(req: http.IncomingMessage): Promise<unknown> {
	return new Promise((resolve, reject) => {
		const chunks: Buffer[] = [];
		req.on('data', (c) => chunks.push(c));
		req.on('end', () => {
			try {
				resolve(JSON.parse(Buffer.concat(chunks).toString()));
			} catch {
				reject(new Error('Invalid JSON'));
			}
		});
	});
}

function json(res: http.ServerResponse, status: number, data: unknown): void {
	res.writeHead(status, { 'Content-Type': 'application/json' });
	res.end(JSON.stringify(data));
}

const server = http.createServer(async (req, res) => {
	const url = new URL(req.url || '/', `http://${req.headers.host}`);
	const method = req.method || 'GET';
	const ip = req.socket.remoteAddress || 'unknown';

	try {
		// Rate limiting
		if (!service.checkRateLimit(ip)) {
			json(res, 429, { error: 'Too many requests' });
			return;
		}

		// POST /api/shorten — Create short URL
		if (url.pathname === '/api/shorten' && method === 'POST') {
			const body = (await parseBody(req)) as { url: string; userId?: string; expiresIn?: number };

			if (!body.url) {
				json(res, 400, { error: 'url is required' });
				return;
			}

			const result = await service.createShortURL(
				body.url,
				body.userId || 'anonymous',
				body.expiresIn
			);
			json(res, 201, result);
			return;
		}

		// GET /api/stats/:code — Get analytics
		const statsMatch = url.pathname.match(/^\/api\/stats\/([a-zA-Z0-9]+)$/);
		if (statsMatch && method === 'GET') {
			const stats = await service.getStats(statsMatch[1]);
			if (!stats) {
				json(res, 404, { error: 'URL not found' });
				return;
			}
			json(res, 200, stats);
			return;
		}

		// GET /:code — Redirect
		const codeMatch = url.pathname.match(/^\/([a-zA-Z0-9]+)$/);
		if (codeMatch && method === 'GET') {
			const originalUrl = await service.resolve(
				codeMatch[1],
				ip,
				req.headers['user-agent'] || '',
				req.headers.referer || ''
			);

			if (!originalUrl) {
				json(res, 404, { error: 'Short URL not found or expired' });
				return;
			}

			res.writeHead(301, { Location: originalUrl, 'Cache-Control': 'private, max-age=90' });
			res.end();
			return;
		}

		// Health check
		if (url.pathname === '/health') {
			json(res, 200, { status: 'ok' });
			return;
		}

		json(res, 404, { error: 'Not found' });
	} catch (err) {
		console.error('Error:', err);
		json(res, 500, { error: 'Internal server error' });
	}
});

const PORT = parseInt(process.env.PORT || '3000');
server.listen(PORT, () => console.log(`URL Shortener on http://localhost:${PORT}`));

process.on('SIGTERM', () => {
	service.shutdown();
	server.close();
});
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
)

// ===========================================
// 1. SNOWFLAKE ID GENERATOR
// ===========================================
type SnowflakeGenerator struct {
	mu            sync.Mutex
	epoch         int64
	workerID      int64
	sequence      int64
	lastTimestamp  int64
	workerIDBits  uint
	sequenceBits  uint
	maxSequence   int64
	workerIDShift uint
	timestampShift uint
}

func NewSnowflakeGenerator(workerID int64) *SnowflakeGenerator {
	const workerIDBits uint = 10
	const sequenceBits uint = 12
	return &SnowflakeGenerator{
		epoch:          1700000000000,
		workerID:       workerID,
		workerIDBits:   workerIDBits,
		sequenceBits:   sequenceBits,
		maxSequence:    (1 << sequenceBits) - 1,
		workerIDShift:  sequenceBits,
		timestampShift: sequenceBits + workerIDBits,
	}
}

func (s *SnowflakeGenerator) Generate() int64 {
	s.mu.Lock()
	defer s.mu.Unlock()

	timestamp := time.Now().UnixMilli() - s.epoch

	if timestamp == s.lastTimestamp {
		s.sequence = (s.sequence + 1) & s.maxSequence
		if s.sequence == 0 {
			for timestamp <= s.lastTimestamp {
				timestamp = time.Now().UnixMilli() - s.epoch
			}
		}
	} else {
		s.sequence = 0
	}

	s.lastTimestamp = timestamp

	return (timestamp << int64(s.timestampShift)) |
		(s.workerID << int64(s.workerIDShift)) |
		s.sequence
}

// ===========================================
// 2. BASE62 ENCODER
// ===========================================
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

func base62Encode(num int64) string {
	if num == 0 {
		return string(base62Chars[0])
	}
	var result strings.Builder
	n := uint64(num) // treat as unsigned for encoding
	for n > 0 {
		result.WriteByte(base62Chars[n%62])
		n /= 62
	}
	// Reverse
	runes := []rune(result.String())
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

// ===========================================
// 3. DATA TYPES
// ===========================================
type URLRecord struct {
	ID          int64     `json:"id"`
	ShortCode   string    `json:"shortCode"`
	OriginalURL string    `json:"originalUrl"`
	UserID      string    `json:"userId"`
	CreatedAt   time.Time `json:"createdAt"`
	ExpiresAt   *time.Time `json:"expiresAt,omitempty"`
	ClickCount  atomic.Int64 `json:"-"`
	Clicks      int64     `json:"clickCount"`
}

type ClickEvent struct {
	ShortCode string `json:"shortCode"`
	Timestamp string `json:"timestamp"`
	IP        string `json:"ip"`
	UserAgent string `json:"userAgent"`
	Referer   string `json:"referer"`
}

// ===========================================
// 4. URL SHORTENER SERVICE
// ===========================================
type URLShortener struct {
	mu        sync.RWMutex
	idGen     *SnowflakeGenerator
	urls      map[string]*URLRecord // short_code -> record
	analytics chan ClickEvent
	baseURL   string
}

func NewURLShortener(workerID int64, baseURL string) *URLShortener {
	s := &URLShortener{
		idGen:     NewSnowflakeGenerator(workerID),
		urls:      make(map[string]*URLRecord),
		analytics: make(chan ClickEvent, 10000),
		baseURL:   baseURL,
	}
	go s.processAnalytics()
	return s
}

func (s *URLShortener) CreateShortURL(originalURL, userID string, expiresIn *int) (string, string, error) {
	if _, err := url.ParseRequestURI(originalURL); err != nil {
		return "", "", fmt.Errorf("invalid URL format")
	}

	// Deduplication check
	s.mu.RLock()
	for _, r := range s.urls {
		if r.OriginalURL == originalURL && r.UserID == userID {
			s.mu.RUnlock()
			return fmt.Sprintf("%s/%s", s.baseURL, r.ShortCode), r.ShortCode, nil
		}
	}
	s.mu.RUnlock()

	id := s.idGen.Generate()
	shortCode := base62Encode(id)

	record := &URLRecord{
		ID:          id,
		ShortCode:   shortCode,
		OriginalURL: originalURL,
		UserID:      userID,
		CreatedAt:   time.Now().UTC(),
	}
	if expiresIn != nil {
		t := time.Now().Add(time.Duration(*expiresIn) * time.Second)
		record.ExpiresAt = &t
	}

	s.mu.Lock()
	s.urls[shortCode] = record
	s.mu.Unlock()

	return fmt.Sprintf("%s/%s", s.baseURL, shortCode), shortCode, nil
}

func (s *URLShortener) Resolve(shortCode, ip, userAgent, referer string) (string, error) {
	s.mu.RLock()
	record, exists := s.urls[shortCode]
	s.mu.RUnlock()

	if !exists {
		return "", fmt.Errorf("not found")
	}

	if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) {
		return "", fmt.Errorf("expired")
	}

	record.ClickCount.Add(1)

	// Async analytics
	select {
	case s.analytics <- ClickEvent{
		ShortCode: shortCode,
		Timestamp: time.Now().UTC().Format(time.RFC3339),
		IP:        ip,
		UserAgent: userAgent,
		Referer:   referer,
	}:
	default:
		// Buffer full, drop event (in production: log warning)
	}

	return record.OriginalURL, nil
}

func (s *URLShortener) GetStats(shortCode string) (*URLRecord, error) {
	s.mu.RLock()
	record, exists := s.urls[shortCode]
	s.mu.RUnlock()
	if !exists {
		return nil, fmt.Errorf("not found")
	}
	record.Clicks = record.ClickCount.Load()
	return record, nil
}

func (s *URLShortener) processAnalytics() {
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()
	var buffer []ClickEvent

	for {
		select {
		case event := <-s.analytics:
			buffer = append(buffer, event)
		case <-ticker.C:
			if len(buffer) > 0 {
				log.Printf("[Analytics] Flushed %d click events", len(buffer))
				buffer = buffer[:0]
			}
		}
	}
}

// ===========================================
// 5. RATE LIMITER
// ===========================================
type RateLimiter struct {
	mu      sync.Mutex
	windows map[string][]int64
}

func NewRateLimiter() *RateLimiter {
	return &RateLimiter{windows: make(map[string][]int64)}
}

func (rl *RateLimiter) IsAllowed(key string, maxReq int, windowMs int64) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	now := time.Now().UnixMilli()
	timestamps := rl.windows[key]

	var valid []int64
	for _, t := range timestamps {
		if t > now-windowMs {
			valid = append(valid, t)
		}
	}
	valid = append(valid, now)
	rl.windows[key] = valid

	return len(valid) <= maxReq
}

// ===========================================
// 6. HTTP SERVER
// ===========================================
func main() {
	baseURL := os.Getenv("BASE_URL")
	if baseURL == "" {
		baseURL = "http://localhost:3000"
	}
	svc := NewURLShortener(1, baseURL)
	rl := NewRateLimiter()

	codePattern := regexp.MustCompile(`^/([a-zA-Z0-9]+)$`)
	statsPattern := regexp.MustCompile(`^/api/stats/([a-zA-Z0-9]+)$`)

	mux := http.NewServeMux()

	mux.HandleFunc("/api/shorten", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			writeJSON(w, 405, map[string]string{"error": "Method not allowed"})
			return
		}

		ip := r.RemoteAddr
		if !rl.IsAllowed(ip, 10, 60000) {
			writeJSON(w, 429, map[string]string{"error": "Too many requests"})
			return
		}

		var body struct {
			URL       string `json:"url"`
			UserID    string `json:"userId"`
			ExpiresIn *int   `json:"expiresIn"`
		}
		if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
			writeJSON(w, 400, map[string]string{"error": "Invalid JSON"})
			return
		}
		if body.URL == "" {
			writeJSON(w, 400, map[string]string{"error": "url is required"})
			return
		}

		userID := body.UserID
		if userID == "" {
			userID = "anonymous"
		}

		shortURL, shortCode, err := svc.CreateShortURL(body.URL, userID, body.ExpiresIn)
		if err != nil {
			writeJSON(w, 400, map[string]string{"error": err.Error()})
			return
		}

		writeJSON(w, 201, map[string]string{"shortUrl": shortURL, "shortCode": shortCode})
	})

	mux.HandleFunc("/api/stats/", func(w http.ResponseWriter, r *http.Request) {
		m := statsPattern.FindStringSubmatch(r.URL.Path)
		if m == nil {
			writeJSON(w, 404, map[string]string{"error": "Not found"})
			return
		}
		stats, err := svc.GetStats(m[1])
		if err != nil {
			writeJSON(w, 404, map[string]string{"error": "URL not found"})
			return
		}
		writeJSON(w, 200, stats)
	})

	mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(w, 200, map[string]string{"status": "ok"})
	})

	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		m := codePattern.FindStringSubmatch(r.URL.Path)
		if m == nil {
			writeJSON(w, 404, map[string]string{"error": "Not found"})
			return
		}

		originalURL, err := svc.Resolve(m[1], r.RemoteAddr,
			r.Header.Get("User-Agent"), r.Header.Get("Referer"))
		if err != nil {
			writeJSON(w, 404, map[string]string{"error": "URL not found or expired"})
			return
		}

		w.Header().Set("Location", originalURL)
		w.Header().Set("Cache-Control", "private, max-age=90")
		w.WriteHeader(http.StatusMovedPermanently)
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}

	srv := &http.Server{
		Addr:         ":" + port,
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	go func() {
		log.Printf("URL Shortener on http://localhost:%s", port)
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("Shutting down...")
	srv.Close()
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

ডিজাইন সিদ্ধান্তের ব্যাখ্যা

UUID বা auto-increment-এর বদলে Snowflake ID কেন?

  • Auto-increment অনুমানযোগ্য (users URL guess করতে পারে) এবং একাধিক server-এ কাজ করে না
  • UUID 36 character — একটা short URL-এর জন্য বড্ড লম্বা
  • Snowflake ID 64-bit, সময় অনুযায়ী sortable, worker-জুড়ে unique, এবং 7-11 base62 character-এ encode হয়

302-এর বদলে 301 redirect কেন?

  • 301 (Permanent) — browser এটা cache করে, server load কমায়। যেসব link বদলাবে না তার জন্য ব্যবহার করুন।
  • 302 (Temporary) — প্রতিটি click আপনার server-এ আসে। accurate click tracking দরকার হলে ব্যবহার করুন।
  • আমরা caching আর analytics accuracy-র মধ্যে ভারসাম্য রাখতে ছোট max-age সহ 301 ব্যবহার করি।

async analytics কেন?

Click tracking কখনো redirect ধীর করা উচিত নয়। আমরা event buffer করে batch-এ একটি analytics pipeline-এ (Kafka, SQS) flush করি। একটি analytics event হারিয়ে গেলে চলবে; একটি ধীর redirect চলবে না।

মূল কথা

  • Snowflake ID আপনাকে server-জুড়ে কোনো coordination ছাড়াই globally unique, সময় অনুযায়ী sorted, compact ID দেয়
  • Base62 encoding ছোট, URL-safe string বানায় — 7 character দিয়ে 3.5 trillion URL encode করা যায়
  • Hot URL-এর জন্য LRU cache ব্যবহার করুন — উপরের 20% URL-ই 80% traffic পায়
  • Analytics অবশ্যই async হতে হবে — tracking-এর জন্য কখনো redirect path block করবেন না
  • API layer-এ rate limiting অপব্যবহার আটকায় এবং downstream service রক্ষা করে
  • URL deduplication storage বাঁচায় এবং consistency নিশ্চিত করে

বাস্তব ব্যবহার

  • Bitly এই একই architecture ব্যবহার করে মাসে 600M+ click প্রসেস করে
  • TinyURL একটি distributed database-এ কোটি কোটি URL mapping store করে
  • Twitter-এর t.co tracking আর link safety-র জন্য tweet-এর প্রতিটি URL shorten করে
  • YouTube video URL-এর জন্য base64-encoded ID ব্যবহার করে (11 character = 73 quintillion সম্ভাব্য ID)
  • এই architecture মাঝারি hardware-এ প্রতি সেকেন্ডে 100K+ redirect সামলায়। আরও বেশির জন্য Redis caching আর horizontal scaling যোগ করুন।