Skip to main content

Design URL Shortener Service

Table of Contents

  1. Overview
  2. System Requirements
  3. Class Design
  4. Database Schema
  5. API Design
  6. Key Algorithms
  7. Design Patterns
  8. Error Handling
  9. Performance Optimizations

Overview

This document provides the Low-Level Design (LLD) for a URL Shortener Service, focusing on detailed class structures, data models, and implementation details for core features including URL shortening, redirection, analytics tracking, caching, and security.

System Requirements

Functional Requirements

  • Create short URLs from long URLs
  • Redirect short URLs to original URLs
  • Support custom aliases (vanity URLs)
  • URL expiration management
  • Analytics tracking (clicks, geography, referrer)
  • User authentication and authorization
  • Rate limiting
  • URL validation and security checks

Non-Functional Requirements

  • High availability (99.99% uptime)
  • Low latency (< 100ms for redirects)
  • Scalability (billions of URLs, millions of requests/sec)
  • High read-to-write ratio (100:1)
  • Data durability and consistency

Class Design

Core Classes

URL

class URL {
constructor(urlId, shortCode, longUrl, userId = null) {
this.urlId = urlId;
this.shortCode = shortCode;
this.longUrl = longUrl;
this.userId = userId;
this.createdAt = new Date();
this.expiresAt = null;
this.isActive = true;
this.isCustom = false;
this.clickCount = 0;
this.lastAccessedAt = null;
}

isExpired() {
if (!this.expiresAt) return false;
return new Date() > this.expiresAt;
}

deactivate() {
this.isActive = false;
}

activate() {
this.isActive = true;
}

updateExpiration(expiresAt) {
this.expiresAt = expiresAt;
}

incrementClickCount() {
this.clickCount++;
this.lastAccessedAt = new Date();
}
}

User

class User {
constructor(userId, email, apiKey) {
this.userId = userId;
this.email = email;
this.apiKey = apiKey;
this.createdAt = new Date();
this.rateLimit = 1000; // requests per hour
this.isPremium = false;
this.urlsCount = 0;
}

updateRateLimit(newLimit) {
this.rateLimit = newLimit;
}

upgradeToPremium() {
this.isPremium = true;
this.rateLimit = 10000; // Higher limit for premium
}

incrementUrlsCount() {
this.urlsCount++;
}
}

ClickEvent

class ClickEvent {
constructor(clickId, shortCode, metadata) {
this.clickId = clickId;
this.shortCode = shortCode;
this.clickedAt = new Date();
this.ipAddress = metadata.ipAddress;
this.userAgent = metadata.userAgent;
this.referrer = metadata.referrer || null;
this.country = metadata.country || null;
this.city = metadata.city || null;
this.deviceType = metadata.deviceType || null;
this.browser = metadata.browser || null;
}

getDeviceType() {
if (!this.userAgent) return 'unknown';
if (/mobile/i.test(this.userAgent)) return 'mobile';
if (/tablet/i.test(this.userAgent)) return 'tablet';
return 'desktop';
}

getBrowser() {
if (!this.userAgent) return 'unknown';
if (this.userAgent.includes('Chrome')) return 'Chrome';
if (this.userAgent.includes('Firefox')) return 'Firefox';
if (this.userAgent.includes('Safari')) return 'Safari';
if (this.userAgent.includes('Edge')) return 'Edge';
return 'unknown';
}
}

Analytics

