← All projects

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.

APIs & ServicesIntermediate6-8 hours#rest#caching#redis#rate-limiting#persistenceSpring BootJPARedisPostgreSQL

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

Entity 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

0 of 7 milestones

Done when

Stretch goals