All Posts

LLD for URL Shortener: Designing TinyURL

How do you turn a long URL into a 7-character string? We explore Base62 encoding, Hash collisions, and database choices.

Abstract AlgorithmsAbstract Algorithms
ยทยท21 min read

AI-assisted content.

TLDR

TLDR: A URL Shortener maps long URLs to short IDs. The core challenge is generating a globally unique, short, collision-free ID at scale. We use Base62 encoding on auto-incrementing database IDs for deterministic, collision-free short codes.


๐Ÿ“– The Locker Room Analogy

A locker room assigns locker numbers. The attendant doesn't randomly pick numbers โ€” she uses the next available sequential number. You get "Locker 5" โ†’ you can find it instantly.

URL shorteners work the same way: assign a unique sequential integer ID, then encode it in a compact string format.

  • Input: https://www.google.com/search?q=system+design+interview
  • Auto-increment database ID: 125
  • Base62 encode 125 โ†’ cb
  • Short URL: https://tiny.url/cb

๐Ÿ” Short Codes, Redirects, and TTL: The Core Vocabulary

Before building, align on the four concepts everything else depends on.

Short Code โ€” the compact identifier that replaces a long URL. cb, Xk3, and my-blog are all short codes. They appear after the domain: https://tiny.url/cb. A good short code is short enough to type from memory and opaque enough not to leak information about neighbouring URLs.

Redirect โ€” when a user visits a short URL, the server looks up the original destination and sends back an HTTP redirect response (301 or 302). The browser follows it silently. The user never stays at tiny.url; they land on the real page within milliseconds.

TTL (Time to Live) โ€” links can expire. An event short link may be valid for 24 hours, a marketing campaign for 30 days, or a personal link forever. The expires_at column in the database enforces this. Expired codes return a 410 Gone or a branded expiry page.

Click Analytics โ€” every redirect is a chance to record who clicked, from where, and when. This is why most commercial shorteners (Bitly, t.co) default to 302 Temporary redirects โ€” the browser re-asks the server on each visit, so every click is counted server-side.

ConceptWhat it meansWhy it matters
Short CodeCompact alias for a long URLHuman-friendly, shareable
RedirectHTTP 301/302 pointing to original URLThe core delivery mechanism
TTLExpiry time on the short linkCampaign control and security
Click AnalyticsPer-redirect tracking on the serverBusiness value of the service

๐Ÿ”ข Why Base62 and Not MD5?

Approach A โ€” Hashing (MD5/SHA):

  • Hash the long URL: MD5("https://...") = 3b94d2a8...
  • Take first 7 chars: 3b94d2a.
  • Problem: Two different URLs might produce the same first 7 chars โ†’ collision โ†’ wrong redirect.
  • Collision probability with 7 chars from MD5: ~1 in 1.4 billion โ€” sounds small, but with billions of URLs it happens.

Approach B โ€” Base62 on Auto-Increment ID (chosen):

  • Database auto-increments: ID = 1, 2, 3, โ€ฆ
  • Encode: toBase62(ID)
  • Guarantee: Every ID is unique โ†’ every short code is unique. Zero collision probability.

Base62 alphabet: a-z (26) + A-Z (26) + 0-9 (10) = 62 characters.

$$62^7 \approx 3.5 \text{ trillion combinations with 7 characters}$$

More than enough for any URL shortening service.

CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

def encode(n: int) -> str:
    if n == 0: return CHARS[0]
    result = []
    while n:
        result.append(CHARS[n % 62])
        n //= 62
    return "".join(reversed(result))

def decode(s: str) -> int:
    n = 0
    for c in s:
        n = n * 62 + CHARS.index(c)
    return n

โš™๏ธ OOP Contracts: Interfaces and Collaborations

The URL shortener's backbone is four focused Java interfaces. Each defines a contract โ€” an agreement about what a collaborator can do without revealing how it does it. UrlShortenerService holds a reference to each interface and receives them via constructor injection; it never references a concrete class directly.

interface UrlEncoder {
    String encode(long id);  // converts counter ID to Base62 short code
}

interface CodeStore {
    String save(String longUrl);            // stores mapping, returns short code
    Optional<String> resolve(String code);  // looks up long URL by short code
    boolean exists(String code);            // deduplication check
}