class Analytics {
constructor(shortCode) {
this.shortCode = shortCode;
this.totalClicks = 0;
this.clicksByDate = new Map(); // date -> count
this.clicksByCountry = new Map(); // country -> count
this.clicksByReferrer = new Map(); // referrer -> count
this.clicksByDevice = new Map(); // device -> count
this.clicksByBrowser = new Map(); // browser -> count
this.firstClickAt = null;
this.lastClickAt = null;
}

addClick(clickEvent) {
this.totalClicks++;

const date = clickEvent.clickedAt.toISOString().split('T')[0];
this.clicksByDate.set(date, (this.clicksByDate.get(date) || 0) + 1);

if (clickEvent.country) {
this.clicksByCountry.set(
clickEvent.country,
(this.clicksByCountry.get(clickEvent.country) || 0) + 1
);
}

if (clickEvent.referrer) {
const domain = this.extractDomain(clickEvent.referrer);
this.clicksByReferrer.set(
domain,
(this.clicksByReferrer.get(domain) || 0) + 1
);
}

if (clickEvent.deviceType) {
this.clicksByDevice.set(
clickEvent.deviceType,
(this.clicksByDevice.get(clickEvent.deviceType) || 0) + 1
);
}

if (clickEvent.browser) {
this.clicksByBrowser.set(
clickEvent.browser,
(this.clicksByBrowser.get(clickEvent.browser) || 0) + 1
);
}

if (!this.firstClickAt) {
this.firstClickAt = clickEvent.clickedAt;
}
this.lastClickAt = clickEvent.clickedAt;
}

extractDomain(referrer) {
try {
const url = new URL(referrer);
return url.hostname;
} catch {
return referrer;
}
}

getSummary() {
return {
totalClicks: this.totalClicks,
clicksByDate: Object.fromEntries(this.clicksByDate),
clicksByCountry: Object.fromEntries(this.clicksByCountry),
clicksByReferrer: Object.fromEntries(this.clicksByReferrer),
clicksByDevice: Object.fromEntries(this.clicksByDevice),
clicksByBrowser: Object.fromEntries(this.clicksByBrowser),
firstClickAt: this.firstClickAt,
lastClickAt: this.lastClickAt
};
}
}

Service Classes

URLShortenerService

class URLShortenerService {
constructor(
urlRepository,
cacheService,
shortCodeGenerator,
urlValidator,
analyticsService
) {
this.urlRepository = urlRepository;
this.cacheService = cacheService;
this.shortCodeGenerator = shortCodeGenerator;
this.urlValidator = urlValidator;
this.analyticsService = analyticsService;
}

async createShortURL(longUrl, options = {}) {
const {
customAlias = null,
userId = null,
expiresAt = null
} = options;

// Validate URL
await this.urlValidator.validate(longUrl);

let shortCode;

if (customAlias) {
// Check if custom alias already exists
const exists = await this.urlRepository.findByShortCode(customAlias);
if (exists) {
throw new Error('Custom alias already exists');
}
shortCode = customAlias;
} else {
// Generate unique short code
shortCode = await this.generateUniqueShortCode();
}

// Create URL record
const url = new URL(
this.generateId(),
shortCode,
longUrl,
userId
);

if (expiresAt) {
url.updateExpiration(new Date(expiresAt));
}

url.isCustom = !!customAlias;

// Save to database
await this.urlRepository.save(url);

// Cache the mapping
await this.cacheService.set(
shortCode,
longUrl,
{ ttl: 3600 } // 1 hour TTL
);

// Update user's URL count if applicable
if (userId) {
await this.userRepository.incrementUrlsCount(userId);
}

return {
shortUrl: `${process.env.BASE_URL}/${shortCode}`,
longUrl: url.longUrl,
shortCode: url.shortCode,
createdAt: url.createdAt,
expiresAt: url.expiresAt
};
}

async generateUniqueShortCode() {
const maxAttempts = 5;
let attempts = 0;

while (attempts < maxAttempts) {
const shortCode = await this.shortCodeGenerator.generate();

// Check if code exists
const exists = await this.urlRepository.findByShortCode(shortCode);

if (!exists) {
return shortCode;
}

attempts++;
}

throw new Error('Failed to generate unique short code after multiple attempts');
}

async getLongURL(shortCode) {
// Try cache first
let longUrl = await this.cacheService.get(shortCode);

if (longUrl) {
return longUrl;
}

// Cache miss - query database
const url = await this.urlRepository.findByShortCode(shortCode);

if (!url) {
throw new Error('URL not found');
}

// Check if expired
if (url.isExpired()) {
throw new Error('URL has expired');
}

// Check if active
if (!url.isActive) {
throw new Error('URL has been deactivated');
}

// Update cache
await this.cacheService.set(shortCode, url.longUrl, { ttl: 3600 });

return url.longUrl;
}

async updateURL(shortCode, updates, userId) {
const url = await this.urlRepository.findByShortCode(shortCode);

if (!url) {
throw new Error('URL not found');
}

// Check authorization
if (url.userId && url.userId !== userId) {
throw new Error('Unauthorized');
}

if (updates.longUrl) {
await this.urlValidator.validate(updates.longUrl);
url.longUrl = updates.longUrl;
}

if (updates.expiresAt) {
url.updateExpiration(new Date(updates.expiresAt));
}

await this.urlRepository.update(url);

// Invalidate cache
await this.cacheService.delete(shortCode);

return url;
}

async deleteURL(shortCode, userId) {
const url = await this.urlRepository.findByShortCode(shortCode);

if (!url) {
throw new Error('URL not found');
}

// Check authorization
if (url.userId && url.userId !== userId) {
throw new Error('Unauthorized');
}

// Soft delete
url.deactivate();
await this.urlRepository.update(url);

// Invalidate cache
await this.cacheService.delete(shortCode);

return true;
}
}

