Code a short-link generator
Open in the interactive app: practice, quiz, mock interview →In one minute
- In a low-level design round, a URL shortener is not about sharding or caches. It is about one object, the link, and the rules around it: who may create it, which codes are legal, when it stops working, and what the redirect returns in each state. The distributed version lives in Design URL Shortener; this lesson is the in-process object model behind it.
- Keep code generation behind an interface. Counter-plus-base62, random draws and hashing are all valid; the service should not care which, and tests should be able to force a collision on demand.
- Treat the code namespace as permanent. A code that once pointed somewhere must never point somewhere else, even after the link is deleted, or old printed posters start sending people to strangers.
- Model link status explicitly (active, disabled, expired, deleted) and map each to a precise HTTP answer. "Not found" (404) and "gone" (410) mean different things to browsers, crawlers and support staff.
- The shape is duplicates and uniqueness under contention: two requests claiming the same alias, or the same user shortening the same URL twice. The same shape shows up in username registration, coupon codes and idempotency keys for payments.
Requirements
Functional:
- A signed-in user submits a long URL and gets back a short code. They may ask for a custom alias such as
spring-sale. - Anyone resolving a code is redirected to the long URL, or gets a clear error if the code is unknown, disabled or expired.
- A link can have an optional time to live. The owner can disable it; an operator can disable any link for abuse.
- Each link counts its successful redirects.
- Shortening the same URL twice by the same user without options returns the same code, so a retried request does not create a second link.
Non-functional:
- No two live links ever share a code, and a code is never reassigned.
- Resolution is a single hash lookup plus a status check. The service object itself adds microseconds, not milliseconds.
- Behaviour is deterministic under test: time and code generation are injected.
Out of scope: analytics dashboards, geo-routing, QR codes, link previews, and anything about replicas or caches.
Entities and relationships
- Link is the entity. It has a code, a target URL, an owner id, a kind (generated or alias), a creation time, an optional expiry time, a status and a click counter.
- ShortCode is a value: a string with validation rules attached. Generated codes are case-sensitive base62; aliases are lowercase letters, digits and hyphens.
- TargetUrl is also a value. It is parsed and validated once at the edge, so the service only ever holds a URL it has already accepted.
- CodeGenerator is an interface with one method,
next(), that proposes a candidate code. - LinkRepository stores links keyed by code, plus a secondary index from
(owner, url)to code for idempotent shortening. - Clock supplies the current time so expiry can be tested.
- ResolveResult is a small value: an HTTP status and, for success, a location.
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 ..|> LinkRepositoryKey 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 urlDesign patterns used and why
- Strategy for code generation. A counter encoded in base62 gives short, collision-free codes but reveals volume and ordering. Random 7-character base62 gives about 3.5 trillion codes and leaks nothing, but needs a collision check. A keyed permutation of the counter (the trick the paste-service lesson uses) gets both.
- Repository for storage. The service speaks in links and one conditional insert; whether that is a hash map, a Postgres table with a unique index or DynamoDB with a condition expression is the repository's business.
insertIfAbsentis the key method: it is the only place uniqueness is enforced, so it is the only thing that must be atomic. - Value objects for codes and URLs. Validation runs once, when the value is built: only
httpandhttpstargets (ajavascript:target would be cross-site scripting served from your domain), and aliases normalised to lowercase soSpring-Saleandspring-salecannot become two links. - State, kept light: an enum (
ACTIVE,DISABLED,DELETED) plus a derived "expired" check against the clock. Expiry is not a stored state, because then a background job would have to flip it on time, and correctness would depend on that job. - Facade:
LinkServiceis the only entry point for controllers. It hides the alias policy, the retry loop and the idempotency index. A caching repository can later wrap the real one as a Decorator, which is how the cache in the distributed design attaches without changing this model.
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
- Two users claim the same alias at once. If the service checks
exists(alias)and then inserts, both checks can pass. The insert itself must be conditional: a map write under a lock, or a unique index that makes the second insert fail. The loser gets "alias taken", never a silent overwrite. - Generated code collides. With random codes, the chance of a collision grows with the number of live links. At 1 billion links in a 62^7 space, roughly 1 in 3,500 draws collides. Retry a bounded number of times (five is plenty), then fail loudly. An unbounded loop turns a full namespace into a hung request.
- Idempotent shorten races. Two retries of the same request arrive together and both miss the
(owner, url)index. Do the index lookup and the insert under the same lock, or accept a rare duplicate and make the index the tie-breaker. - Reserved words. Aliases such as
api,admin,loginandstaticwould shadow your own routes. Keep a reserved list in the alias policy and reject them at creation. - Case. Generated base62 codes are case-sensitive; aliases are not. Resolution therefore tries the exact code first and then the lowercase form, accepting the second only if it belongs to an alias.
- Expiry boundary. Decide whether a link that expires at time T works at T. The practice code treats
now >= expiresAtas expired. Store UTC epoch seconds. - Deleted links. Keep a tombstone. If you delete the row, a later random draw can reissue the code, and a QR code printed last year now advertises someone else's site.
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
- Short links are printed, tweeted and embedded where you cannot update them, so a code must be permanent and never reused.
- Permanence plus many concurrent creators means uniqueness must be enforced at one atomic point, the conditional insert, not by a prior check.
- Generation schemes trade predictability for collision checks, and the business may change its mind, so generation sits behind a strategy.
- 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.
- 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
- Analytics per click (referrer, country): the click counter becomes an event emitted from
resolveto a buffer. The object model gains aClickSinkinterface; the service does not change shape. The distributed pipeline is covered in Design URL Shortener. - Password-protected or one-time links: add a
Guardstrategy evaluated inresolvebefore the redirect. One-time links become "disable after first successful resolve", done under the same lock as the counter. - Editable targets: some products let owners change where a code points. That conflicts with permanence, so make it explicit: keep a version history on the link and only allow edits by the owner, with an audit entry.
- Branded domains: codes become unique per domain, so the repository key becomes
(domain, code). The alias policy gets a per-domain reserved list.
Leadership lens
- Build first: the conditional insert and the status-to-HTTP mapping, with tests for alias races, collisions and expiry boundaries. Code generation can change later; a reused code cannot be taken back.
- Ask in review: where is uniqueness enforced; can a deleted code ever be reissued; what does resolve return for each status and why; which clock decides expiry; how are reserved aliases maintained.
- Choose 302 over 301 deliberately and write it down. A permanent redirect is cached by browsers, which removes your ability to count clicks or disable an abusive link.
- Ownership: the abuse team will want to disable links in seconds. Give them an operator path into the same
disablemethod rather than a direct database edit that bypasses the cache and the audit log.
Check yourself
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.
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.
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
expiresAtand the injected clock makes resolve correct even if no job runs, and makes it testable.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
- High-Level vs Low-Level Design with URL Shortener Explained (Lazy Programmer): contrasts the class-level view of a shortener with the architecture view.
- Design a URL Shortener Like Bitly (KodeKloud): builds a working shortener as a project, useful to see the moving parts in code.
- System Design: URL Shortener like TinyURL (System Design Fight Club): the distributed version, for when the interview moves past the object model.
Free resources
- RFC 9110, HTTP Semantics: the definitions of 301, 302, 307, 308, 404 and 410, which decide what browsers and crawlers do with your redirects.
- Redirections in HTTP (MDN): a practical guide to permanent versus temporary redirects and how browsers cache them.
- INSERT and ON CONFLICT (PostgreSQL docs): the conditional insert that makes code and alias uniqueness atomic in a database.
- Strategy pattern (Refactoring.Guru): the pattern behind swappable code generators.
- Value Object (Martin Fowler): why codes and target URLs are better as validated values than raw strings.
- URL shortening (Wikipedia): background on techniques, history and the abuse problems short links create.