4.7 KiB
system-design-url-shortener
A URL shortener built the way a system-design interview asks you to design one, then actually run: base62 ids, a block-allocating id generator, scrambled 7-character codes, Redis cache-aside with negative caching, click counters, and a Redis token-bucket rate limiter written as one atomic Lua script.
Companion to System Design Interview for Java Developers: Building a URL Shortener with Spring Boot.
Versions
| Component | Version | Verified against |
|---|---|---|
| Spring Boot | 4.1.1 | spring-boot-starter-parent metadata on Maven Central; Starting ... Spring Boot (v4.1.1) in the test log |
| Spring Framework | 7.0.9 | spring-beans-7.0.9.jar in the packaged app |
| Tomcat | 11.0.24 | startup log |
| JDK | 25.0.4.1+1 (Temurin LTS); source level 21 | java -version |
| PostgreSQL | 16.15 | select version() (H2 in PostgreSQL mode for the automated tests) |
| Redis | 7.0.15 | redis-server --version |
Run it
# PostgreSQL on :5433 with an empty database `urlshort`, Redis on :6379
./scripts/run.sh # builds and starts the app on :8080
./scripts/demo.sh # curl transcripts 01-06 into output/
./scripts/id-generation-demo.sh # transcripts 07-08 (starts its own instances on :8082 and :8083)
./scripts/test-report.sh # 24 tests, one line each, into output/09-test-results.txt
mvn test needs neither PostgreSQL nor Redis: the inmemory profile swaps in H2, an in-memory cache and an
in-memory token bucket. Time is injected (java.time.Clock), so TTL and refill tests never sleep.
Run with -Duser.timezone=UTC (the scripts do). The PostgreSQL JDBC driver sends the JVM's time zone to the
server, and a legacy zone name such as Asia/Calcutta can be rejected with invalid value for parameter "TimeZone".
Endpoints
| Method and path | What it does | Success | Failures |
|---|---|---|---|
POST /api/v1/urls |
Create a link. Body: {"url": "...", "alias": "optional", "ttlSeconds": optional} |
201 + Location |
400 invalid URL or alias, 409 alias taken, 429 rate limited |
GET /{code} |
Redirect | 302 + Location |
404 unknown, 410 expired |
GET /api/v1/urls/{code}/stats |
Target, click count, dates | 200 | 404 |
GET /api/v1/debug/cache-stats |
Diagnostic, delete before shipping. Cache hits and misses | 200 |
Profiles and settings
| Setting | Default | Effect |
|---|---|---|
profile snowflake |
off | Snowflake ids instead of database-allocated blocks; codes become 9-11 characters |
profile inmemory |
off (tests) | H2 + in-memory cache and rate limiter, no Redis or PostgreSQL |
app.rate-limit.capacity / refill-per-second |
5 / 0.5 | Token bucket per client address, on POST /api/v1/urls only |
app.cache-ttl / app.negative-cache-ttl |
1h / 60s | Cache lifetime for hits / for "no such code" |
app.node-id |
1 | Snowflake node (0-1023); must differ per instance |
Layout
src/main/java/com/ankurm/urlshortener/
api/ UrlController, request/response records, ApiExceptionHandler (RFC 9457 problem details)
core/ UrlService, UrlRepository (JdbcClient), Base62, CodeScrambler (40-bit Feistel),
IdGenerator, BlockIdGenerator, SnowflakeIdGenerator, ShortenerProperties
cache/ UrlCache, RedisUrlCache, InMemoryUrlCache
ratelimit/ RateLimiter, RedisTokenBucketRateLimiter (+ token_bucket.lua), InMemory..., RateLimitFilter
src/main/resources/ application.yml, schema.sql, token_bucket.lua
src/test/java/... Base62Test, CodeScramblerTest, SnowflakeIdGeneratorTest, SnowflakeProfileTest,
UrlShortenerApiTest, RateLimitTest
Captured output (output/)
| File | What it shows |
|---|---|
01-create-and-redirect.txt |
201 with Location, then a 302 to the long URL |
02-cache-aside-and-clicks.txt |
First redirect misses and fills Redis (TTL 3600), the next nine hit; click counter in Redis |
03-negative-cache.txt |
Unknown code: first lookup misses, second is answered from the - marker with TTL 60 |
04-alias-and-validation.txt |
Alias conflict 409, ftp: rejected, reserved alias rejected |
05-rate-limit.txt |
Burst of 5, then 429 with Retry-After, refill after 2.2 s, redirects never limited, the bucket as stored in Redis |
06-expiry.txt |
A 2-second link: 302, then 410; the Redis TTL is capped at the link's remaining life |
07-id-generation.txt |
Ids and codes; a restart skips the rest of a block; two instances never overlap |
08-snowflake.txt |
The snowflake profile: 10-character codes from 57-bit ids |
09-test-results.txt |
All 24 tests, generated from the Surefire XML report |
License
MIT, see LICENSE.