RedirectService

class RedirectService {
constructor(
urlShortenerService,
analyticsService,
cacheService
) {
this.urlShortenerService = urlShortenerService;
this.analyticsService = analyticsService;
this.cacheService = cacheService;
}

async redirect(shortCode, requestMetadata) {
// Get long URL
const longUrl = await this.urlShortenerService.getLongURL(shortCode);

// Track click asynchronously (fire and forget)
this.trackClick(shortCode, requestMetadata).catch(err => {
console.error('Failed to track click:', err);
});

return longUrl;
}

async trackClick(shortCode, requestMetadata) {
const clickEvent = new ClickEvent(
this.generateId(),
shortCode,
{
ipAddress: requestMetadata.ip,
userAgent: requestMetadata.userAgent,
referrer: requestMetadata.referrer,
country: requestMetadata.country,
city: requestMetadata.city,
deviceType: this.parseDeviceType(requestMetadata.userAgent),
browser: this.parseBrowser(requestMetadata.userAgent)
}
);

// Send to analytics service (async via message queue)
await this.analyticsService.trackClick(clickEvent);
}

parseDeviceType(userAgent) {
if (!userAgent) return 'unknown';
if (/mobile/i.test(userAgent)) return 'mobile';
if (/tablet/i.test(userAgent)) return 'tablet';
return 'desktop';
}

parseBrowser(userAgent) {
if (!userAgent) return 'unknown';
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
return 'unknown';
}
}

ShortCodeGenerator

class ShortCodeGenerator {
constructor(database) {
this.database = database;
this.base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
}

async generate() {
// Counter-based generation
const counter = await this.database.getNextSequence('url_counter');
return this.toBase62(counter);
}

toBase62(num) {
if (num === 0) return this.base62Chars[0];

let result = '';
while (num > 0) {
result = this.base62Chars[num % 62] + result;
num = Math.floor(num / 62);
}

return result;
}

fromBase62(str) {
let num = 0;
for (let i = 0; i < str.length; i++) {
num = num * 62 + this.base62Chars.indexOf(str[i]);
}
return num;
}
}

URLValidator

class URLValidator {
constructor(blacklistService, malwareChecker) {
this.blacklistService = blacklistService;
this.malwareChecker = malwareChecker;
}

async validate(url) {
// Check URL format
if (!this.isValidFormat(url)) {
throw new Error('Invalid URL format');
}

// Check against blacklist
if (await this.blacklistService.isBlacklisted(url)) {
throw new Error('URL is blacklisted');
}

// Check for malware/phishing
if (await this.malwareChecker.hasMalware(url)) {
throw new Error('URL contains malware or is a phishing site');
}

return true;
}

isValidFormat(url) {
try {
const urlObj = new URL(url);
return ['http:', 'https:'].includes(urlObj.protocol);
} catch {
return false;
}
}
}

AnalyticsService

