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