interface AnalyticsTracker {
    void recordClick(String code, String referrer, Instant timestamp);
}

interface AliasValidator {
    boolean isValid(String customAlias);    // checks format, uniqueness, reserved words
}

Who implements each interface, who calls it, and what it abstracts:

InterfaceImplemented byCalled byWhat it abstracts
UrlEncoderBase62EncoderUrlShortenerServiceThe 62-char alphabet and modular arithmetic
CodeStoreInMemoryStore, RedisStore, DistributedStoreUrlShortenerServicePersistence: hash map, Redis, or SQL
AnalyticsTrackerNoOpTracker, DatabaseTrackerUrlShortenerServiceClick recording โ€” optional and swappable
AliasValidatorCustomAliasValidatorUrlShortenerServiceFormat rules, reserved-word checks, uniqueness

Runtime collaboration โ€” how the components interact during a shorten() call:

flowchart LR
    Client -->|"shorten(longUrl)"| SVC[UrlShortenerService]
    SVC -->|"save(longUrl)"| Store[CodeStore (RedisStore / InMemoryStore)]
    Store -->|shortCode| SVC
    SVC -->|"encode(id)"| Encoder[UrlEncoder (Base62Encoder)]
    Encoder -->|"cb"| SVC
    SVC -->|shortCode| Client

Structural relationships โ€” ownership and implementation:

classDiagram
    class UrlEncoder {
        <>
        +encode(id long) String
    }
    class CodeStore {
        <>
        +save(longUrl String) String
        +resolve(code String) Optional~String~
        +exists(code String) boolean
    }
    class AnalyticsTracker {
        <>
        +recordClick(code String, referrer String, timestamp Instant) void
    }
    class Base62Encoder {
        -CHARS String
        +encode(id long) String
    }
    class CustomAliasEncoder {
        +encode(id long) String
    }
    class UrlShortenerService {
        -encoder UrlEncoder
        -store CodeStore
        -tracker AnalyticsTracker
        +shorten(longUrl String) String
        +resolve(shortCode String) String
    }
    Base62Encoder ..|> UrlEncoder : implements
    CustomAliasEncoder --|> Base62Encoder : extends
    UrlShortenerService o-- CodeStore : uses (injected)
    UrlShortenerService o-- UrlEncoder : uses (injected)
    UrlShortenerService o-- AnalyticsTracker : uses (injected)

This class diagram shows the structural ownership relationships of the URL shortener, with UrlShortenerService at the centre depending on three injected interfaces (UrlEncoder, CodeStore, AnalyticsTracker) and CustomAliasEncoder extending Base62Encoder through inheritance. The interface arrows (...|>) versus the composition arrows (o--) deliberately separate implementation details from runtime dependencies, making it clear that the service never references a concrete class directly. Take away: the three o-- arrows from UrlShortenerService are the injection points โ€” swapping InMemoryStore for RedisStore requires changing only one constructor argument, not a single line of service logic.


๐Ÿงฑ OOP Pillars Applied to the URL Shortener

Each classical OOP pillar maps directly to a concrete design decision in this system.

Encapsulation โ€” Base62Encoder owns its alphabet The 62-character alphabet (a-z A-Z 0-9) and the modular-arithmetic encoding loop are private implementation details inside Base62Encoder. Callers invoke encode(125) and receive "cb" โ€” they never see the CHARS constant or the while (id > 0) loop. Swapping the alphabet (say, to a URL-safe Base64 variant) requires editing exactly one class, nowhere else.

Abstraction โ€” CodeStore hides the backing technology UrlShortenerService calls store.save(longUrl) and store.resolve(code). It has no idea whether the store is a HashMap in unit tests, a Redis HSET in staging, or a PostgreSQL INSERT in production. That decision lives at the injection site and never leaks into the service logic.

Inheritance โ€” CustomAliasEncoder extends Base62Encoder When a user provides their own short code (e.g., my-blog), the system needs an encoder that accepts the user-supplied string directly instead of computing one. CustomAliasEncoder extends Base62Encoder and overrides encode() to return the validated alias โ€” reusing all of Base62Encoder's decoding support while replacing only the encoding direction. The same pattern applies to store variants: AtomicCounterStore implements CodeStore for single-node deployments; DistributedStore implements CodeStore for multi-node production with a Token Range Service.