class AnalyticsService {
constructor(
clickRepository,
analyticsRepository,
messageQueue
) {
this.clickRepository = clickRepository;
this.analyticsRepository = analyticsRepository;
this.messageQueue = messageQueue;
}

async trackClick(clickEvent) {
// Publish to message queue for async processing
await this.messageQueue.publish('url_clicks', {
clickId: clickEvent.clickId,
shortCode: clickEvent.shortCode,
clickedAt: clickEvent.clickedAt,
ipAddress: clickEvent.ipAddress,
userAgent: clickEvent.userAgent,
referrer: clickEvent.referrer,
country: clickEvent.country,
city: clickEvent.city,
deviceType: clickEvent.deviceType,
browser: clickEvent.browser
});
}

async processClickEvents() {
// Consumer for message queue
this.messageQueue.subscribe('url_clicks', async (clickData) => {
// Save to database
await this.clickRepository.save(clickData);

// Update aggregated analytics
await this.updateAnalytics(clickData.shortCode, clickData);
});
}

async updateAnalytics(shortCode, clickData) {
let analytics = await this.analyticsRepository.findByShortCode(shortCode);

if (!analytics) {
analytics = new Analytics(shortCode);
}

const clickEvent = new ClickEvent(
clickData.clickId,
shortCode,
{
ipAddress: clickData.ipAddress,
userAgent: clickData.userAgent,
referrer: clickData.referrer,
country: clickData.country,
city: clickData.city,
deviceType: clickData.deviceType,
browser: clickData.browser
}
);
clickEvent.clickedAt = new Date(clickData.clickedAt);

analytics.addClick(clickEvent);
await this.analyticsRepository.save(analytics);
}

async getAnalytics(shortCode, startDate = null, endDate = null) {
const analytics = await this.analyticsRepository.findByShortCode(shortCode);

if (!analytics) {
return {
totalClicks: 0,
clicksByDate: {},
clicksByCountry: {},
clicksByReferrer: {},
clicksByDevice: {},
clicksByBrowser: {}
};
}

return analytics.getSummary();
}

async getClickHistory(shortCode, page = 1, pageSize = 20) {
return await this.clickRepository.findByShortCode(
shortCode,
page,
pageSize
);
}
}

RateLimiter

class RateLimiter {
constructor(redisClient) {
this.redis = redisClient;
}

async checkLimit(identifier, limit = 100, windowSeconds = 3600) {
const key = `ratelimit:${identifier}`;

// Increment counter
const current = await this.redis.incr(key);

// Set expiry on first request
if (current === 1) {
await this.redis.expire(key, windowSeconds);
}

if (current > limit) {
throw new Error('Rate limit exceeded');
}

return {
allowed: true,
remaining: limit - current,
resetAt: new Date(Date.now() + windowSeconds * 1000)
};
}

async getRemaining(identifier) {
const key = `ratelimit:${identifier}`;
const current = await this.redis.get(key);
return current ? parseInt(current) : 0;
}
}

CacheService

class CacheService {
constructor(redisClient) {
this.redis = redisClient;
}

async get(key) {
const value = await this.redis.get(key);
return value ? JSON.parse(value) : null;
}

async set(key, value, options = {}) {
const { ttl = 3600 } = options;
const serialized = JSON.stringify(value);

if (ttl) {
await this.redis.setex(key, ttl, serialized);
} else {
await this.redis.set(key, serialized);
}
}

async delete(key) {
await this.redis.del(key);
}

async deletePattern(pattern) {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}

async exists(key) {
return await this.redis.exists(key) === 1;
}
}

Database Schema

URLs Table

CREATE TABLE urls (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
url_id VARCHAR(50) UNIQUE NOT NULL,
short_code VARCHAR(10) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
is_active BOOLEAN DEFAULT TRUE,
is_custom BOOLEAN DEFAULT FALSE,
click_count INT DEFAULT 0,
last_accessed_at TIMESTAMP NULL,

INDEX idx_short_code (short_code),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at),
INDEX idx_expires_at (expires_at),
INDEX idx_is_active (is_active),
FOREIGN KEY (user_id) REFERENCES users(user_id)
);

Users Table

