URL Shortener API
Build a REST service that turns long URLs into short base62 codes, with PostgreSQL storage, a Redis read cache and per-client rate limiting.
Revise first: REST API Design, Validation & ControllerAdviceHibernate & JPA InternalsRedis & Caching StrategiesLow-Level Design
You will build a service with two endpoints: POST /api/urls creates a short code for a long URL, and GET /{code} redirects to the original. It is a read-heavy system (often 100:1), so the design centres on a cache-aside Redis layer in front of PostgreSQL. Interviewers like this project because it touches ID generation, caching, validation, HTTP semantics (301 vs 302) and abuse protection in a small surface area.
Project layout
src/main/java/com/example/shortener/
api/ UrlController, CreateUrlRequest, UrlResponse, ApiExceptionHandler
domain/ ShortUrl (entity), ShortUrlRepository
service/ ShortenerService, Base62
ratelimit/ RateLimitFilter
src/main/resources/
application.yml
db/migration/V1__create_short_url.sqlEntity and schema with base62 ids
Generate the code from the database sequence value, not from a random string: it is collision-free and needs no retry loop. Base62 over [0-9a-zA-Z] gives 62^7 (about 3.5 trillion) codes in 7 characters.
public final class Base62 {
private static final String ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String encode(long n) {
if (n == 0) return "0";
StringBuilder sb = new StringBuilder();
for (; n > 0; n /= 62) sb.append(ALPHABET.charAt((int) (n % 62)));
return sb.reverse().toString();
}
public static long decode(String s) {
long n = 0;
for (char c : s.toCharArray()) n = n * 62 + ALPHABET.indexOf(c);
return n;
}
}Sequential ids are guessable. If enumeration matters, add a fixed offset or XOR the id with a secret before encoding; do not rely on the code being secret.
CREATE TABLE short_url (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE,
long_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);code is nullable because it is computed after the insert assigns id. The unique constraint creates the index that serves redirects.
@Entity
public class ShortUrl {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 10)
private String code;
@Column(nullable = false, columnDefinition = "text")
private String longUrl;
private Instant createdAt = Instant.now();
private Instant expiresAt;
// getters and setters omitted
}Create and resolve with a Redis cache
Validate input at the edge, then save once to get the id, then set the code. Both statements run in one transaction.
public record CreateUrlRequest(@NotBlank @Size(max = 2048) @URL String longUrl) {}
@Transactional
public String create(String longUrl) {
ShortUrl entity = new ShortUrl();
entity.setLongUrl(longUrl);
repo.save(entity); // IDENTITY: INSERT runs now, id assigned
entity.setCode(Base62.encode(entity.getId())); // dirty checking issues the UPDATE
return entity.getCode();
}Resolve uses cache-aside. Cache misses for unknown codes too (a short-lived negative entry) so scanners cannot hammer the database.
public Optional<String> resolve(String code) {
String key = "url:" + code;
String cached = redis.opsForValue().get(key);
if (cached != null) return NOT_FOUND.equals(cached) ? Optional.empty() : Optional.of(cached);
Optional<String> found = repo.findByCode(code).map(ShortUrl::getLongUrl);
redis.opsForValue().set(key, found.orElse(NOT_FOUND),
found.isPresent() ? Duration.ofHours(24) : Duration.ofMinutes(1));
return found;
}The controller returns 302 Found with a Location header. Use 302 if you need click analytics or the ability to change targets; 301 is cached by browsers permanently, so later redirects never reach your server.
@GetMapping("/{code}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
return service.resolve(code)
.map(u -> ResponseEntity.status(HttpStatus.FOUND).location(URI.create(u)).<Void>build())
.orElse(ResponseEntity.notFound().build());
}Rate limiting
Keep a fixed-window counter per client IP in Redis. It is crude but enough here; the Rate Limiter project builds the precise versions.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String key = "rl:" + req.getRemoteAddr() + ":" + (System.currentTimeMillis() / 60_000);
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(60));
if (count != null && count > 30 && "POST".equals(req.getMethod())) {
res.setStatus(429);
res.setHeader("Retry-After", "60");
return;
}
chain.doFilter(req, res);
}Behind a load balancer, getRemoteAddr() is the proxy. Set server.forward-headers-strategy=framework and only trust X-Forwarded-For from your own proxy.
Testing
Use Testcontainers so the test hits real PostgreSQL and Redis.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class ShortenerIT {
@Container @ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
@Container @ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7").withExposedPorts(6379);
@Autowired TestRestTemplate http;
@Test
void createsAndRedirects() {
var res = http.postForEntity("/api/urls", Map.of("longUrl", "https://example.com/a"), Map.class);
String code = (String) res.getBody().get("code");
var redirect = http.getForEntity("/" + code, Void.class);
assertThat(redirect.getStatusCode()).isEqualTo(HttpStatus.FOUND);
assertThat(redirect.getHeaders().getLocation()).hasToString("https://example.com/a");
}
}Note that TestRestTemplate does not follow redirects by default with the JDK client in recent versions; assert on the 302 as above.
Interview angle
- Why base62 of a sequence rather than a hash? A hash truncated to 7 characters collides (birthday bound near 2^21 for 42 bits) and needs a retry loop; a sequence is unique by construction. The cost is predictability and a single-writer sequence, solved later with range allocation per node.
- How do you scale reads? Cache-aside in Redis with TTL, negative caching, and read replicas for misses. A hot code is one key, so also consider a local Caffeine tier.
- 301 or 302? 301 is cacheable forever by clients, cutting load but losing analytics and edits; 302 keeps every hit visible to you.
- What happens when the cache and DB disagree? On delete or edit, evict the key after commit. A stale entry lives at most the TTL, which bounds the inconsistency.
Milestones
Done when
- A long URL posted to the API returns a code of at most 7 characters for the first million rows.
- Redirects for cached codes never touch PostgreSQL, verified by SQL logging.
- Invalid, oversized and non-http(s) URLs are rejected with 400.
- Unknown codes return 404 and are negatively cached for a short time.
- Exceeding the POST limit returns 429 with a
Retry-Afterheader. - The integration test passes against real PostgreSQL and Redis containers.
Stretch goals
- Support custom aliases with a uniqueness check that returns 409 on conflict.
- Add expiry using
expires_atplus a scheduled cleanup job and matching cache TTL. - Record click counts asynchronously via a Redis counter flushed in batches.
- Replace the single sequence with range allocation so multiple instances generate ids without contention.