Polymorphism โ€” UrlShortenerService holds a CodeStore reference The service is written once against the CodeStore interface. Inject InMemoryStore for tests, RedisStore for production, or a DualWriteStore during a live migration โ€” the same shorten() and resolve() methods run identically regardless of which implementation sits behind the reference.

PillarApplied viaConcrete location
Encapsulationprivate CHARS constant hidden from callersBase62Encoder
AbstractionCodeStore interface hides persistence technologyUrlShortenerService โ†’ CodeStore
InheritanceCustomAliasEncoder extends Base62EncoderCustom alias code path
PolymorphismCodeStore reference accepts any implementationUrlShortenerService constructor

โœ… SOLID Principles in the URL Shortener Design

SOLID is not an abstract checklist โ€” each principle appears as a specific, verifiable choice here.

Single Responsibility (SRP) โ€” Each class has exactly one reason to change. Base62Encoder changes only if the encoding algorithm changes. A CodeStore implementation changes only if the persistence technology changes. AnalyticsTracker changes only if the analytics schema or sink changes. None of these concerns bleed into UrlShortenerService.

Open/Closed (OCP) โ€” Adding Base58 encoding? Create Base58Encoder implements UrlEncoder. Adding NanoID? Same: NanoIdEncoder implements UrlEncoder. In both cases UrlShortenerService is untouched โ€” the interface is the extension point, and the service is closed to modification.

Liskov Substitution (LSP) โ€” Any CodeStore implementation can substitute another without changing the observable behaviour for UrlShortenerService. InMemoryStore.save("https://example.com") returns a short code. RedisStore.save("https://example.com") also returns a short code. The service cannot tell the difference โ€” and it should not need to.

Interface Segregation (ISP) โ€” Four small, focused interfaces instead of one large UrlShortenerPort. A single fat interface would force every implementor to provide all six methods even when only one is needed. The four-interface design lets NoOpTracker implement just recordClick() trivially, without carrying dead weight from encoding or storage concerns.

Dependency Inversion (DIP) โ€” UrlShortenerService receives abstractions via constructor injection:

public class UrlShortenerService {
    private final UrlEncoder encoder;
    private final CodeStore store;
    private final AnalyticsTracker tracker;

    public UrlShortenerService(UrlEncoder encoder, CodeStore store, AnalyticsTracker tracker) {
        this.encoder = encoder;
        this.store   = store;
        this.tracker = tracker;
    }
}

No new Base62Encoder(). No new RedisStore(). Concrete classes are wired by a Spring @Configuration class โ€” the service depends exclusively on interfaces.


๐Ÿ“ Interface Contracts: The Four Boundaries of the URL Shortener

Each interface is a stable boundary. Anything inside that boundary can change freely; anything outside can only call the methods listed here.

/**
 * Converts a numeric counter ID into a compact short-code string.
 * Implementations: Base62Encoder, CustomAliasEncoder, Base58Encoder
 */
interface UrlEncoder {
    String encode(long id);  // e.g. encode(125) โ†’ "cb"
}

/**
 * Persists and retrieves short-code โ†” long-URL mappings.
 * Implementations: InMemoryStore (test), RedisStore (staging), DistributedStore (prod)
 */
interface CodeStore {
    String save(String longUrl);            // persists mapping, returns generated short code
    Optional<String> resolve(String code);  // returns long URL for code, or empty if not found
    boolean exists(String code);            // returns true if the code is already in use
}

/**
 * Records analytics for every redirect event.
 * Implementations: NoOpTracker (default), DatabaseTracker (analytics-on)
 */
interface AnalyticsTracker {
    void recordClick(String code, String referrer, Instant timestamp);
}

/**
 * Validates user-supplied custom aliases before storage.
 * Implementations: CustomAliasValidator
 */
interface AliasValidator {
    boolean isValid(String customAlias);  // false if blank, reserved, non-alphanumeric, or taken
}

These four contracts are everything UrlShortenerService needs from the outside world. Any Spring @Bean that satisfies an interface can be wired in โ€” production configs, test configs, and migration configs all swap implementations without modifying the service itself.


๐Ÿ“Š Enhanced Class Diagram: All Interfaces and Implementations

classDiagram
    class UrlEncoder {
        <>
        +encode(id long) String
        +decode(code String) long
    }
    class CodeStore {
        <>
        +save(longUrl String) String
        +resolve(code String) Optional~String~
        +exists(code String) boolean
    }
    class AnalyticsTracker {
        <>
        +recordClick(code String, referrer String, ts Instant) void
    }
    class AliasValidator {
        <>
        +isValid(customAlias String) boolean
    }
    class Base62Encoder {
        -String CHARS
        +encode(id long) String
        +decode(code String) long
    }
    class CustomAliasEncoder {
        +encode(id long) String
    }
    class InMemoryStore {
        -Map~String,String~ store
        +save(longUrl String) String
        +resolve(code String) Optional~String~
        +exists(code String) boolean
    }
    class RedisStore {
        +save(longUrl String) String
        +resolve(code String) Optional~String~
        +exists(code String) boolean
    }
    class NoOpTracker {
        +recordClick(code, referrer, ts) void
    }
    class DatabaseTracker {
        +recordClick(code, referrer, ts) void
    }
    class CustomAliasValidator {
        +isValid(customAlias String) boolean
    }
    class UrlShortenerService {
        -UrlEncoder encoder
        -CodeStore store
        -AnalyticsTracker tracker
        -AliasValidator validator
        +shorten(longUrl String) String
        +resolve(shortCode String) String
    }
    Base62Encoder ..|> UrlEncoder : implements
    CustomAliasEncoder --|> Base62Encoder : extends
    InMemoryStore ..|> CodeStore : implements
    RedisStore ..|> CodeStore : implements
    NoOpTracker ..|> AnalyticsTracker : implements
    DatabaseTracker ..|> AnalyticsTracker : implements
    CustomAliasValidator ..|> AliasValidator : implements
    UrlShortenerService o-- UrlEncoder : uses (injected)
    UrlShortenerService o-- CodeStore : uses (injected)
    UrlShortenerService o-- AnalyticsTracker : uses (injected)
    UrlShortenerService o-- AliasValidator : uses (injected)

This enhanced class diagram expands the earlier view to include all four interface hierarchies โ€” UrlEncoder, CodeStore, AnalyticsTracker, and AliasValidator โ€” along with their concrete implementations, showing the complete set of injection points and their swap-in alternatives. The pattern of multiple implements arrows per interface (e.g., InMemoryStore and RedisStore both implementing CodeStore) illustrates that switching storage backends from in-memory testing to production Redis is a one-line constructor change. Take away: this diagram is the dependency map for the URL shortener โ€” every arrow from UrlShortenerService is a seam where a real implementation can be replaced with a stub in tests.


๐Ÿ“Š URL Shortening in Motion: The End-to-End Request Flow

There are two fundamentally different operations in a URL shortener: creating a short link and resolving one. They travel very different code paths.

Creation flow โ€” the write path. Happens once per link, so throughput requirements are moderate:

sequenceDiagram
    participant Client
    participant API
    participant DB
    Client->>API: POST /api/shorten {longUrl}
    API->>DB: INSERT long_url  returns auto-increment ID = 125
    DB-->>API: ID = 125
    API->>API: Base62(125) = "cb"
    API-->>Client: {shortUrl: "https://tiny.url/cb"}

Resolution flow โ€” the read path. Happens millions of times per link, so every millisecond matters. This is the Redis cache-first path wired via @Cacheable in the Spring Boot section below โ€” cache hits serve from Redis in under 1 ms; cold misses fall through to the database and write back to the cache automatically.

The asymmetry is intentional: writes happen once; reads happen constantly. A Redis cache on the resolution path keeps p99 redirect latency under 5 ms for hot links, while the database only sees cold-cache misses. Design your system around the read-heavy reality from the start.


๐Ÿ“Š URL Resolve (Redirect) Sequence