CREATE TABLE users (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) UNIQUE NOT NULL,
api_key VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
rate_limit INT DEFAULT 1000,
is_premium BOOLEAN DEFAULT FALSE,
urls_count INT DEFAULT 0,

INDEX idx_email (email),
INDEX idx_api_key (api_key)
);

Click Events Table

CREATE TABLE click_events (
click_id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) NOT NULL,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45),
user_agent TEXT,
referrer TEXT,
country VARCHAR(2),
city VARCHAR(100),
device_type VARCHAR(20),
browser VARCHAR(50),

INDEX idx_short_code (short_code),
INDEX idx_clicked_at (clicked_at),
INDEX idx_country (country),
FOREIGN KEY (short_code) REFERENCES urls(short_code)
);

-- Partition by date for better performance
PARTITION BY RANGE (YEAR(clicked_at), MONTH(clicked_at));

Analytics Table (Aggregated)

CREATE TABLE analytics (
analytics_id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) UNIQUE NOT NULL,
total_clicks INT DEFAULT 0,
clicks_by_date JSON,
clicks_by_country JSON,
clicks_by_referrer JSON,
clicks_by_device JSON,
clicks_by_browser JSON,
first_click_at TIMESTAMP NULL,
last_click_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

INDEX idx_short_code (short_code),
FOREIGN KEY (short_code) REFERENCES urls(short_code)
);

Counter Table (for ID generation)

