URL Shortener
Difficulty: Intermediate Patterns: Strategy, Repository, Factory Asked at: Amazon, Google, Microsoft, Uber, Flipkart
Functional Requirements
- Shorten URLs - generate short codes via Base62 encoding of atomic counter
- Custom aliases - register user-specified short codes with conflict checking
- Resolve URLs - redirect short code to long URL with click tracking
- URL expiration - TTL-based expiry with auto-cleanup on resolve
- Usage statistics - track click count, creation time, expiration status
Non-Functional Requirements
- Thread-safety - atomic counter increment and collision-free alias registration
- Extensibility - swappable encoding strategies without changing service logic
- Uniqueness - short codes are guaranteed unique (counter-based or collision-checked)
- Validation - URL format and alias constraints enforced at the service boundary
Core Entities
| Entity | Description |
|---|---|
UrlMapping |
Domain model - short code, long URL, user, click count, timestamps, expiration |
UrlStats |
Read-only stats snapshot - code, URL, clicks, creation time, expiry status |
EncodingStrategy |
Strategy interface - converts a numeric ID into a short code string |
Base62Strategy |
Deterministic encoding - maps counter value to compact Base62 string |
RandomStrategy |
Non-deterministic encoding - generates random alphanumeric codes |
UrlRepository |
Repository interface - abstracts storage (save, find, exists, delete) |
UrlShortenerService |
Core service - atomic counter, encoding, alias registration, resolution |
AliasAlreadyExistsException |
Thrown when a custom alias is already taken |
UrlNotFoundException |
Thrown when a short code has no mapping |
UrlExpiredException |
Thrown when a resolved URL has passed its TTL |
Class Diagram
classDiagram
class UrlMapping {
-String shortCode
-String longUrl
-String userId
-long clickCount
-LocalDateTime createdAt
-LocalDateTime expiresAt
+incrementClicks()
+isExpired() boolean
+getClickCount() long
}
class EncodingStrategy {
<<interface>>
+encode(long id) String
}
class Base62Strategy {
-String ALPHABET
+encode(long id) String
}
class RandomStrategy {
-int length
-SecureRandom random
+encode(long id) String
}
class UrlRepository {
<<interface>>
+save(UrlMapping)
+findByShortCode(String) UrlMapping
+existsByShortCode(String) boolean
+delete(String)
}
class InMemoryUrlRepository {
-ConcurrentHashMap store
+save(UrlMapping)
+findByShortCode(String) UrlMapping
+existsByShortCode(String) boolean
+delete(String)
}
class UrlShortenerService {
-AtomicLong counter
-UrlRepository repository
-EncodingStrategy strategy
-ReentrantLock aliasLock
+shorten(String longUrl) String
+shortenWithAlias(String longUrl and String alias) String
+resolve(String shortCode) String
+delete(String shortCode)
+getStats(String shortCode) UrlStats
}
class UrlStats {
-String shortCode
-String longUrl
-long clickCount
-LocalDateTime createdAt
-LocalDateTime expiresAt
-boolean expired
}
class AliasAlreadyExistsException {
-String alias
}
class UrlNotFoundException {
-String shortCode
}
class UrlExpiredException {
-String shortCode
}
UrlShortenerService --> UrlRepository
UrlShortenerService --> EncodingStrategy
UrlRepository <|.. InMemoryUrlRepository
EncodingStrategy <|.. Base62Strategy
EncodingStrategy <|.. RandomStrategy
InMemoryUrlRepository --> UrlMapping
UrlShortenerService --> UrlStats
Design Patterns
| Pattern | Where | Why |
|---|---|---|
| Strategy | EncodingStrategy with Base62/Random |
Swap encoding algorithm without changing service logic; add new strategies without modifying existing code |
| Repository | UrlRepository with InMemory impl |
Abstracts storage - swap to Redis or DynamoDB for production without touching business logic |
| Factory | UrlMapping creation in service |
Centralized initialization of timestamps, click counts, and expiration ensures consistency |
How It All Fits Together
Hereโs what happens when a client shortens a URL:
- Client calls
shorten(longUrl)on UrlShortenerService - Service validates the URL format (must start with http/https)
- Atomically increments the counter to get a unique ID
- Delegates to the EncodingStrategy to convert the ID to a short code
- Checks for collision (only possible with RandomStrategy); retries if collision found
- Creates a UrlMapping with the short code, long URL, timestamps, and TTL
- Persists to the repository and returns the short code
๐ก With Base62Strategy, collisions are impossible because the counter is monotonically increasing and the encoding is bijective. The collision loop only activates for RandomStrategy where two different IDs could produce the same random string.
Complete Code
UrlMapping and UrlStats
UrlMapping is the core domain entity representing a shortened URL. It tracks the short code, original URL, click count (atomically incremented), and expiration. UrlStats is a read-only snapshot for reporting.
import java.util.concurrent.atomic.AtomicLong;
import java.time.LocalDateTime;
class UrlMapping {
private final String shortCode;
private final String longUrl;
private final String userId;
private final AtomicLong clickCount;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
public UrlMapping(String shortCode, String longUrl, String userId, LocalDateTime expiresAt) {
this.shortCode = shortCode;
this.longUrl = longUrl;
this.userId = userId;
this.clickCount = new AtomicLong(0);
this.createdAt = LocalDateTime.now();
this.expiresAt = expiresAt;
}
public void incrementClicks() {
clickCount.incrementAndGet();
}
public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
}
public String getShortCode() { return shortCode; }
public String getLongUrl() { return longUrl; }
public String getUserId() { return userId; }
public long getClickCount() { return clickCount.get(); }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getExpiresAt() { return expiresAt; }
}
class UrlStats {
private final String shortCode;
private final String longUrl;
private final long clickCount;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
private final boolean expired;
public UrlStats(UrlMapping mapping) {
this.shortCode = mapping.getShortCode();
this.longUrl = mapping.getLongUrl();
this.clickCount = mapping.getClickCount();
this.createdAt = mapping.getCreatedAt();
this.expiresAt = mapping.getExpiresAt();
this.expired = mapping.isExpired();
}
@Override
public String toString() {
return "UrlStats{code=" + shortCode + " url=" + longUrl
+ " clicks=" + clickCount + " expired=" + expired + "}";
}
}
import threading
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
class UrlMapping:
def __init__(self, short_code: str, long_url: str, user_id: Optional[str],
expires_at: Optional[datetime]):
self.short_code = short_code
self.long_url = long_url
self.user_id = user_id
self._click_count = 0
self._click_lock = threading.Lock()
self.created_at = datetime.now()
self.expires_at = expires_at
def increment_clicks(self) -> None:
with self._click_lock:
self._click_count += 1
def is_expired(self) -> bool:
return self.expires_at is not None and datetime.now() > self.expires_at
@property
def click_count(self) -> int:
with self._click_lock:
return self._click_count
@dataclass
class UrlStats:
short_code: str
long_url: str
click_count: int
created_at: datetime
expires_at: Optional[datetime]
expired: bool
def __str__(self) -> str:
return (f"UrlStats{{code={self.short_code} url={self.long_url} "
f"clicks={self.click_count} expired={self.expired}}}")
#include <string>
#include <atomic>
#include <chrono>
using Clock = std::chrono::system_clock;
using TimePoint = std::chrono::time_point<Clock>;
class UrlMapping {
private:
std::string shortCode_;
std::string longUrl_;
std::string userId_;
std::atomic<long> clickCount_;
TimePoint createdAt_;
TimePoint expiresAt_;
public:
UrlMapping(const std::string& shortCode, const std::string& longUrl,
const std::string& userId, TimePoint expiresAt)
: shortCode_(shortCode), longUrl_(longUrl), userId_(userId),
clickCount_(0), createdAt_(Clock::now()), expiresAt_(expiresAt) {}
void incrementClicks() { clickCount_.fetch_add(1); }
bool isExpired() const { return Clock::now() > expiresAt_; }
const std::string& getShortCode() const { return shortCode_; }
const std::string& getLongUrl() const { return longUrl_; }
long getClickCount() const { return clickCount_.load(); }
TimePoint getCreatedAt() const { return createdAt_; }
TimePoint getExpiresAt() const { return expiresAt_; }
};
class UrlMapping {
#shortCode; #longUrl; #userId; #clickCount; #createdAt; #expiresAt;
constructor(shortCode, longUrl, userId, expiresAt) {
this.#shortCode = shortCode;
this.#longUrl = longUrl;
this.#userId = userId;
this.#clickCount = 0;
this.#createdAt = new Date();
this.#expiresAt = expiresAt;
}
incrementClicks() { this.#clickCount++; }
isExpired() { return this.#expiresAt && new Date() > this.#expiresAt; }
get shortCode() { return this.#shortCode; }
get longUrl() { return this.#longUrl; }
get userId() { return this.#userId; }
get clickCount() { return this.#clickCount; }
get createdAt() { return this.#createdAt; }
get expiresAt() { return this.#expiresAt; }
}
Custom Exceptions
Domain-specific exceptions provide clear error semantics for alias conflicts, missing URLs, and expired URLs. Each carries the offending identifier for debugging.
class AliasAlreadyExistsException extends RuntimeException {
public AliasAlreadyExistsException(String alias) {
super("Alias already taken: " + alias);
}
}
class UrlNotFoundException extends RuntimeException {
public UrlNotFoundException(String shortCode) {
super("URL not found for code: " + shortCode);
}
}
class UrlExpiredException extends RuntimeException {
public UrlExpiredException(String shortCode) {
super("URL has expired: " + shortCode);
}
}
class AliasAlreadyExistsError(Exception):
def __init__(self, alias: str):
super().__init__(f"Alias already taken: {alias}")
class UrlNotFoundError(Exception):
def __init__(self, short_code: str):
super().__init__(f"URL not found for code: {short_code}")
class UrlExpiredError(Exception):
def __init__(self, short_code: str):
super().__init__(f"URL has expired: {short_code}")
#include <stdexcept>
class AliasAlreadyExistsException : public std::runtime_error {
public:
AliasAlreadyExistsException(const std::string& alias)
: std::runtime_error("Alias already taken: " + alias) {}
};
class UrlNotFoundException : public std::runtime_error {
public:
UrlNotFoundException(const std::string& shortCode)
: std::runtime_error("URL not found for code: " + shortCode) {}
};
class UrlExpiredException : public std::runtime_error {
public:
UrlExpiredException(const std::string& shortCode)
: std::runtime_error("URL has expired: " + shortCode) {}
};
class AliasAlreadyExistsError extends Error {
constructor(alias) {
super(`Alias already taken: ${alias}`);
this.name = 'AliasAlreadyExistsError';
}
}
class UrlNotFoundError extends Error {
constructor(shortCode) {
super(`URL not found for code: ${shortCode}`);
this.name = 'UrlNotFoundError';
}
}
class UrlExpiredError extends Error {
constructor(shortCode) {
super(`URL has expired: ${shortCode}`);
this.name = 'UrlExpiredError';
}
}
Encoding Strategy
The strategy interface and its implementations. Base62Strategy provides deterministic, compact encoding. RandomStrategy generates unpredictable codes using secure randomness.
๐ก Strategy pattern = define a family of algorithms, encapsulate each one, and make them interchangeable. The service calls strategy.encode(id) without knowing which algorithm is active. Adding a new encoding (e.g., HashStrategy) requires zero changes to UrlShortenerService.
import java.security.SecureRandom;
interface EncodingStrategy {
String encode(long id);
}
class Base62Strategy implements EncodingStrategy {
private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@Override
public String encode(long id) {
if (id == 0) return String.valueOf(ALPHABET.charAt(0));
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(ALPHABET.charAt((int)(id % 62)));
id /= 62;
}
return sb.reverse().toString();
}
}
class RandomStrategy implements EncodingStrategy {
private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private final int length;
private final SecureRandom random = new SecureRandom();
public RandomStrategy(int length) {
this.length = length;
}
@Override
public String encode(long id) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
}
return sb.toString();
}
}
from abc import ABC, abstractmethod
import secrets
import string
class EncodingStrategy(ABC):
@abstractmethod
def encode(self, id_val: int) -> str:
pass
class Base62Strategy(EncodingStrategy):
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def encode(self, id_val: int) -> str:
if id_val == 0:
return self.ALPHABET[0]
result = []
while id_val > 0:
result.append(self.ALPHABET[id_val % 62])
id_val //= 62
return "".join(reversed(result))
class RandomStrategy(EncodingStrategy):
def __init__(self, length: int = 8):
self._length = length
self._alphabet = string.ascii_letters + string.digits
def encode(self, id_val: int) -> str:
return "".join(secrets.choice(self._alphabet) for _ in range(self._length))
#include <string>
#include <random>
class EncodingStrategy {
public:
virtual ~EncodingStrategy() = default;
virtual std::string encode(long id) = 0;
};
class Base62Strategy : public EncodingStrategy {
static constexpr const char* ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public:
std::string encode(long id) override {
if (id == 0) return std::string(1, ALPHABET[0]);
std::string result;
while (id > 0) {
result = ALPHABET[id % 62] + result;
id /= 62;
}
return result;
}
};
class RandomStrategy : public EncodingStrategy {
int length_;
std::mt19937 rng_;
static constexpr const char* ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public:
RandomStrategy(int length) : length_(length), rng_(std::random_device{}()) {}
std::string encode(long id) override {
std::string result(length_, ' ');
std::uniform_int_distribution<int> dist(0, 61);
for (int i = 0; i < length_; ++i) {
result[i] = ALPHABET[dist(rng_)];
}
return result;
}
};
class Base62Strategy {
#alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
encode(id) {
if (id === 0) return this.#alphabet[0];
let result = '';
while (id > 0) {
result = this.#alphabet[id % 62] + result;
id = Math.floor(id / 62);
}
return result;
}
}
class RandomStrategy {
#length;
#alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
constructor(length = 8) { this.#length = length; }
encode(id) {
let result = '';
for (let i = 0; i < this.#length; i++) {
result += this.#alphabet[Math.floor(Math.random() * this.#alphabet.length)];
}
return result;
}
}
UrlRepository
The repository interface and its in-memory implementation. Abstracts all storage operations behind a clean interface so the service never knows whether itโs talking to a HashMap, Redis, or DynamoDB.
๐ก Repository pattern = swap storage without touching business logic. For production, implement UrlRepository backed by Redis (for low-latency lookups) or DynamoDB (for durability) โ the service code stays identical.
import java.util.concurrent.ConcurrentHashMap;
interface UrlRepository {
void save(UrlMapping mapping);
UrlMapping findByShortCode(String shortCode);
boolean existsByShortCode(String shortCode);
void delete(String shortCode);
}
class InMemoryUrlRepository implements UrlRepository {
private final ConcurrentHashMap<String, UrlMapping> store = new ConcurrentHashMap<>();
@Override
public void save(UrlMapping mapping) {
store.put(mapping.getShortCode(), mapping);
}
@Override
public UrlMapping findByShortCode(String shortCode) {
return store.get(shortCode);
}
@Override
public boolean existsByShortCode(String shortCode) {
return store.containsKey(shortCode);
}
@Override
public void delete(String shortCode) {
store.remove(shortCode);
}
}
class UrlRepository:
def __init__(self):
self._store: dict[str, UrlMapping] = {}
self._lock = threading.Lock()
def save(self, mapping: UrlMapping) -> None:
with self._lock:
self._store[mapping.short_code] = mapping
def find_by_short_code(self, short_code: str) -> Optional[UrlMapping]:
return self._store.get(short_code)
def exists_by_short_code(self, short_code: str) -> bool:
return short_code in self._store
def delete(self, short_code: str) -> None:
with self._lock:
self._store.pop(short_code, None)
#include <unordered_map>
#include <mutex>
#include <memory>
// In the C++ version, the repository is embedded in the service
// using an unordered_map with mutex protection.
// For a standalone repository interface:
class UrlRepositoryBase {
public:
virtual ~UrlRepositoryBase() = default;
virtual void save(std::unique_ptr<UrlMapping> mapping) = 0;
virtual UrlMapping* findByShortCode(const std::string& shortCode) = 0;
virtual bool existsByShortCode(const std::string& shortCode) = 0;
virtual void remove(const std::string& shortCode) = 0;
};
class UrlRepository {
#store = new Map();
save(mapping) { this.#store.set(mapping.shortCode, mapping); }
findByShortCode(code) { return this.#store.get(code) || null; }
existsByShortCode(code) { return this.#store.has(code); }
delete(code) { this.#store.delete(code); }
}
UrlShortenerService
The main service orchestrating URL shortening, resolution, deletion, and statistics. Uses an atomic counter for ID generation, delegates encoding to the strategy, and protects alias registration with a lock.
๐ก AtomicLong guarantees that no two threads ever get the same counter value โ even under heavy concurrency. Combined with Base62โs bijective mapping, this makes short code collisions impossible without any locking on the hot path.
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.time.LocalDateTime;
class UrlShortenerService {
private final AtomicLong counter;
private final UrlRepository repository;
private EncodingStrategy strategy;
private final ReentrantLock aliasLock = new ReentrantLock();
private final long defaultTtlHours;
public UrlShortenerService(UrlRepository repository, EncodingStrategy strategy,
long startCounter, long defaultTtlHours) {
this.repository = repository;
this.strategy = strategy;
this.counter = new AtomicLong(startCounter);
this.defaultTtlHours = defaultTtlHours;
}
public void setEncodingStrategy(EncodingStrategy strategy) {
this.strategy = strategy;
}
public String shorten(String longUrl) {
return shorten(longUrl, null, null);
}
public String shorten(String longUrl, String userId) {
return shorten(longUrl, userId, null);
}
public String shortenWithAlias(String longUrl, String customAlias) {
return shortenWithAlias(longUrl, customAlias, null);
}
public String shortenWithAlias(String longUrl, String customAlias, String userId) {
validateUrl(longUrl);
validateAlias(customAlias);
aliasLock.lock();
try {
if (repository.existsByShortCode(customAlias)) {
throw new AliasAlreadyExistsException(customAlias);
}
LocalDateTime expiresAt = LocalDateTime.now().plusHours(defaultTtlHours);
UrlMapping mapping = new UrlMapping(customAlias, longUrl, userId, expiresAt);
repository.save(mapping);
return customAlias;
} finally {
aliasLock.unlock();
}
}
public String resolve(String shortCode) {
UrlMapping mapping = repository.findByShortCode(shortCode);
if (mapping == null) {
throw new UrlNotFoundException(shortCode);
}
if (mapping.isExpired()) {
repository.delete(shortCode);
throw new UrlExpiredException(shortCode);
}
mapping.incrementClicks();
return mapping.getLongUrl();
}
public void delete(String shortCode) {
if (!repository.existsByShortCode(shortCode)) {
throw new UrlNotFoundException(shortCode);
}
repository.delete(shortCode);
}
public UrlStats getStats(String shortCode) {
UrlMapping mapping = repository.findByShortCode(shortCode);
if (mapping == null) {
throw new UrlNotFoundException(shortCode);
}
return new UrlStats(mapping);
}
private String shorten(String longUrl, String userId, LocalDateTime expiresAt) {
validateUrl(longUrl);
if (expiresAt == null) {
expiresAt = LocalDateTime.now().plusHours(defaultTtlHours);
}
long id = counter.incrementAndGet();
String shortCode = strategy.encode(id);
// Handle potential collision for random strategy
aliasLock.lock();
try {
while (repository.existsByShortCode(shortCode)) {
id = counter.incrementAndGet();
shortCode = strategy.encode(id);
}
UrlMapping mapping = new UrlMapping(shortCode, longUrl, userId, expiresAt);
repository.save(mapping);
} finally {
aliasLock.unlock();
}
return shortCode;
}
private void validateUrl(String url) {
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("URL cannot be empty");
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new IllegalArgumentException("URL must start with http:// or https://");
}
}
private void validateAlias(String alias) {
if (alias == null || alias.isBlank()) {
throw new IllegalArgumentException("Alias cannot be empty");
}
if (alias.length() < 3 || alias.length() > 20) {
throw new IllegalArgumentException("Alias must be 3-20 characters");
}
if (!alias.matches("[a-zA-Z0-9_-]+")) {
throw new IllegalArgumentException("Alias must be alphanumeric with hyphens and underscores only");
}
}
}
from datetime import timedelta
import re
class UrlShortenerService:
def __init__(self, repository: UrlRepository, strategy: EncodingStrategy,
start_counter: int = 10000, default_ttl_hours: int = 24):
self._repository = repository
self._strategy = strategy
self._counter = start_counter
self._counter_lock = threading.Lock()
self._alias_lock = threading.Lock()
self._default_ttl_hours = default_ttl_hours
def set_encoding_strategy(self, strategy: EncodingStrategy) -> None:
self._strategy = strategy
def shorten(self, long_url: str, user_id: Optional[str] = None) -> str:
self._validate_url(long_url)
expires_at = datetime.now() + timedelta(hours=self._default_ttl_hours)
with self._counter_lock:
self._counter += 1
current_id = self._counter
short_code = self._strategy.encode(current_id)
with self._alias_lock:
while self._repository.exists_by_short_code(short_code):
with self._counter_lock:
self._counter += 1
current_id = self._counter
short_code = self._strategy.encode(current_id)
mapping = UrlMapping(short_code, long_url, user_id, expires_at)
self._repository.save(mapping)
return short_code
def shorten_with_alias(self, long_url: str, custom_alias: str,
user_id: Optional[str] = None) -> str:
self._validate_url(long_url)
self._validate_alias(custom_alias)
with self._alias_lock:
if self._repository.exists_by_short_code(custom_alias):
raise AliasAlreadyExistsError(custom_alias)
expires_at = datetime.now() + timedelta(hours=self._default_ttl_hours)
mapping = UrlMapping(custom_alias, long_url, user_id, expires_at)
self._repository.save(mapping)
return custom_alias
def resolve(self, short_code: str) -> str:
mapping = self._repository.find_by_short_code(short_code)
if mapping is None:
raise UrlNotFoundError(short_code)
if mapping.is_expired():
self._repository.delete(short_code)
raise UrlExpiredError(short_code)
mapping.increment_clicks()
return mapping.long_url
def delete(self, short_code: str) -> None:
if not self._repository.exists_by_short_code(short_code):
raise UrlNotFoundError(short_code)
self._repository.delete(short_code)
def get_stats(self, short_code: str) -> UrlStats:
mapping = self._repository.find_by_short_code(short_code)
if mapping is None:
raise UrlNotFoundError(short_code)
return UrlStats(
short_code=mapping.short_code,
long_url=mapping.long_url,
click_count=mapping.click_count,
created_at=mapping.created_at,
expires_at=mapping.expires_at,
expired=mapping.is_expired()
)
def _validate_url(self, url: str) -> None:
if not url or not url.strip():
raise ValueError("URL cannot be empty")
if not url.startswith("http://") and not url.startswith("https://"):
raise ValueError("URL must start with http:// or https://")
def _validate_alias(self, alias: str) -> None:
if not alias or not alias.strip():
raise ValueError("Alias cannot be empty")
if len(alias) < 3 or len(alias) > 20:
raise ValueError("Alias must be 3-20 characters")
if not re.match(r'^[a-zA-Z0-9_-]+$', alias):
raise ValueError("Alias must be alphanumeric with hyphens and underscores only")
class UrlShortenerService {
private:
std::atomic<long> counter_;
std::unordered_map<std::string, std::unique_ptr<UrlMapping>> store_;
EncodingStrategy* strategy_;
std::mutex mtx_;
int defaultTtlHours_;
void validateUrl(const std::string& url) {
if (url.empty()) throw std::invalid_argument("URL cannot be empty");
if (url.substr(0, 7) != "http://" && url.substr(0, 8) != "https://")
throw std::invalid_argument("URL must start with http:// or https://");
}
void validateAlias(const std::string& alias) {
if (alias.empty()) throw std::invalid_argument("Alias cannot be empty");
if (alias.length() < 3 || alias.length() > 20)
throw std::invalid_argument("Alias must be 3-20 characters");
std::regex pattern("^[a-zA-Z0-9_-]+$");
if (!std::regex_match(alias, pattern))
throw std::invalid_argument("Alias must be alphanumeric with hyphens and underscores");
}
public:
UrlShortenerService(EncodingStrategy* strategy, long startCounter = 10000,
int defaultTtlHours = 24)
: counter_(startCounter), strategy_(strategy), defaultTtlHours_(defaultTtlHours) {}
void setEncodingStrategy(EncodingStrategy* s) { strategy_ = s; }
std::string shorten(const std::string& longUrl) {
validateUrl(longUrl);
auto expiresAt = Clock::now() + std::chrono::hours(defaultTtlHours_);
long id = counter_.fetch_add(1) + 1;
std::string shortCode = strategy_->encode(id);
std::lock_guard<std::mutex> guard(mtx_);
while (store_.count(shortCode)) {
id = counter_.fetch_add(1) + 1;
shortCode = strategy_->encode(id);
}
store_[shortCode] = std::make_unique<UrlMapping>(shortCode, longUrl, "", expiresAt);
return shortCode;
}
std::string shortenWithAlias(const std::string& longUrl, const std::string& alias) {
validateUrl(longUrl);
validateAlias(alias);
std::lock_guard<std::mutex> guard(mtx_);
if (store_.count(alias)) throw AliasAlreadyExistsException(alias);
auto expiresAt = Clock::now() + std::chrono::hours(defaultTtlHours_);
store_[alias] = std::make_unique<UrlMapping>(alias, longUrl, "", expiresAt);
return alias;
}
std::string resolve(const std::string& shortCode) {
std::lock_guard<std::mutex> guard(mtx_);
auto it = store_.find(shortCode);
if (it == store_.end()) throw UrlNotFoundException(shortCode);
if (it->second->isExpired()) {
store_.erase(it);
throw UrlExpiredException(shortCode);
}
it->second->incrementClicks();
return it->second->getLongUrl();
}
void deleteUrl(const std::string& shortCode) {
std::lock_guard<std::mutex> guard(mtx_);
if (!store_.count(shortCode)) throw UrlNotFoundException(shortCode);
store_.erase(shortCode);
}
long getClickCount(const std::string& shortCode) {
std::lock_guard<std::mutex> guard(mtx_);
auto it = store_.find(shortCode);
if (it == store_.end()) throw UrlNotFoundException(shortCode);
return it->second->getClickCount();
}
};
// JS is single-threaded; operations are atomic within a single event loop tick.
// For async patterns, use sequential await or a mutex library.
class UrlShortenerService {
#counter;
#repository;
#strategy;
#defaultTtlMs;
constructor(repository, strategy, startCounter = 10000, defaultTtlHours = 24) {
this.#repository = repository;
this.#strategy = strategy;
this.#counter = startCounter;
this.#defaultTtlMs = defaultTtlHours * 60 * 60 * 1000;
}
setEncodingStrategy(strategy) { this.#strategy = strategy; }
shorten(longUrl, userId = null) {
this.#validateUrl(longUrl);
const expiresAt = new Date(Date.now() + this.#defaultTtlMs);
this.#counter++;
let shortCode = this.#strategy.encode(this.#counter);
// Handle collision
while (this.#repository.existsByShortCode(shortCode)) {
this.#counter++;
shortCode = this.#strategy.encode(this.#counter);
}
const mapping = new UrlMapping(shortCode, longUrl, userId, expiresAt);
this.#repository.save(mapping);
return shortCode;
}
shortenWithAlias(longUrl, customAlias, userId = null) {
this.#validateUrl(longUrl);
this.#validateAlias(customAlias);
if (this.#repository.existsByShortCode(customAlias)) {
throw new AliasAlreadyExistsError(customAlias);
}
const expiresAt = new Date(Date.now() + this.#defaultTtlMs);
const mapping = new UrlMapping(customAlias, longUrl, userId, expiresAt);
this.#repository.save(mapping);
return customAlias;
}
resolve(shortCode) {
const mapping = this.#repository.findByShortCode(shortCode);
if (!mapping) throw new UrlNotFoundError(shortCode);
if (mapping.isExpired()) {
this.#repository.delete(shortCode);
throw new UrlExpiredError(shortCode);
}
mapping.incrementClicks();
return mapping.longUrl;
}
delete(shortCode) {
if (!this.#repository.existsByShortCode(shortCode)) {
throw new UrlNotFoundError(shortCode);
}
this.#repository.delete(shortCode);
}
getStats(shortCode) {
const mapping = this.#repository.findByShortCode(shortCode);
if (!mapping) throw new UrlNotFoundError(shortCode);
return {
shortCode: mapping.shortCode,
longUrl: mapping.longUrl,
clickCount: mapping.clickCount,
createdAt: mapping.createdAt,
expiresAt: mapping.expiresAt,
expired: mapping.isExpired(),
toString() {
return `UrlStats{code=${this.shortCode} url=${this.longUrl} ` +
`clicks=${this.clickCount} expired=${this.expired}}`;
}
};
}
#validateUrl(url) {
if (!url || !url.trim()) throw new Error('URL cannot be empty');
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('URL must start with http:// or https://');
}
}
#validateAlias(alias) {
if (!alias || !alias.trim()) throw new Error('Alias cannot be empty');
if (alias.length < 3 || alias.length > 20)
throw new Error('Alias must be 3-20 characters');
if (!/^[a-zA-Z0-9_-]+$/.test(alias))
throw new Error('Alias must be alphanumeric with hyphens and underscores only');
}
}
Main / Demo
End-to-end demonstration exercising all features: basic shortening, resolution, click tracking, custom aliases, duplicate detection, deletion, strategy switching, and concurrent access.
import java.util.*;
import java.util.concurrent.*;
public class URLShortenerDemo {
public static void main(String[] args) {
UrlRepository repository = new InMemoryUrlRepository();
EncodingStrategy base62 = new Base62Strategy();
UrlShortenerService service = new UrlShortenerService(repository, base62, 10000, 24);
// Basic shortening
System.out.println("=== Shorten URLs ===");
String code1 = service.shorten("https://www.example.com/very/long/path/to/resource");
String code2 = service.shorten("https://docs.oracle.com/javase/tutorial");
String code3 = service.shorten("https://github.com/user/repo/pull/123");
System.out.println("Shortened: " + code1);
System.out.println("Shortened: " + code2);
System.out.println("Shortened: " + code3);
// Resolve
System.out.println("\n=== Resolve URLs ===");
System.out.println(code1 + " -> " + service.resolve(code1));
System.out.println(code2 + " -> " + service.resolve(code2));
// Multiple resolves to increment clicks
service.resolve(code1);
service.resolve(code1);
// Stats
System.out.println("\n=== URL Stats ===");
System.out.println(service.getStats(code1));
// Custom alias
System.out.println("\n=== Custom Alias ===");
String alias = service.shortenWithAlias("https://myportfolio.com", "my-site");
System.out.println("Custom alias: " + alias);
System.out.println("Resolves to: " + service.resolve(alias));
// Duplicate alias
System.out.println("\n=== Duplicate Alias ===");
try {
service.shortenWithAlias("https://another.com", "my-site");
} catch (AliasAlreadyExistsException e) {
System.out.println("Caught: " + e.getMessage());
}
// Delete
System.out.println("\n=== Delete URL ===");
service.delete(code3);
try {
service.resolve(code3);
} catch (UrlNotFoundException e) {
System.out.println("Caught: " + e.getMessage());
}
// Not found
System.out.println("\n=== Not Found ===");
try {
service.resolve("nonexistent");
} catch (UrlNotFoundException e) {
System.out.println("Caught: " + e.getMessage());
}
// Switch to random strategy
System.out.println("\n=== Random Strategy ===");
service.setEncodingStrategy(new RandomStrategy(8));
String randomCode = service.shorten("https://random-strategy.example.com");
System.out.println("Random code: " + randomCode);
System.out.println("Resolves to: " + service.resolve(randomCode));
// Concurrent click counting
System.out.println("\n=== Concurrent Click Test ===");
String concurrentCode = service.shorten("https://popular-page.com");
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(executor.submit(() -> service.resolve(concurrentCode)));
}
for (Future<?> f : futures) {
try { f.get(); } catch (Exception ignored) {}
}
executor.shutdown();
System.out.println("After 1000 concurrent resolves: " + service.getStats(concurrentCode));
System.out.println("Expected click count: 1000");
}
}
def main():
repository = UrlRepository()
base62 = Base62Strategy()
service = UrlShortenerService(repository, base62, start_counter=10000, default_ttl_hours=24)
# Basic shortening
print("=== Shorten URLs ===")
code1 = service.shorten("https://www.example.com/very/long/path/to/resource")
code2 = service.shorten("https://docs.python.org/3/tutorial")
code3 = service.shorten("https://github.com/user/repo/pull/123")
print(f"Shortened: {code1}")
print(f"Shortened: {code2}")
print(f"Shortened: {code3}")
# Resolve
print("\n=== Resolve URLs ===")
print(f"{code1} -> {service.resolve(code1)}")
print(f"{code2} -> {service.resolve(code2)}")
# Multiple resolves
service.resolve(code1)
service.resolve(code1)
# Stats
print("\n=== URL Stats ===")
print(service.get_stats(code1))
# Custom alias
print("\n=== Custom Alias ===")
alias = service.shorten_with_alias("https://myportfolio.com", "my-site")
print(f"Custom alias: {alias}")
print(f"Resolves to: {service.resolve(alias)}")
# Duplicate alias
print("\n=== Duplicate Alias ===")
try:
service.shorten_with_alias("https://another.com", "my-site")
except AliasAlreadyExistsError as e:
print(f"Caught: {e}")
# Delete
print("\n=== Delete URL ===")
service.delete(code3)
try:
service.resolve(code3)
except UrlNotFoundError as e:
print(f"Caught: {e}")
# Not found
print("\n=== Not Found ===")
try:
service.resolve("nonexistent")
except UrlNotFoundError as e:
print(f"Caught: {e}")
# Switch strategy
print("\n=== Random Strategy ===")
service.set_encoding_strategy(RandomStrategy(8))
random_code = service.shorten("https://random-strategy.example.com")
print(f"Random code: {random_code}")
print(f"Resolves to: {service.resolve(random_code)}")
# Concurrent click counting
print("\n=== Concurrent Click Test ===")
service.set_encoding_strategy(base62)
concurrent_code = service.shorten("https://popular-page.com")
threads = []
for _ in range(1000):
t = threading.Thread(target=service.resolve, args=(concurrent_code,))
threads.append(t)
t.start()
for t in threads:
t.join()
stats = service.get_stats(concurrent_code)
print(f"After 1000 concurrent resolves: {stats}")
print(f"Expected click count: 1000")
if __name__ == "__main__":
main()
#include <iostream>
#include <thread>
#include <vector>
#include <regex>
int main() {
Base62Strategy base62;
UrlShortenerService service(&base62, 10000, 24);
std::cout << "=== Shorten URLs ===" << std::endl;
std::string code1 = service.shorten("https://www.example.com/very/long/path");
std::string code2 = service.shorten("https://docs.cppreference.com");
std::string code3 = service.shorten("https://github.com/user/repo");
std::cout << "Shortened: " << code1 << std::endl;
std::cout << "Shortened: " << code2 << std::endl;
std::cout << "Shortened: " << code3 << std::endl;
std::cout << "\n=== Resolve URLs ===" << std::endl;
std::cout << code1 << " -> " << service.resolve(code1) << std::endl;
std::cout << code2 << " -> " << service.resolve(code2) << std::endl;
service.resolve(code1);
service.resolve(code1);
std::cout << "\n=== Click Count ===" << std::endl;
std::cout << code1 << " clicks: " << service.getClickCount(code1) << std::endl;
std::cout << "\n=== Custom Alias ===" << std::endl;
std::string alias = service.shortenWithAlias("https://myportfolio.com", "my-site");
std::cout << "Custom: " << alias << " -> " << service.resolve(alias) << std::endl;
std::cout << "\n=== Duplicate Alias ===" << std::endl;
try {
service.shortenWithAlias("https://another.com", "my-site");
} catch (const AliasAlreadyExistsException& e) {
std::cout << "Caught: " << e.what() << std::endl;
}
std::cout << "\n=== Delete ===" << std::endl;
service.deleteUrl(code3);
try {
service.resolve(code3);
} catch (const UrlNotFoundException& e) {
std::cout << "Caught: " << e.what() << std::endl;
}
std::cout << "\n=== Random Strategy ===" << std::endl;
RandomStrategy randomStrategy(8);
service.setEncodingStrategy(&randomStrategy);
std::string rCode = service.shorten("https://random.example.com");
std::cout << "Random: " << rCode << " -> " << service.resolve(rCode) << std::endl;
// Concurrent test
std::cout << "\n=== Concurrent Click Test ===" << std::endl;
service.setEncodingStrategy(&base62);
std::string popCode = service.shorten("https://popular.com");
std::vector<std::thread> threads;
for (int i = 0; i < 1000; ++i) {
threads.emplace_back([&service, &popCode]() { service.resolve(popCode); });
}
for (auto& t : threads) t.join();
std::cout << "After 1000 concurrent resolves, clicks: "
<< service.getClickCount(popCode) << std::endl;
std::cout << "Expected: 1000" << std::endl;
return 0;
}
function main() {
const repository = new UrlRepository();
const base62 = new Base62Strategy();
const service = new UrlShortenerService(repository, base62, 10000, 24);
console.log('=== Shorten URLs ===');
const code1 = service.shorten('https://www.example.com/very/long/path/to/resource');
const code2 = service.shorten('https://developer.mozilla.org/en-US/docs');
const code3 = service.shorten('https://github.com/user/repo/pull/123');
console.log(`Shortened: ${code1}`);
console.log(`Shortened: ${code2}`);
console.log(`Shortened: ${code3}`);
console.log('\n=== Resolve URLs ===');
console.log(`${code1} -> ${service.resolve(code1)}`);
console.log(`${code2} -> ${service.resolve(code2)}`);
service.resolve(code1);
service.resolve(code1);
console.log('\n=== URL Stats ===');
console.log(service.getStats(code1).toString());
console.log('\n=== Custom Alias ===');
const alias = service.shortenWithAlias('https://myportfolio.com', 'my-site');
console.log(`Custom alias: ${alias}`);
console.log(`Resolves to: ${service.resolve(alias)}`);
console.log('\n=== Duplicate Alias ===');
try {
service.shortenWithAlias('https://another.com', 'my-site');
} catch (e) {
console.log(`Caught: ${e.message}`);
}
console.log('\n=== Delete URL ===');
service.delete(code3);
try {
service.resolve(code3);
} catch (e) {
console.log(`Caught: ${e.message}`);
}
console.log('\n=== Not Found ===');
try {
service.resolve('nonexistent');
} catch (e) {
console.log(`Caught: ${e.message}`);
}
console.log('\n=== Random Strategy ===');
service.setEncodingStrategy(new RandomStrategy(8));
const randomCode = service.shorten('https://random-strategy.example.com');
console.log(`Random code: ${randomCode}`);
console.log(`Resolves to: ${service.resolve(randomCode)}`);
console.log('\n=== Sequential Click Test (single-threaded JS) ===');
service.setEncodingStrategy(base62);
const popCode = service.shorten('https://popular-page.com');
for (let i = 0; i < 1000; i++) {
service.resolve(popCode);
}
console.log(`After 1000 resolves: ${service.getStats(popCode).toString()}`);
console.log('Expected click count: 1000');
}
main();
Follow-up Questions
-
How would you scale to billions of URLs? Shard by short code prefix (consistent hashing). Use a distributed counter (Snowflake ID or UUID-based) instead of a single atomic counter. Store mappings in a distributed KV store (DynamoDB, Cassandra) with the short code as partition key.
-
How would you handle hot URLs (caching)? Add a read-through cache (Redis) in front of the repository. Popular URLs stay cached with a TTL. Use write-through for new entries. For extremely hot URLs, replicate across multiple cache nodes.
-
How would you implement rate limiting? Token bucket or sliding window per user/IP. Store counters in Redis with TTL. Return HTTP 429 when limit exceeded. Different limits for authenticated vs anonymous users.
-
How would you build an analytics pipeline? Emit a ClickEvent on every resolve (async, non-blocking). Stream events to Kafka, process with Flink for real-time dashboards. Batch to S3 for historical analytics. Track referrer, geo, device, timestamp.
-
How would you shard the storage? Hash-based sharding on short code (first 2 chars determine shard). Use consistent hashing for elastic scaling. Keep a lookup service or encode shard info in the short code itself.
-
How would you handle custom alias conflicts at scale? Bloom filter for fast negative lookups (alias definitely not taken). On positive match, confirm with the actual store. For high-traffic registration, use a distributed lock or optimistic concurrency with retry.
-
How would you implement URL previews and safety checks? On shorten: async job to fetch the page, extract title/description/thumbnail, and run malware/phishing checks. Store preview metadata alongside the mapping. Flag suspicious URLs and show interstitial warnings on resolve.
Related Designs
- Inventory Management - Repository pattern and thread-safe operations
- Multilevel Cache - Caching patterns applicable to hot URL resolution
Related Concepts
Scale this design past a single process and these are the concepts it runs into:
- Unique ID Generation โ โ Snowflake, UUID v7 and ULID are how the single atomic counter survives multiple instances
- Caching โ โ resolution is read-heavy and a small set of hot codes dominates traffic
- Database Indexing โ โ short-code lookup is a primary-key hit and the alias conflict check is a unique index
- Database Sharding โ โ the mapping table outgrows one node, so the short code becomes the shard key
- Rate Limiting โ โ open shorteners get abused, so creation needs per-key limits
Discussion
Newest first