sequenceDiagram
    participant B as Browser
    participant API as UrlShortenerService
    participant CS as CodeStore
    participant AT as AnalyticsTracker
    B->>API: GET /cb (short code)
    API->>CS: resolve(cb)
    CS-->>API: Optional.of(https://example.com)
    API->>AT: recordClick("cb", referrer, now)
    AT-->>API: void
    API-->>B: 301 Redirect  https://example.com
    Note over B,API: Cache miss path  CS queries Redis then DB
    Note over B,API: Cache hit path  CS returns from Redis in under 1ms

This resolution sequence diagram traces the hot path of the URL shortener โ€” the redirect read that happens millions of times per day for popular short codes โ€” showing how the CodeStore abstraction enables a two-tier lookup (Redis cache โ†’ database) transparent to the caller. The notes at the bottom highlight the two execution paths: a cache hit returns in under 1ms from Redis, while a cache miss falls through to the database and writes back to the cache for subsequent requests. Take away: the CodeStore interface is what makes this caching strategy testable in isolation โ€” the service never knows whether it is talking to Redis or a hash map.


๐Ÿง  Deep Dive: Scaling the ID Generator

Auto-increment works with a single DB shard. At scale:

ApproachHowTradeoff
DB Auto-IncrementSequential IDs, single write masterBottleneck at DB master
Token Range ServiceEach app server claims a range (1โ€“1000, 1001โ€“2000, โ€ฆ)Low contention; predictable ranges
Twitter Snowflake41-bit timestamp + 10-bit machine ID + 12-bit sequence64-bit globally unique, time-sortable, no coordination needed
UUID128-bit random, no coordinationToo long for a short URL; not sortable

Token Range Service is the sweet spot for most URL shorteners:

  1. App server asks "Range Service" for the next batch of 1000 IDs.
  2. App server assigns IDs locally from its batch.
  3. Range Service only coordinates batch handoffs, not individual URLs.

โš–๏ธ Trade-offs & Failure Modes: Custom Aliases and Collision Handling

Users sometimes want custom codes: tiny.url/my-blog.

  1. Check if my-blog exists in the DB.
  2. If not: insert {short_code: "my-blog", long_url: "..."}.
  3. If yes: return "Code already taken" error.

Custom aliases bypass the Base62 auto-increment path and are stored directly.


๐Ÿ“Š Short URL Lifecycle States

stateDiagram-v2
    [*] --> ACTIVE : shorten(longUrl)  code generated and stored
    ACTIVE --> ACTIVE : resolve(code)  redirect served
    ACTIVE --> EXPIRED : TTL reached (if set)
    ACTIVE --> DELETED : owner deletes alias
    EXPIRED --> [*]
    DELETED --> [*]
    note right of ACTIVE : Every GET increments click count via AnalyticsTracker

This state diagram captures the full lifecycle of a short URL from creation through its possible end states, showing that ACTIVE is the only state that generates redirects and analytics events. The ACTIVE โ†’ EXPIRED and ACTIVE โ†’ DELETED transitions model two distinct termination reasons โ€” system-enforced TTL expiry and owner-initiated deletion โ€” both of which lead to terminal states with no path back. Take away: modeling short URL states explicitly prevents bugs like serving expired codes as valid redirects or counting clicks against deleted aliases.


๐ŸŒ Real-World Applications of URL Shorteners

URL shorteners are not just a convenience tool โ€” they underpin several critical web workflows across the industry.

Social media sharing: Twitter's t.co wraps every URL posted in a tweet. This lets Twitter track engagement, enforce character limits, and scan destination links for malware before users click. Every tweet ever published with a URL goes through this shortening layer.

QR code campaigns: A QR code printed on a poster or product package cannot be edited after printing. But if it encodes a short URL like tiny.url/event24, you can update the destination in the database without reprinting anything. This makes short URLs essential for physical-world marketing where the campaign destination may change after launch.

Email marketing: Campaign links typically carry UTM parameters โ€” ?utm_source=newsletter&utm_campaign=spring-launch&utm_medium=email โ€” which inflate URL length significantly and look ugly in email copy. Short URLs keep the email readable while preserving full analytics on the server side.

Internal developer tooling: Engineering teams build private shorteners (often called "go links") for long dashboard URLs, runbook links, JIRA queries, and Grafana panels. go/deploy-prod beats any bookmark. Google pioneered this internally and the pattern is widespread at large tech companies.

Common thread: all of these use cases decouple the publicly shared link from the destination, so destinations can be changed, tracked, A/B tested, and secured independently of the URL already in circulation.


๐Ÿงช Hands-On: Extend and Stress-Test Your URL Shortener

Put the concepts to work with these three targeted exercises.

Exercise 1 โ€” Implement and Verify Base62 Encode/Decode

Take the Python snippet from the Base62 section and test these boundary conditions:

  • encode(0) should return "a" (the first character in the alphabet).
  • encode(61) should return "9" (the last character).
  • encode(62) should return "ba" (first two-character code).
  • decode(encode(12345)) should round-trip back to 12345.

If any assertion fails, check whether your alphabet ordering matches a-z A-Z 0-9.

Exercise 2 โ€” Design the "Update Destination" Feature

A user wants to change where tiny.url/cb points without changing the short code. Write:

  1. The SQL UPDATE statement that changes the long_url.
  2. The Redis DEL cb command that invalidates the stale cache entry.

Why must the cache invalidation happen before or at the same time as the DB update? What failure scenario occurs if you invalidate cache after the DB update with a delay?

Exercise 3 โ€” 301 vs. 302 Trade-off Audit

You ran a campaign using HTTP 301 for redirect performance. Mid-campaign, the landing page URL changed. Document the exact failure: which users are affected, what they see, and why the server cannot help them. Then explain how switching to HTTP 302 would have prevented it โ€” and what the bandwidth cost of 302 is at 10 million redirects per day.


๐Ÿงญ Decision Guide: Choosing the Right URL Shortener Architecture

|---|---| | Collision-free short codes | Base62 on auto-increment ID โ€” zero collision by construction | | Analytics tracking needed | HTTP 302 (temporary redirect) โ€” browser never caches, every click hits your server | | No analytics needed | HTTP 301 (permanent redirect) โ€” browser caches, reduces server load | | Scale beyond single DB | Token Range Service (simplest) or Snowflake IDs (globally unique, time-sortable) | | Popular codes hit DB | Redis cache with 24-hour TTL on the read path | | Users need custom aliases | Separate code path bypassing auto-increment; check existence before insert |

Start with a single DB + auto-increment. Add Redis when cache miss rate rises above 5 %. Move to Token Range Service when the write master becomes the bottleneck. Add custom alias support only if users request it.


๐Ÿ› ๏ธ Spring Boot and Redis: Production URL Shortener with Cache-Through Redirect

Spring Boot wires the URL shortener service, REST controller, and database repository into a running application with embedded Tomcat and auto-configured Jackson serialization. Spring Data Redis (via RedisTemplate or @Cacheable) provides the read-through Redis cache layer that sits in front of the database โ€” ensuring redirects are served in under 1ms from cache on cache hits.

How they solve the problem in this post: The UrlShortenerService implements the Base62-encode-on-auto-increment-ID strategy from this post. Spring Data Redis caches the shortCode โ†’ longUrl mapping in Redis. On a redirect, the service checks Redis first (O(1), sub-millisecond); on a cache miss it queries the DB and writes back to Redis โ€” the classic cache-through pattern.

// โ”€โ”€โ”€ URL entity (JPA, stored in PostgreSQL / MySQL) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import jakarta.persistence.*;

@Entity
@Table(name = "urls")
public class UrlMapping {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;               // auto-increment โ€” we Base62-encode this

    @Column(nullable = false, length = 2048)
    private String longUrl;

    private java.time.Instant expiresAt;   // null = never expires

    // getters / setters (or Lombok @Data)
}

// โ”€โ”€โ”€ Base62 encoder: the core encoding function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public class Base62 {
    private static final String CHARS =
            "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

    public static String encode(long id) {
        if (id == 0) return "0";
        StringBuilder sb = new StringBuilder();
        while (id > 0) {
            sb.append(CHARS.charAt((int)(id % 62)));
            id /= 62;
        }
        return sb.reverse().toString();   // e.g. 125 โ†’ "cb"
    }
}

