Files

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).