86 lines
4.7 KiB
Markdown
86 lines
4.7 KiB
Markdown
# 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](https://ankurm.com/system-design-interview-url-shortener-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
|
|
|
|
```bash
|
|
# 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](LICENSE).
|