// โ”€โ”€โ”€ Service: Base62 ID generation + Redis cache-through โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import org.springframework.cache.annotation.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.*;

interface UrlMappingRepository extends JpaRepository<UrlMapping, Long> {
    java.util.Optional<UrlMapping> findByLongUrl(String longUrl);
}

@Service
public class UrlShortenerService {

    private final UrlMappingRepository repo;

    public UrlShortenerService(UrlMappingRepository repo) {
        this.repo = repo;
    }

    /** Shorten: persist long URL, return Base62-encoded ID as short code */
    public String shorten(String longUrl) {
        return repo.findByLongUrl(longUrl)
                   .map(existing -> Base62.encode(existing.getId()))
                   .orElseGet(() -> {
                       UrlMapping m = new UrlMapping();
                       m.setLongUrl(longUrl);
                       UrlMapping saved = repo.save(m);       // auto-increment ID assigned
                       return Base62.encode(saved.getId());   // e.g. 12345 โ†’ "dnh"
                   });
    }

    /** Resolve: decode short code โ†’ DB id โ†’ long URL (Redis caches this) */
    @Cacheable(value = "urls", key = "#shortCode")  // cache key: "urls::dnh"
    public String resolve(String shortCode) {
        long id = decodeBase62(shortCode);
        return repo.findById(id)
                   .map(UrlMapping::getLongUrl)
                   .orElseThrow(() -> new java.util.NoSuchElementException("Not found: " + shortCode));
    }

