Build a URL Shortener API
Build a Spring Boot REST API with PostgreSQL, Redis caching, and rate limiting.
Introduction
30 Second Summary
Every time you click a shortened link on social media or in a text message, a backend system is translating that short code into the real destination URL. These systems handle millions of lookups per second, using caching and rate limiting to stay fast and protected.
In this project, you will build a URL shortener REST API with Spring Boot that stores URLs in PostgreSQL, caches lookups in Redis, and rate-limits requests to prevent abuse.
What You'll Build
A production-style URL shortener API that accepts long URLs, generates short codes, redirects visitors to the original destination, and tracks click statistics.
By the end of this project, you'll have:
- A Spring Boot REST API with three endpoints for shortening URLs, redirecting visitors, and viewing click stats.
- A PostgreSQL database with indexed schema and a Redis caching layer for fast lookups with configurable TTL.
- A custom rate limiter using Redis as a fixed-window counter, plus a Docker Compose file running both databases locally.
- Secret Mission: Add a click analytics dashboard endpoint that returns the top 10 most-clicked URLs.
Are there any prerequisites?
This project assumes basic Java knowledge and familiarity with HTTP/REST concepts. You should already have Java 17+, Docker, Maven, and VS Code installed on your machine.
Set Up the Project
Before you can build a URL shortener, you need a working development environment with the right dependencies wired together.
In this step, you'll generate a Spring Boot project with all the starters you need, spin up PostgreSQL and Redis using Docker, and verify everything connects.
In this step, get ready to:
- Generate a Spring Boot project with the required dependencies.
- Run PostgreSQL and Redis containers with Docker Compose.
- Configure and verify the application starts successfully.
Generate the Spring Boot project
Spring Initializr is a web service that generates pre-configured Spring Boot projects. Instead of clicking through a web form, you can hit its API directly from the command line with curl.
- Move to your Desktop by running this command:
cd ~/Desktop
- Generate the Spring Boot project by running this command:
curl https://start.spring.io/starter.zip \
-d dependencies=web,data-jpa,data-redis,cache,validation,postgresql \
-d type=maven-project \
-d javaVersion=17 \
-d bootVersion=3.5.14 \
-d groupId=com.example \
-d artifactId=url-shortener \
-d name=url-shortener \
-d description="URL Shortener API with caching and rate limiting" \
-d baseDir=url-shortener \
-o url-shortener.zip
What does this command do?
This curl command hits the Spring Initializr API and downloads a pre-configured project as a zip file.
- dependencies=web,data-jpa,data-redis,cache,validation,postgresql adds all six starters you need: a web server, JPA for database access, Redis for caching, the Spring Cache abstraction, input validation, and the PostgreSQL JDBC driver.
- baseDir=url-shortener creates a top-level folder inside the zip so it extracts neatly.
- bootVersion=3.5.14 pins the project to Spring Boot 3.5.14.
- Unzip the project and move into it by running these commands:
unzip url-shortener.zip
cd url-shortener
- Confirm the project files exist by running:
ls
You should see pom.xml, mvnw, src/, and other project files in the output.
Seeing a connection error?
Make sure you have an internet connection. The curl command needs to reach start.spring.io to generate the project.
If curl is not installed, you can download the project from the Spring Initializr website instead. Select the same dependencies (Spring Web, Spring Data JPA, Spring Data Redis, Spring Cache, Validation, PostgreSQL Driver), set Group to com.example, Artifact to url-shortener, and Java to 17.
Help me troubleshoot my Spring Initializr download
- Open the project in VS Code by running:
code .
Create the Docker Compose file
Your API needs two databases: PostgreSQL for persistent storage and Redis for caching and rate limiting. Docker Compose lets you run both in containers with a single command.
- In VS Code, create a new file called docker-compose.yml in the root of your url-shortener folder.
- Add the following configuration to docker-compose.yml:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
redis:
image: redis:7.4
ports:
- "6379:6379"
What does this configuration do?
This file defines two services that Docker will run as containers on your machine.
- postgres:16 runs PostgreSQL 16 with a database called urlshortener and credentials postgres/postgres. Port 5432 is exposed so your Spring Boot app can connect.
- redis:7.4 runs Redis 7.4 on port 6379. You'll use this for caching URL lookups and tracking rate limit counters.
- Save docker-compose.yml.
- Start both containers by running this command in your terminal:
docker compose up -d
- Verify the containers are running by running:
docker compose ps
You should see both postgres and redis containers listed with a status of running.
Containers not starting?
Make sure Docker Desktop is running. If you see a "Cannot connect to the Docker daemon" error, open Docker Desktop and wait for it to fully start before retrying.
If port 5432 or 6379 is already in use, another service is occupying that port. Stop the conflicting service or change the port mapping in docker-compose.yml.
Help me fix my Docker containers
Configure application properties and verify the app starts
Spring Boot reads its configuration from application.properties. You need to tell it how to connect to the PostgreSQL and Redis containers you just started.
- In VS Code, open src/main/resources/application.properties.
- Replace the contents of the file with the following configuration:
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.data.redis.host=localhost
spring.data.redis.port=6379
server.port=8080
What do these properties do?
The first block connects to your PostgreSQL container. The JDBC URL points at the urlshortener database on port 5432.
spring.jpa.hibernate.ddl-auto=create tells Hibernate to automatically create database tables from your Java entities on every startup. This is perfect for development.
spring.jpa.show-sql=true logs every SQL query to the console so you can see exactly what Hibernate does behind the scenes.
The Redis properties point at port 6379, which matches your Docker Compose config.
- Save application.properties.
- Verify the app starts successfully by running:
./mvnw spring-boot:run
The first run downloads dependencies, so it may take a minute or two. You should eventually see a line containing Started UrlShortenerApplication and the app listening on port 8080.
- Press Ctrl+C in the terminal to stop the application once you've confirmed it starts.
App failing to start?
If you see a "Connection refused" error for port 5432, your PostgreSQL container may not be running. Run docker compose ps to check. If it's not listed, run docker compose up -d again.
If you see a "Connection refused" error for port 6379, your Redis container may have stopped. The same docker compose ps check applies.
Help me debug my Spring Boot startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here's what each file should look like at this point.
docker-compose.yml:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
redis:
image: redis:7.4
ports:
- "6379:6379"
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>url-shortener</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>url-shortener</name>
<description>URL Shortener API with caching and rate limiting</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
src/main/java/com/example/urlshortener/UrlShortenerApplication.java:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
src/main/resources/application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.data.redis.host=localhost
spring.data.redis.port=6379
server.port=8080
Your development environment is fully wired up. Next, you'll design the database layer with a JPA entity and repository.
Design the Database Layer
Your Spring Boot project is running and connected to PostgreSQL. Now you need to define how data is stored, queried, and validated.
Every URL shortener needs a way to map short codes to original URLs. In this step, you'll design the database layer that handles this mapping. You'll create a JPA entity that becomes a database table, a repository that handles queries and atomic updates, and a DTO that validates incoming requests before they reach your business logic.
In this step, get ready to:
- Create a JPA entity with indexed fields for fast URL lookups.
- Build a repository with an atomic click-count increment method.
- Define a request DTO with validation annotations.
Create the Url JPA entity
A JPA entity is a Java class that maps directly to a database table. Each field becomes a column, and each instance becomes a row. You'll annotate the class so that Hibernate automatically creates the table structure in PostgreSQL when the app starts.
- In the VS Code Explorer sidebar, right-click the src/main/java/com/example/urlshortener folder.
- Select New Folder and name it model.
- Right-click the new model folder, select New File, and name it Url.java.
- Paste the following code into Url.java:
package com.example.urlshortener.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "urls")
public class Url {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 7)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private Long clickCount = 0L;
public Url() {}
public Url(String shortCode, String originalUrl) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = LocalDateTime.now();
this.clickCount = 0L;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getShortCode() { return shortCode; }
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
public String getOriginalUrl() { return originalUrl; }
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public Long getClickCount() { return clickCount; }
public void setClickCount(Long clickCount) { this.clickCount = clickCount; }
}
What does this code do?
This entity defines the urls table in your PostgreSQL database.
- @Entity tells Hibernate this class maps to a database table.
- @Column(unique = true, length = 7) on shortCode creates a unique constraint. PostgreSQL automatically builds a database index for any unique constraint, which means lookups by short code are fast without you writing extra SQL.
- clickCount defaults to 0L so every new URL starts with zero clicks.
- The two-argument constructor sets createdAt to the current time automatically.
- Save Url.java.
- In your terminal, stop the running app with Ctrl+C, then restart it by running:
./mvnw spring-boot:run
- In the terminal output, look for Hibernate's SQL. You should see a create table urls statement with columns for id, short_code, original_url, created_at, and click_count.
Don't see the create table statement?
- Make sure your Docker containers are still running. Run docker compose ps to check that both postgres and redis show as running.
- Check that Url.java is in the model folder inside com.example.urlshortener. If it's in a different package, Spring Boot won't scan it.
- Verify the package declaration at the top of the file matches com.example.urlshortener.model exactly.
Why isn't Hibernate creating my table on startup?
✔️ Awesome, I've got everything!
Great. Double check you've saved Url.java before moving on.
ⓧ I'd like to double check the full code
package com.example.urlshortener.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "urls")
public class Url {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 7)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private Long clickCount = 0L;
public Url() {}
public Url(String shortCode, String originalUrl) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = LocalDateTime.now();
this.clickCount = 0L;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getShortCode() { return shortCode; }
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
public String getOriginalUrl() { return originalUrl; }
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public Long getClickCount() { return clickCount; }
public void setClickCount(Long clickCount) { this.clickCount = clickCount; }
}
Create the URL repository
A repository interface gives you database operations without writing SQL by hand. Spring Data JPA generates the implementation at runtime based on method names and annotations you define.
- Right-click the src/main/java/com/example/urlshortener folder in the Explorer sidebar.
- Select New Folder and name it repository.
- Right-click the repository folder, select New File, and name it UrlRepository.java.
- Paste the following code into UrlRepository.java:
package com.example.urlshortener.repository;
import com.example.urlshortener.model.Url;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
public interface UrlRepository extends JpaRepository<Url, Long> {
Optional<Url> findByShortCode(String shortCode);
@Modifying
@Transactional
@Query("UPDATE Url u SET u.clickCount = u.clickCount + 1 WHERE u.shortCode = :shortCode")
void incrementClickCount(@Param("shortCode") String shortCode);
List<Url> findTop10ByOrderByClickCountDesc();
}
What does this code do?
- findByShortCode is a Spring Data query method. Spring reads the method name and auto-generates a SELECT ... WHERE short_code = ? query.
- incrementClickCount uses a custom @Query to run UPDATE ... SET click_count = click_count + 1 directly in the database. This is atomic, meaning even if two users click the same link simultaneously, the count is always correct.
- findTop10ByOrderByClickCountDesc returns the 10 most-clicked URLs, sorted from highest to lowest. Spring generates this query from the method name alone.
The @Modifying annotation tells Spring this query changes data (it's not a SELECT). The @Transactional annotation ensures the update runs inside a transaction so it can be rolled back if something fails.
Why atomic increment instead of read-modify-write?
A naive approach would read the entity, increment the field in Java, and save it back. If two requests hit at the same time, one increment gets lost because both read the same value before either writes.
The UPDATE ... SET click_count = click_count + 1 pattern pushes the increment into PostgreSQL itself. The database handles concurrency with row-level locking, so every click is counted.
- Save UrlRepository.java.
You won't see a visible change yet. The repository only activates when the service layer calls it in the next step.
Seeing a compile error?
- Check that the import for Url matches your entity's package exactly: com.example.urlshortener.model.Url.
- Make sure the @Query string uses Url (the entity class name, not the table name urls). JPQL queries reference entity names, not table names.
Why is my repository not compiling?
✔️ Awesome, I've got everything!
Great. Double check you've saved UrlRepository.java before moving on.
ⓧ I'd like to double check the full code
package com.example.urlshortener.repository;
import com.example.urlshortener.model.Url;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
public interface UrlRepository extends JpaRepository<Url, Long> {
Optional<Url> findByShortCode(String shortCode);
@Modifying
@Transactional
@Query("UPDATE Url u SET u.clickCount = u.clickCount + 1 WHERE u.shortCode = :shortCode")
void incrementClickCount(@Param("shortCode") String shortCode);
List<Url> findTop10ByOrderByClickCountDesc();
}
Create the request validation DTO
Before a URL reaches your database, it needs to pass validation. A DTO (Data Transfer Object) defines the exact shape of incoming requests and rejects bad data at the API boundary.
- Right-click the src/main/java/com/example/urlshortener folder in the Explorer sidebar.
- Select New Folder and name it dto.
- Right-click the dto folder, select New File, and name it ShortenRequest.java.
- Paste the following code into ShortenRequest.java:
package com.example.urlshortener.dto;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;
public class ShortenRequest {
@NotBlank(message = "URL is required")
@URL(message = "Invalid URL format")
private String url;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
What does this code do?
- @NotBlank rejects empty strings and null values. If someone sends a request with no URL, they get an error immediately.
- @URL checks that the string is a properly formatted URL (starts with http:// or https://). Malformed strings like not-a-url are rejected before they reach your service code.
Notice the @URL import comes from org.hibernate.validator.constraints, not jakarta.validation. This is a Hibernate Validator extension included by the spring-boot-starter-validation dependency.
- Save ShortenRequest.java.
- In your terminal, stop the app with Ctrl+C and restart with:
./mvnw spring-boot:run
- The app should start cleanly on port 8080 with no errors. You should still see the create table urls SQL output from Hibernate.
App fails to start?
- If you see an error about @URL not being found, check your import. It must be org.hibernate.validator.constraints.URL (uppercase URL), not java.net.URL.
- If you see a package error, confirm each file's package declaration matches its folder path. For example, ShortenRequest.java must be inside dto/ and declare package com.example.urlshortener.dto.
Help me fix my Spring Boot startup error.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
package com.example.urlshortener.dto;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;
public class ShortenRequest {
@NotBlank(message = "URL is required")
@URL(message = "Invalid URL format")
private String url;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
Your database layer is locked in. You have an entity, a repository with atomic operations, and validated input. Next up, you'll build the service and controller that wire these pieces together into a working API.
Build the URL Shortening Service and API
You have a solid data layer with your entity and repository. Now you need the brains of the operation: a service class that contains the business logic, and a controller that exposes it as a REST API.
In this step, you'll build the service that generates short codes and looks up URLs, then wire it to three HTTP endpoints. By the end, you'll have a working URL shortener you can test from the command line.
In this step, get ready to:
- Create the URL shortening service with random code generation.
- Create the REST controller with shorten, redirect, and stats endpoints.
- Test the full flow with curl.
Create the URL shortening service
The service layer sits between the controller and the repository. It holds the business logic: generating short codes, saving URLs, and looking them up.
- In VS Code, create a new folder called service inside src/main/java/com/example/urlshortener/.
- Create a new file called UrlService.java inside the service folder, then paste in the following code:
package com.example.urlshortener.service;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
@Service
public class UrlService {
private final UrlRepository urlRepository;
// Characters used to generate random short codes
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 7;
// SecureRandom provides cryptographically strong randomness
private final SecureRandom random = new SecureRandom();
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
// Generate a short code and save the URL to the database
public Url createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
Url url = new Url(shortCode, originalUrl);
return urlRepository.save(url);
}
// Look up the original URL by short code (caching configured in next step)
@Cacheable(value = "urls", key = "#shortCode")
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
// Delegate click tracking to the atomic repository method
public void recordClick(String shortCode) {
urlRepository.incrementClickCount(shortCode);
}
// Return the full Url entity for the stats endpoint
public Url getUrlStats(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
// Build a random 7-character alphanumeric string
private String generateShortCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return sb.toString();
}
}
What does this code do?
- createShortUrl() generates a random 7-character code from uppercase, lowercase, and digits (62 possible characters per position). It then wraps the code and original URL into a Url entity and saves it to PostgreSQL.
- getOriginalUrl() looks up the original URL by short code. If no match exists, it throws a 404 response.
- recordClick() delegates to the atomic incrementClickCount query you wrote in the repository. This avoids race conditions when multiple users click the same link simultaneously.
- SecureRandom is used instead of Math.random() because it produces cryptographically strong random values, making short codes much harder to guess.
What about that @Cacheable annotation?
The @Cacheable annotation on getOriginalUrl() marks this method for caching. It won't activate until you configure the cache manager in the next step. For now, it's just a marker.
- Save UrlService.java.
You won't see a change yet. The service needs a controller to expose its methods as HTTP endpoints.
Seeing a compile error?
- Check that the package declaration at the top reads com.example.urlshortener.service. It must match the folder path exactly.
- Verify your imports include com.example.urlshortener.model.Url and com.example.urlshortener.repository.UrlRepository. These reference files you created in the previous step.
Why is my UrlService not compiling?
Create the REST controller
The controller is the public face of your API. It maps HTTP requests to service methods and shapes the response.
- Create a new folder called controller inside src/main/java/com/example/urlshortener/.
- Create a new file called UrlController.java inside the controller folder, then paste in the following code:
package com.example.urlshortener.controller;
import com.example.urlshortener.dto.ShortenRequest;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.service.UrlService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Map;
@RestController
public class UrlController {
private final UrlService urlService;
// Spring injects the UrlService automatically via constructor injection
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
// POST /api/shorten - accepts a JSON body with a "url" field
@PostMapping("/api/shorten")
public ResponseEntity<Map<String, String>> shortenUrl(@Valid @RequestBody ShortenRequest request) {
Url url = urlService.createShortUrl(request.getUrl());
Map<String, String> response = Map.of(
"shortCode", url.getShortCode(),
"shortUrl", "http://localhost:8080/" + url.getShortCode(),
"originalUrl", url.getOriginalUrl()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// GET /{shortCode} - redirects the browser to the original URL
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String originalUrl = urlService.getOriginalUrl(shortCode);
urlService.recordClick(shortCode);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originalUrl))
.build();
}
// GET /api/stats/{shortCode} - returns click count and metadata
@GetMapping("/api/stats/{shortCode}")
public ResponseEntity<Map<String, Object>> getStats(@PathVariable String shortCode) {
Url url = urlService.getUrlStats(shortCode);
Map<String, Object> stats = Map.of(
"shortCode", url.getShortCode(),
"originalUrl", url.getOriginalUrl(),
"clickCount", url.getClickCount(),
"createdAt", url.getCreatedAt().toString()
);
return ResponseEntity.ok(stats);
}
}
What does this code do?
- POST /api/shorten accepts a JSON body, validates it using the @Valid annotation (triggering your @NotBlank and @URL checks), then returns the short code with a 201 Created status.
- GET /{shortCode} looks up the original URL, records a click, then returns a 302 Found response with a Location header. Browsers automatically follow this redirect.
- GET /api/stats/{shortCode} returns the metadata and click count as JSON. This is useful for seeing how many times a short URL has been visited.
- Map.of() creates an immutable map that Spring automatically serializes to JSON in the response body.
- Save UrlController.java.
Seeing an import error on UrlService?
- Make sure the UrlService.java file you created in the previous substep is saved and has no errors of its own.
- Check the import line reads com.example.urlshortener.service.UrlService. This must match the package declaration in your service file.
Why can't my controller find UrlService?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to testing.
ⓧ I'd like to double check the full code
Here is UrlService.java:
package com.example.urlshortener.service;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
@Service
public class UrlService {
private final UrlRepository urlRepository;
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 7;
private final SecureRandom random = new SecureRandom();
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
public Url createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
Url url = new Url(shortCode, originalUrl);
return urlRepository.save(url);
}
@Cacheable(value = "urls", key = "#shortCode")
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
public void recordClick(String shortCode) {
urlRepository.incrementClickCount(shortCode);
}
public Url getUrlStats(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
private String generateShortCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return sb.toString();
}
}
Here is UrlController.java:
package com.example.urlshortener.controller;
import com.example.urlshortener.dto.ShortenRequest;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.service.UrlService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Map;
@RestController
public class UrlController {
private final UrlService urlService;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
@PostMapping("/api/shorten")
public ResponseEntity<Map<String, String>> shortenUrl(@Valid @RequestBody ShortenRequest request) {
Url url = urlService.createShortUrl(request.getUrl());
Map<String, String> response = Map.of(
"shortCode", url.getShortCode(),
"shortUrl", "http://localhost:8080/" + url.getShortCode(),
"originalUrl", url.getOriginalUrl()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String originalUrl = urlService.getOriginalUrl(shortCode);
urlService.recordClick(shortCode);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originalUrl))
.build();
}
@GetMapping("/api/stats/{shortCode}")
public ResponseEntity<Map<String, Object>> getStats(@PathVariable String shortCode) {
Url url = urlService.getUrlStats(shortCode);
Map<String, Object> stats = Map.of(
"shortCode", url.getShortCode(),
"originalUrl", url.getOriginalUrl(),
"clickCount", url.getClickCount(),
"createdAt", url.getCreatedAt().toString()
);
return ResponseEntity.ok(stats);
}
}
Test the basic flow with curl
Time to see your API in action. You'll shorten a URL, follow the redirect, and check the click stats.
- Start your Spring Boot application by running this command from your project root:
./mvnw spring-boot:run
Wait until you see a line containing Started UrlShortenerApplication in the terminal output. This confirms the app is running on port 8080.
App won't start?
- Make sure your Docker containers are still running. Run docker compose ps to check. If they're down, run docker compose up -d to restart them.
- If you see a connection refused error mentioning port 5432, PostgreSQL is not ready yet. Wait a few seconds and try again.
- If the error mentions a compilation failure, check that both UrlService.java and UrlController.java are saved with no typos.
Help me debug my Spring Boot startup error.
- Open a second terminal window. Shorten a URL by running this command:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url": "https://www.example.com"}'
You should see a JSON response containing a shortCode (a random 7-character string), a shortUrl, and the originalUrl you submitted.
- Copy the shortCode value from the response. Test the redirect endpoint by running:
curl -v http://localhost:8080/[[SHORTCODE="your short code from the response above"]]
In the verbose output, look for HTTP/1.1 302 and a Location: https://www.example.com header. This confirms the redirect is working.
- Check the click stats for your shortened URL by running:
curl http://localhost:8080/api/stats/[[SHORTCODE="your short code from the response above"]]
You should see a JSON response with clickCount showing 1. Each time you hit the redirect endpoint, this counter increments atomically in the database.
Why is the click count atomic?
A naive approach would read the count, add one, and save it back. If two requests arrive at the same time, both read the same value and both write the same incremented value. One click gets lost.
Your incrementClickCount method uses a SQL UPDATE ... SET clickCount = clickCount + 1 statement. The database handles the increment in a single operation, so no clicks are lost under concurrency.
Getting a 400 Bad Request on the shorten endpoint?
- Make sure your JSON body uses the key "url" (not "link" or "originalUrl"). This must match the field name in ShortenRequest.java.
- The URL value must include the protocol (https://). The @URL validator rejects values like www.example.com without a scheme.
- Include the -H "Content-Type: application/json" header. Without it, Spring doesn't know to parse the body as JSON.
Why is my shorten endpoint returning a 400 error?
Your URL shortener works end to end. Next up, you'll add Redis caching so that repeated lookups for the same short code skip the database entirely and resolve in sub-millisecond time.
Add Redis Caching
Your URL shortener works end to end. You can shorten a URL, follow the redirect, and check stats. But every single redirect hits PostgreSQL to look up the original URL.
In production, popular short URLs get clicked thousands of times per minute. Hitting the database on every request wastes resources and adds latency. Redis solves this by storing frequently accessed URLs in memory, so lookups return in sub-millisecond time without touching the database.
In this step, get ready to:
- Configure a Redis cache manager with a 60-minute TTL.
- Enable caching on the URL lookup hot path.
- Verify cached entries appear in Redis.
Create the Redis cache configuration
Spring Boot's cache abstraction needs a cache manager to know where and how to store cached values. You'll create a configuration class that tells Spring to use Redis with a 60-minute expiry on cached entries.
- In VS Code, create a new file at src/main/java/com/example/urlshortener/config/RedisConfig.java.
- Add the following code to configure the cache manager:
package com.example.urlshortener.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
What does this code do?
This configuration class does two important things:
- @EnableCaching activates Spring's caching infrastructure across the entire application. Without it, @Cacheable annotations on your methods would be silently ignored.
- The cacheManager bean creates a Redis-backed cache where every entry expires after 60 minutes. The StringRedisSerializer ensures cached URL strings are stored as plain text in Redis, making them easy to inspect.
- Save RedisConfig.java.
Why 60 minutes for the TTL?
Most shortened URLs point to stable destinations that rarely change. A 60-minute cache means even the most popular URLs only hit PostgreSQL once per hour. If a URL destination changes, the stale cache entry expires naturally within the hour.
Seeing a compile error?
Make sure the package declaration matches the folder structure exactly: package com.example.urlshortener.config. Also confirm you created the config directory inside src/main/java/com/example/urlshortener/.
Help me fix a compile error in my RedisConfig class
Understand the @Cacheable annotation on the service
With the cache manager in place, Spring can now intercept method calls and serve cached results. The getOriginalUrl method in your service already has the annotation that connects it to this cache.
- In VS Code, open src/main/java/com/example/urlshortener/service/UrlService.java.
- Find the getOriginalUrl method. Notice the annotation above it:
@Cacheable(value = "urls", key = "#shortCode")
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
How does @Cacheable work here?
When a request hits getOriginalUrl, Spring checks Redis for a key matching the short code before executing the method body. If a cached value exists, the method body never runs. If not, the method runs normally and Spring stores the result in Redis for future calls.
The value = "urls" names the cache region. The key = "#shortCode" uses the method parameter as the cache key, so each short code maps to its own cached entry.
Why not cache the click count too?
The click count changes on every single redirect request. Caching it would mean the stats endpoint shows stale numbers. The URL destination, on the other hand, almost never changes. Cache the stable data, query the volatile data fresh every time.
Verify caching works with Redis
Time to prove that Redis is actually caching your URL lookups. You'll hit the redirect endpoint twice and then peek inside Redis to confirm the entry was stored.
- Restart the application by running:
./mvnw spring-boot:run
- Shorten a URL by running:
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://spring.io"}'
- Copy the shortCode value from the response. Hit the redirect endpoint twice using your short code (replace the placeholder below with your actual code):
curl -v http://localhost:8080/[[SHORTCODE="your short code from the response above"]]
curl -v http://localhost:8080/[[SHORTCODE="your short code from the response above"]]
Why hit it twice?
The first request triggers a PostgreSQL lookup and stores the result in Redis. The second request is served entirely from cache. You can confirm this in your terminal: the SQL log from spring.jpa.show-sql=true only appears on the first call.
- Inspect the Redis cache by running:
docker compose exec redis redis-cli KEYS "*"
- You should see a key that includes urls and your short code, confirming the URL was cached in Redis.
Not seeing any keys in Redis?
Make sure your Redis container is running by checking docker compose ps. Both postgres and redis should show a status of Up.
Also confirm you restarted the Spring Boot application after creating RedisConfig.java. The cache manager is only loaded on startup.
Help me debug why my Redis cache is empty after hitting the redirect endpoint
✔️ Awesome, I've got everything!
Great. Make sure you've saved RedisConfig.java and confirmed a cached entry appears in Redis.
ⓧ I'd like to double check the full code
Here is the complete RedisConfig.java file:
package com.example.urlshortener.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
And confirm your UrlService.java has @Cacheable on the getOriginalUrl method:
package com.example.urlshortener.service;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
@Service
public class UrlService {
private final UrlRepository urlRepository;
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 7;
private final SecureRandom random = new SecureRandom();
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
public Url createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
Url url = new Url(shortCode, originalUrl);
return urlRepository.save(url);
}
@Cacheable(value = "urls", key = "#shortCode")
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
public void recordClick(String shortCode) {
urlRepository.incrementClickCount(shortCode);
}
public Url getUrlStats(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Short URL not found"));
}
private String generateShortCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return sb.toString();
}
}
Your URL shortener now serves hot URLs from Redis in sub-millisecond time. Next up, you'll add rate limiting to protect the API from abuse.
Implement Rate Limiting
Your API now caches hot URLs in Redis, making repeat lookups blazing fast. But fast APIs attract abuse.
Without rate limiting, a single client could hammer your API with thousands of requests per second. This overwhelms your database, starves other users of resources, and racks up costs in production. In this step, you'll build a custom rate limiter that uses Redis as a fixed-window counter to cap each client at 10 requests per minute.
In this step, get ready to:
- Create a rate limit interceptor that tracks requests per client IP using Redis.
- Register the interceptor across all API paths.
- Test that the 11th request in a minute returns HTTP 429.
Create the RateLimitInterceptor
A fixed-window rate limiter works by counting how many requests a client makes within the current clock minute. Each client IP gets its own counter in Redis, and when the counter exceeds the threshold, the server rejects the request with HTTP 429 (Too Many Requests).
- In VS Code, create a new package folder at src/main/java/com/example/urlshortener/ratelimit.
- Create a new file called RateLimitInterceptor.java inside that folder.
- Paste the following code into the file:
package com.example.urlshortener.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.time.Duration;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
private static final int MAX_REQUESTS_PER_MINUTE = 10;
public RateLimitInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Build a unique key from the client's IP and the current minute
String clientIp = request.getRemoteAddr();
long currentMinute = System.currentTimeMillis() / 60000;
String key = "rate_limit:" + clientIp + ":" + currentMinute;
// Atomically increment the counter for this window
Long requestCount = redisTemplate.opsForValue().increment(key);
// Set expiry only on the first request in the window
if (requestCount != null && requestCount == 1) {
redisTemplate.expire(key, Duration.ofSeconds(60));
}
// Reject if over the limit
if (requestCount != null && requestCount > MAX_REQUESTS_PER_MINUTE) {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Rate limit exceeded. Try again in 1 minute.\"}");
return false;
}
// Add rate limit headers to every response
response.setHeader("X-RateLimit-Limit", String.valueOf(MAX_REQUESTS_PER_MINUTE));
response.setHeader("X-RateLimit-Remaining",
String.valueOf(MAX_REQUESTS_PER_MINUTE - (requestCount != null ? requestCount : 0)));
return true;
}
}
What does this code do?
This interceptor runs before every controller method. It implements a fixed-window counter algorithm using Redis.
- The key pattern rate_limit:{ip}:{minute} ensures each client gets a separate counter for each clock minute.
- redisTemplate.opsForValue().increment(key) atomically increments the counter and returns the new value in one round trip.
- The 60-second TTL on the key means Redis automatically cleans up old counters. No manual garbage collection needed.
- Returning false from preHandle short-circuits the request. The controller never runs.
Why an interceptor instead of putting this in each controller?
Rate limiting is a cross-cutting concern. It applies to every endpoint in the same way, regardless of what the endpoint does. If you put rate-limit logic inside each controller method, you would duplicate code across every endpoint and risk missing one.
A HandlerInterceptor runs before any controller. You configure it once and it protects everything.
- Save RateLimitInterceptor.java.
You won't see a change yet. The interceptor exists but Spring doesn't know to use it until you register it in the next substep.
Register the interceptor with WebConfig
Spring MVC needs a configuration class that tells it which interceptors to apply and on which URL patterns. You'll create a WebConfig class that registers your rate limiter on all paths.
- Create a new file called WebConfig.java inside src/main/java/com/example/urlshortener/config/ (the same folder as RedisConfig.java).
- Paste the following code into the file:
package com.example.urlshortener.config;
import com.example.urlshortener.ratelimit.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Apply rate limiting to every endpoint
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/**");
}
}
What does this code do?
- WebMvcConfigurer is Spring's hook for customizing the MVC pipeline. Implementing it lets you register interceptors, formatters, and view resolvers.
- addPathPatterns("/**") applies the interceptor to every URL. You could narrow this to specific paths if some endpoints should be unmetered.
- Spring auto-injects RateLimitInterceptor because you annotated it with @Component in the previous file.
- Save WebConfig.java.
- Restart the application by pressing Ctrl+C in the terminal running the app, then running:
./mvnw spring-boot:run
The app should start without errors. You'll verify rate limiting works in the next substep.
Seeing a compilation error on startup?
- Check that RateLimitInterceptor.java is in the ratelimit package, not config. The import in WebConfig.java expects com.example.urlshortener.ratelimit.RateLimitInterceptor.
- Verify the package statement at the top of each file matches the folder structure exactly.
Why is my app failing to start after adding the interceptor?
Test rate limiting with rapid requests
The best way to verify rate limiting is to send more requests than the limit allows and confirm the server starts rejecting them. You'll fire 11 requests in quick succession and check that the 11th gets a 429 response.
- Open a second terminal in VS Code by pressing Ctrl+Shift+` (macOS and Windows).
- Send 11 requests rapidly by running this loop:
for i in $(seq 1 11); do echo "Request $i:"; curl -s -o /dev/null -w "HTTP %{http_code} | Remaining: " http://localhost:8080/api/stats/test && curl -s -D - http://localhost:8080/api/stats/test 2>&1 | grep -i "X-RateLimit-Remaining"; echo ""; done
What does this command do?
This loop sends 11 GET requests to the stats endpoint. It prints the HTTP status code and the X-RateLimit-Remaining header for each one. You should see the remaining count decrement from 9 down to 0, then the 11th request return HTTP 429.
If the loop syntax doesn't work on your system, you can also test with a simpler approach. Run a single request that shows headers:
curl -i http://localhost:8080/api/stats/test
- Run it repeatedly (11 times total) and watch the X-RateLimit-Remaining header decrease with each request.
- On the 11th request, you should see HTTP 429 and a JSON body that says Rate limit exceeded. Try again in 1 minute..
Not seeing HTTP 429 on the 11th request?
- Make sure you restarted the app after adding both files. The interceptor won't load until Spring restarts.
- If you waited more than a minute between requests, the window resets. Send all 11 requests within the same 60-second window.
- Confirm your Redis container is running by running docker compose ps. The interceptor depends on Redis being available.
Help me debug why my rate limiter isn't blocking the 11th request.
✔️ Awesome, I've got everything!
Great. Double check you've saved both new files before moving on.
ⓧ I'd like to double check the full code
Here's the complete RateLimitInterceptor.java:
package com.example.urlshortener.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.time.Duration;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
private static final int MAX_REQUESTS_PER_MINUTE = 10;
public RateLimitInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String clientIp = request.getRemoteAddr();
long currentMinute = System.currentTimeMillis() / 60000;
String key = "rate_limit:" + clientIp + ":" + currentMinute;
Long requestCount = redisTemplate.opsForValue().increment(key);
if (requestCount != null && requestCount == 1) {
redisTemplate.expire(key, Duration.ofSeconds(60));
}
if (requestCount != null && requestCount > MAX_REQUESTS_PER_MINUTE) {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Rate limit exceeded. Try again in 1 minute.\"}");
return false;
}
response.setHeader("X-RateLimit-Limit", String.valueOf(MAX_REQUESTS_PER_MINUTE));
response.setHeader("X-RateLimit-Remaining",
String.valueOf(MAX_REQUESTS_PER_MINUTE - (requestCount != null ? requestCount : 0)));
return true;
}
}
And the complete WebConfig.java:
package com.example.urlshortener.config;
import com.example.urlshortener.ratelimit.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/**");
}
}
Your URL shortener now has all the production backend patterns in place: layered architecture, database indexing, caching, atomic writes, and rate limiting. Next up, you'll tackle the Secret Mission and add click analytics.
Secret mission
Add Click Analytics with a Top URLs Dashboard
Your URL shortener tracks clicks, but what if you could see which URLs are the most popular? In this mission, you'll build an analytics dashboard endpoint that ranks your top URLs and enriches your stats with time-based insights.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Docker containers running PostgreSQL 16 and Redis 7.4.
- The url-shortener project folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with the API further.
- Your Docker containers will keep running in the background until you restart your machine or stop them manually.
- Your project files stay on your Desktop, ready to open in VS Code any time.
Pause - I'll come back to this later
Shut down the Docker containers to free up memory. Your project files and Docker images stay on disk so you can restart quickly.
- Stop the PostgreSQL and Redis containers by running this command from your url-shortener folder:
docker compose down
- When you're ready to come back, navigate to the project folder and run docker compose up -d to start the containers again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This stops the containers, removes them, and deletes the project folder.
- Stop and remove the Docker containers by running this command from your url-shortener folder:
docker compose down
- Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf url-shortener
- On Windows:
cd ~\Desktop
rmdir /s /q url-shortener
- Optionally remove the Docker images to reclaim disk space:
docker rmi postgres:16 redis:7.4
Nice Work!
Nice Work!
You just built a production-grade URL shortener API that goes well beyond basic CRUD!
You've learned how to:
- Built a layered Spring Boot REST API with input validation and a PostgreSQL schema using strategic indexing for fast lookups.
- Implemented Redis caching with TTL to serve hot URLs in sub-millisecond time.
- Built a custom rate limiter using Redis as a fixed-window counter and used atomic database updates to safely track click counts under concurrency.
- Added a click analytics dashboard with cached top-URL rankings.
Ready to quiz yourself?