CREATE TABLE counters (
counter_name VARCHAR(50) PRIMARY KEY,
counter_value BIGINT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Initialize counter
INSERT INTO counters (counter_name, counter_value) VALUES ('url_counter', 0);

API Design

RESTful Endpoints

URL Endpoints

POST   /api/v1/urls                    # Create short URL
GET /api/v1/urls/:shortCode # Get URL details
PUT /api/v1/urls/:shortCode # Update URL
DELETE /api/v1/urls/:shortCode # Delete URL
GET /api/v1/urls/:shortCode/analytics # Get analytics

Redirect Endpoint

GET    /:shortCode                     # Redirect to long URL

User Endpoints

POST   /api/v1/users/register          # Register user
POST /api/v1/users/login # Login
GET /api/v1/users/me # Get current user
GET /api/v1/users/me/urls # Get user's URLs

Request/Response Examples

Create Short URL

POST /api/v1/urls
Content-Type: application/json
Authorization: Bearer <api_key>

{
"long_url": "https://example.com/very/long/url",
"custom_alias": "my-link",
"expiration_time": "2024-12-31T23:59:59Z"
}

Response: 201 Created
{
"short_url": "https://short.ly/my-link",
"long_url": "https://example.com/very/long/url",
"short_code": "my-link",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-12-31T23:59:59Z"
}

Redirect

GET /abc123

Response: 301 Moved Permanently
Location: https://example.com/very/long/url

Get Analytics

GET /api/v1/urls/abc123/analytics?start_date=2024-01-01&end_date=2024-01-31
Authorization: Bearer <api_key>

Response: 200 OK
{
"short_code": "abc123",
"total_clicks": 15420,
"clicks_by_date": {
"2024-01-15": 1200,
"2024-01-16": 950
},
"clicks_by_country": {
"US": 8500,
"IN": 3200
},
"clicks_by_referrer": {
"twitter.com": 5000,
"facebook.com": 3200
},
"clicks_by_device": {
"mobile": 8000,
"desktop": 6000,
"tablet": 1420
},
"clicks_by_browser": {
"Chrome": 9000,
"Safari": 4000,
"Firefox": 2420
},
"first_click_at": "2024-01-15T10:30:00Z",
"last_click_at": "2024-01-16T14:22:10Z"
}

Key Algorithms

Base62 Encoding Algorithm

class Base62Encoder {
constructor() {
this.chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
}

encode(num) {
if (num === 0) return this.chars[0];

let result = '';
while (num > 0) {
result = this.chars[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}

decode(str) {
let num = 0;
for (let i = 0; i < str.length; i++) {
num = num * 62 + this.chars.indexOf(str[i]);
}
return num;
}
}

URL Shortening Algorithm

class URLShorteningAlgorithm {
constructor(database) {
this.database = database;
this.encoder = new Base62Encoder();
}

async generateShortCode() {
// Get next counter value atomically
const counter = await this.database.incrementCounter('url_counter');

// Encode to Base62
return this.encoder.encode(counter);
}

// Alternative: Hash-based approach
generateFromHash(longUrl) {
const crypto = require('crypto');
const hash = crypto.createHash('md5').update(longUrl).digest('hex');
const shortHash = hash.substring(0, 7);
return this.encoder.encode(parseInt(shortHash, 16));
}
}

Cache-Aside Pattern

async function getURLWithCache(shortCode) {
// Try cache first
let url = await cache.get(shortCode);

if (url) {
return url; // Cache hit
}

// Cache miss - query database
url = await database.findByShortCode(shortCode);

if (url) {
// Update cache
await cache.set(shortCode, url, { ttl: 3600 });
}

return url;
}

Rate Limiting Algorithm (Token Bucket)

class TokenBucketRateLimiter {
constructor(redis, capacity, refillRate) {
this.redis = redis;
this.capacity = capacity; // Max tokens
this.refillRate = refillRate; // Tokens per second
}

async checkLimit(identifier) {
const key = `token_bucket:${identifier}`;
const now = Date.now();

// Get current state
const state = await this.redis.hgetall(key);
const tokens = parseFloat(state.tokens || this.capacity);
const lastRefill = parseFloat(state.lastRefill || now);

// Calculate tokens to add
const elapsed = (now - lastRefill) / 1000; // seconds
const tokensToAdd = elapsed * this.refillRate;
const newTokens = Math.min(this.capacity, tokens + tokensToAdd);

if (newTokens < 1) {
throw new Error('Rate limit exceeded');
}

// Consume one token
const remainingTokens = newTokens - 1;

// Update state
await this.redis.hset(key, {
tokens: remainingTokens,
lastRefill: now
});
await this.redis.expire(key, 3600);

return {
allowed: true,
remaining: Math.floor(remainingTokens),
resetAt: new Date(now + ((this.capacity - remainingTokens) / this.refillRate * 1000))
};
}
}

Design Patterns

Repository Pattern

class URLRepository {
constructor(database) {
this.db = database;
}

async save(url) {
const query = `
INSERT INTO urls (url_id, short_code, long_url, user_id, expires_at, is_custom)
VALUES (?, ?, ?, ?, ?, ?)
`;
await this.db.query(query, [
url.urlId,
url.shortCode,
url.longUrl,
url.userId,
url.expiresAt,
url.isCustom
]);
}

async findByShortCode(shortCode) {
const query = 'SELECT * FROM urls WHERE short_code = ? AND is_active = TRUE';
const rows = await this.db.query(query, [shortCode]);
return rows[0] ? this.mapToURL(rows[0]) : null;
}

async update(url) {
const query = `
UPDATE urls
SET long_url = ?, expires_at = ?, is_active = ?, click_count = ?, last_accessed_at = ?
WHERE short_code = ?
`;
await this.db.query(query, [
url.longUrl,
url.expiresAt,
url.isActive,
url.clickCount,
url.lastAccessedAt,
url.shortCode
]);
}

mapToURL(row) {
const url = new URL(row.url_id, row.short_code, row.long_url, row.user_id);
url.expiresAt = row.expires_at ? new Date(row.expires_at) : null;
url.isActive = row.is_active;
url.isCustom = row.is_custom;
url.clickCount = row.click_count;
url.lastAccessedAt = row.last_accessed_at ? new Date(row.last_accessed_at) : null;
return url;
}
}

Service Layer Pattern

Business logic separated into service classes:

  • URLShortenerService
  • RedirectService
  • AnalyticsService
  • URLValidator
  • RateLimiter

Factory Pattern

class ShortCodeGeneratorFactory {
static create(type, database) {
switch (type) {
case 'counter':
return new CounterBasedGenerator(database);
case 'hash':
return new HashBasedGenerator();
case 'random':
return new RandomGenerator();
default:
throw new Error('Unknown generator type');
}
}
}

Strategy Pattern

class RedirectStrategy {
redirect(url) {
throw new Error('Must implement redirect method');
}
}

class PermanentRedirectStrategy extends RedirectStrategy {
redirect(url) {
return { statusCode: 301, location: url };
}
}

class TemporaryRedirectStrategy extends RedirectStrategy {
redirect(url) {
return { statusCode: 302, location: url };
}
}

Observer Pattern (for Analytics)

class ClickEventObserver {
constructor() {
this.observers = [];
}

subscribe(observer) {
this.observers.push(observer);
}

notify(clickEvent) {
this.observers.forEach(observer => observer.onClick(clickEvent));
}
}

class AnalyticsObserver {
onClick(clickEvent) {
// Update analytics
analyticsService.updateAnalytics(clickEvent);
}
}

class LoggingObserver {
onClick(clickEvent) {
// Log click event
logger.info('Click event:', clickEvent);
}
}

Error Handling

Error Types

class URLShortenerError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = 'URLShortenerError';
}
}

class URLNotFoundError extends URLShortenerError {
constructor(shortCode) {
super(`URL with short code ${shortCode} not found`, 404);
this.name = 'URLNotFoundError';
}
}

class URLExpiredError extends URLShortenerError {
constructor(shortCode) {
super(`URL with short code ${shortCode} has expired`, 410);
this.name = 'URLExpiredError';
}
}

class InvalidURLError extends URLShortenerError {
constructor(url) {
super(`Invalid URL: ${url}`, 400);
this.name = 'InvalidURLError';
}
}

class AliasExistsError extends URLShortenerError {
constructor(alias) {
super(`Custom alias ${alias} already exists`, 409);
this.name = 'AliasExistsError';
}
}

class RateLimitExceededError extends URLShortenerError {
constructor() {
super('Rate limit exceeded', 429);
this.name = 'RateLimitExceededError';
}
}

class UnauthorizedError extends URLShortenerError {
constructor() {
super('Unauthorized', 401);
this.name = 'UnauthorizedError';
}
}

Error Handling Middleware

function errorHandler(err, req, res, next) {
if (err instanceof URLShortenerError) {
return res.status(err.statusCode).json({
error: {
message: err.message,
type: err.name,
code: err.code || 'UNKNOWN_ERROR'
}
});
}

// Default error
console.error('Unexpected error:', err);
res.status(500).json({
error: {
message: 'Internal server error',
type: 'InternalServerError',
code: 'INTERNAL_ERROR'
}
});
}

Performance Optimizations

Caching Strategy

  • Hot URLs Cache: Cache top 20% of URLs (80/20 rule)
  • TTL Strategy:
    • Normal URLs: 1 hour
    • Hot URLs: 24 hours
    • Custom URLs: 6 hours
  • Cache Warming: Preload popular URLs on startup
  • Cache Invalidation: Invalidate on update/delete

Database Optimizations

  • Indexes on frequently queried columns (short_code, user_id, created_at)
  • Partitioning for click_events table by date
  • Read replicas for read-heavy operations
  • Connection pooling
  • Batch inserts for analytics

Async Processing

  • Click tracking via message queue (Kafka/RabbitMQ)
  • Analytics aggregation in background jobs
  • Cache updates asynchronously
  • URL validation checks can be async for non-critical validations

CDN and Static Assets

  • Serve static assets via CDN
  • Use CDN for redirect endpoints (Cloudflare, AWS CloudFront)
  • Edge caching for frequently accessed short URLs

Background Jobs

  • Analytics aggregation (hourly/daily)
  • Expired URL cleanup (daily)
  • Cache warming (periodic)
  • Click event processing

Conclusion

This LLD provides a comprehensive design for a URL Shortener Service, focusing on:

  • Clean class structure and separation of concerns
  • Scalable database schema with proper indexing
  • Efficient caching strategies
  • Robust error handling
  • Performance optimizations for high-throughput systems
  • Security considerations (rate limiting, URL validation)

The design follows best practices and design patterns to ensure maintainability, scalability, and high performance for a production-ready URL shortening service.