    private long decodeBase62(String code) {
        long id = 0;
        for (char c : code.toCharArray()) {
            id = id * 62 + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".indexOf(c);
        }
        return id;
    }
}

// โ”€โ”€โ”€ REST controller โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@RestController
public class UrlShortenerController {

    private final UrlShortenerService svc;

    public UrlShortenerController(UrlShortenerService svc) { this.svc = svc; }

    /** POST /shorten  body: { "url": "https://..." } */
    @PostMapping("/shorten")
    public ResponseEntity<Map<String, String>> shorten(@RequestBody Map<String, String> body) {
        String shortCode = svc.shorten(body.get("url"));
        return ResponseEntity.ok(Map.of(
                "shortCode", shortCode,
                "shortUrl",  "https://tiny.url/" + shortCode));
    }

    /** GET /{shortCode}  โ†’ 302 redirect to long URL */
    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        String longUrl = svc.resolve(shortCode);  // Redis hit: <1ms; DB miss: ~5ms
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(java.net.URI.create(longUrl)).build();
    }
}

Configure Redis caching in application.yml:

spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    redis:
      time-to-live: 86400000   # 24h TTL โ€” expired short codes auto-evict from cache

Cache-hit redirects serve from Redis in under 1ms. Cache-miss redirects hit PostgreSQL (~5ms) and write back to Redis automatically via @Cacheable. The time-to-live: 24h ensures Redis memory is bounded โ€” only hot short codes stay in cache.

For a full deep-dive on Spring Boot cache-through patterns and Redis cluster configuration, a dedicated follow-up post is planned.


๐Ÿ“š Key Lessons from Building a URL Shortener

  • Encoding beats hashing for uniqueness guarantees. Base62 on a sequential ID is collision-free by construction โ€” every ID is unique, so every short code is unique. MD5 prefix truncation is probabilistic; at billions of URLs it collides.
  • Redirect semantics affect analytics permanently. Once a 301 is cached by a browser, there is no server-side way to correct it. Decide your redirect strategy at schema design time, not after launch.
  • The read path dominates. A URL shortener is a read-heavy system by a wide margin โ€” thousands of redirects for every single create. Cache aggressively on the read path; don't over-engineer the write path.
  • ID generation is the scalability bottleneck at high write volume. Move from single-DB auto-increment to a Token Range Service or Snowflake IDs before write contention becomes a problem.
  • Custom aliases need their own code path. Keep them isolated from the auto-increment sequence to avoid polluting the ID space and making short codes predictable from the alias.

๐Ÿ“Œ TLDR: Summary & Key Takeaways

  • Base62 on auto-increment ID is collision-free and generates compact 7-char codes for up to 3.5 trillion URLs.
  • 301 saves bandwidth but loses analytics; 302 preserves click tracking โ€” use 302 for production analytics.
  • Redis cache on the read path eliminates DB hits for popular short codes.
  • Token Range Service or Snowflake IDs scale the ID generator beyond a single DB.

Explore more LLD and system design patterns that complement the URL shortener:


Share

Test Your Knowledge

๐Ÿง 

Ready to test what you just learned?

AI will generate 4 questions based on this article's content.

Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms