Engineering Atlas › Low-Level Design

Code a short-link generator

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: analytics dashboards, geo-routing, QR codes, link previews, and anything about replicas or caches.

Entities and relationships

Relationships: the service composes a repository, a generator, a clock and an alias policy. A link references its owner by id, not a User object. The repository aggregates links; deleting one leaves a tombstone so the code stays reserved.

Class diagram

classDiagram
    class LinkService {
      -repo: LinkRepository
      -gen: CodeGenerator
      -clock: Clock
      -aliases: AliasPolicy
      +shorten(owner, url, alias, ttl) string
      +resolve(code) ResolveResult
      +disable(owner, code) void
      +clicks(code) int
    }
    class Link {
      +code: string
      +target: string
      +owner: string
      +kind: LinkKind
      +expiresAt: int
      +status: LinkStatus
      +clicks: int
      +isLive(now) bool
    }
    class CodeGenerator {
      <<interface>>
      +next() string
    }
    class LinkRepository {
      <<interface>>
      +get(code) Link
      +insertIfAbsent(link) bool
      +findGenerated(owner, url) Link
    }
    class AliasPolicy {
      +normalize(alias) string
      +check(alias) void
    }
    class Clock {
      <<interface>>
      +now() int
    }
    LinkService --> LinkRepository
    LinkService --> CodeGenerator
    LinkService --> Clock
    LinkService --> AliasPolicy
    LinkRepository "1" o-- "many" Link
    CounterBase62 ..|> CodeGenerator
    RandomBase62 ..|> CodeGenerator
    InMemoryRepo ..|> LinkRepository

Key flows

Shortening without an alias combines idempotency and collision handling; resolution carries most of the traffic.

sequenceDiagram
    participant C as Client
    participant S as LinkService
    participant R as LinkRepository
    participant G as CodeGenerator
    participant K as Clock
    C->>S: shorten(u42, url)
    S->>R: findGenerated(u42, url)
    R-->>S: none
    S->>G: next()
    G-->>S: "aZ3kQ9x"
    S->>K: now()
    S->>R: insertIfAbsent(link aZ3kQ9x)
    R-->>S: false (taken)
    S->>G: next()
    G-->>S: "b81LmQ2"
    S->>R: insertIfAbsent(link b81LmQ2)
    R-->>S: true
    S-->>C: "b81LmQ2"
    C->>S: resolve("b81LmQ2")
    S->>R: get("b81LmQ2")
    S->>K: now()
    S->>S: live? increment clicks
    S-->>C: 302 Location url

Design patterns used and why

What not to use: a Singleton service, or a Link class hierarchy (AliasLink, GeneratedLink) when a kind field carries the only difference.

Concurrency and edge cases

Down the stack

Layer 1The contract

shorten either returns a code that resolves to exactly the submitted URL, or fails with a specific reason (bad URL, bad alias, reserved alias, alias taken, no free code) and changes nothing. A code, once issued, is never bound to a different target. resolve returns 302 with the target for a live link and increments its counter by exactly one; 404 for a code that never existed; and 410 for one that is disabled, deleted or expired. Calling shorten twice with the same owner and URL and no options returns the same code while that link is live.

Layer 2The mechanism

In memory, the repository is a hash map from code to link plus a second map from owner + "\x00" + url to code. Both are updated under one mutex, so "check index, generate, insert" is atomic. Ten million links in a Go map cost on the order of a gigabyte once strings and map overhead are counted, which is why production keeps links in a database and caches only hot codes. In Postgres the same contract is INSERT INTO link (code, ...) VALUES (...) ON CONFLICT (code) DO NOTHING RETURNING code: a row coming back means you won; no row means retry with the next candidate. Aliases use a unique index on lower(code), which is how the case rule becomes a database guarantee instead of a hope.

Layer 3The machine

A hash lookup on a warm map is 20 to 100 ns, dominated by one or two cache misses (each about 80 ns to DRAM). Hashing a 7-byte key is a few nanoseconds. An uncontended Go mutex is around 20 ns. The redirect response is a few hundred bytes: status line, Location header and some cache headers, which fits in one TCP segment. The real latency is the network round trip (tens of milliseconds on mobile) and TLS setup on a cold connection. If the counter-based generator persists its next value, it must fsync it or lease blocks; an fsync on NVMe is 0.1 to 1 ms, which is why real systems lease a block of 1,000 codes per fsync instead of one.

Why it's built this way

  1. Short links are printed, tweeted and embedded where you cannot update them, so a code must be permanent and never reused.
  2. Permanence plus many concurrent creators means uniqueness must be enforced at one atomic point, the conditional insert, not by a prior check.
  3. Generation schemes trade predictability for collision checks, and the business may change its mind, so generation sits behind a strategy.
  4. Redirects vastly outnumber creations, so resolve must be one lookup and a branch, with the click count as a cheap increment that can later move to an asynchronous log.
  5. Expiry must be correct even if no background job runs, so it is computed from the clock at read time instead of stored as a state.

Extensibility

Leadership lens

Check yourself

  1. Why is checking exists(alias) before inserting not enough to guarantee unique aliases?

    Show answer

    Two requests can both pass the check before either inserts. Uniqueness must come from a single atomic step, a conditional insert under a lock or a unique index.

  2. Why keep a tombstone for a deleted link instead of removing it?

    Show answer

    Removing it frees the code, and a later generated or custom code could reuse it. Anyone still holding the old short link would then land on an unrelated page.

  3. Why compute "expired" from the clock at resolve time instead of storing it as a status?

    Show answer

    A stored status depends on a background job flipping it on time. Computing it from expiresAt and the injected clock makes resolve correct even if no job runs, and makes it testable.

  4. A user shortens the same URL twice, once with a 1-hour TTL and once without. Should both calls return the same code?

    Show answer

    No. The links have different lifetimes, so they are different links. Idempotency should only merge requests whose options are identical, which is why the practice limits it to generated links without a TTL.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →