Build a URL Shortener in Java
Build a URL shortener API with Base62 encoding, caching, and analytics.
Introduction
30 Second Summary
Every time you share a link on social media, there is a URL shortener working behind the scenes. Services like bit.ly take long, unwieldy URLs and compress them into compact codes that redirect users to the right destination.
In this project, you will build a fully functional URL shortener REST API from scratch using Spring Boot. You'll implement Base62 encoding, HTTP redirects, click analytics, and a caching layer that optimizes for read-heavy traffic.
What You'll Build
Picture yourself pasting a long URL into your API, getting back a compact short code like localhost:8080/b, and watching your browser redirect to the original destination in real time.
By the end of this project, you'll have:
- A working REST API that shortens URLs into compact Base62-encoded codes and redirects visitors with proper HTTP 302 semantics.
- A click analytics endpoint that tracks how many times each short URL has been accessed, proving why production shorteners choose 302 over 301.
- A caching layer using Caffeine that demonstrates the cache-aside pattern for read-heavy workloads, serving hot URLs from memory instead of the database.
- Secret Mission: Add URL expiration with TTL and lazy deletion to handle time-based data lifecycle at scale.
Are there any prerequisites?
This project assumes basic Spring Boot experience (the controller/service/repository pattern) and Java fundamentals. You do not need prior system design interview knowledge.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Taking a moment to articulate the goal helps you stay focused as you work through each layer of the system.
Set Up Your Spring Boot Project
Before you can build any endpoints, you need a running Spring Boot project wired up with the right dependencies.
In this step, you will generate a project using Spring Initializr, add caching libraries, and configure the database and cache settings so the app starts cleanly.
In this step, get ready to:
- Generate a Spring Boot project with web, database, and caching dependencies.
- Add Caffeine caching dependencies to the project.
- Configure H2 and Caffeine in the application properties file.
Generate the project at Spring Initializr
Spring Initializr is a web tool that scaffolds a new Spring Boot project with your chosen dependencies already wired up.
- Open https://start.spring.io in your browser.
- Set **Project** to Maven.
- Set Language to Java.
- Set Spring Boot to 4.1.0.
- Under Project Metadata, set Group to com.example and Artifact to url-shortener.
- Set Packaging to Jar and Java to 17.
- Click ADD DEPENDENCIES and search for Spring Web. Select it.
- Search for Spring Data JPA and add it.
- Search for H2 Database and add it.
- Click GENERATE to download the project zip file.
Why Java 17 when you have Java 23?
The Java version in Spring Initializr sets the source compatibility level, not which JDK runs on your machine. Setting it to 17 means the code stays compatible with Java 17+.
Your Java 23 is fully backward-compatible, so it will compile and run Java 17 source code without issue.
- Move the downloaded url-shortener.zip to your Desktop.
- Unzip the file. This creates a url-shortener folder on your Desktop.
Open the project and add caching dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the url-shortener folder on your Desktop, then click Open.
Spring Initializr included three dependencies, but the project also needs a caching library. You will add two more dependencies manually.
- In the Explorer sidebar, click pom.xml to open it.
- Find the <dependencies> section. Locate the closing </dependency> tag of the H2 dependency.
- Add the following two dependencies directly below it (before the test dependency):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
What do these dependencies do?
- spring-boot-starter-cache enables Spring's caching abstraction so you can cache URL lookups with annotations like @Cacheable.
- caffeine is a high-performance in-memory cache that Spring Boot auto-configures as the cache backend. No version number is needed because the Spring Boot BOM manages it.
- Save pom.xml.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the application by running this command:
./mvnw spring-boot:run
Windows users
If you are using PowerShell, run .\mvnw.cmd spring-boot:run instead.
Maven will download dependencies on the first run, which may take a minute. You should see output ending with a line that includes Started UrlShortenerApplication.
Not seeing 'Started' in the output?
- Check that you saved pom.xml after adding the dependencies. Unsaved changes mean Maven uses the old file.
- Make sure you are running the command from inside the url-shortener folder. Run pwd to confirm you see url-shortener at the end of the path.
- If you see a permissions error on macOS, run chmod +x mvnw first.
help me fix my Spring Boot application not starting
- Stop the running server by pressing Ctrl+C in the terminal.
Configure the database and cache settings
The app started with defaults, but you need explicit configuration for the H2 database name, JPA behavior, and cache sizing.
- In the Explorer sidebar, expand src > main > resources.
- Click application.properties to open it.
- You will see one existing line: spring.application.name=url-shortener. Add the following configuration below it:
spring.datasource.url=jdbc:h2:mem:urlshortener
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=3600s
What does this configuration do?
- spring.datasource.url creates an in-memory H2 database named urlshortener. Data resets each time you restart the app.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to auto-create database tables from your Java entities.
- spring.jpa.show-sql=true prints every SQL query to the terminal so you can see what the database is doing.
- spring.h2.console.enabled=true gives you a web-based database browser at /h2-console.
- spring.cache.caffeine.spec configures the cache to hold up to 10,000 entries and expire each one after an hour.
- Save application.properties.
✔️ Awesome, I've got everything!
Great. Double check you have saved both pom.xml and application.properties.
ⓧ I'd like to double check the full code
Your application.properties should look exactly like this:
spring.application.name=url-shortener
spring.datasource.url=jdbc:h2:mem:urlshortener
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=3600s
For pom.xml, confirm that the <dependencies> section contains these six dependencies in any order: spring-boot-starter-web, spring-boot-starter-data-jpa, spring-boot-starter-cache, caffeine, h2, and spring-boot-starter-test.
Before you run the server again, what do you think will happen when you visit localhost:8080 in your browser? There are no controllers defined yet.
- Restart the application by running:
./mvnw spring-boot:run
You should see Started UrlShortenerApplication in the terminal output again.
- Open your browser and visit http://localhost:8080.
You will see a "Whitelabel Error Page" with status 404. This confirms the server is running and accepting requests, but has no endpoints to serve yet.
Not seeing the 404 page?
- Check the terminal for errors. If it says "port already in use", you may still have the server running from earlier. Press Ctrl+C and try again.
- Make sure you are visiting http://localhost:8080 (not https).
help me get my Spring Boot server running on localhost:8080
Your Spring Boot project is wired up and running. Next up, you will build the actual URL shortening endpoint and see your first short code generated.
Build the Naive URL Shortener
In the previous step, you set up a Spring Boot project with all the dependencies needed for a URL shortener.
Now it is time to build the core shortening logic. Before optimizing anything, you need a working version that accepts a long URL, stores it, and redirects anyone who visits the short code. This first version uses raw database IDs as short codes, which is the simplest possible approach.
In this step, get ready to:
- Create the JPA entity, repository, and service that store and retrieve URL mappings.
- Build a REST controller with endpoints for shortening URLs and redirecting visitors.
- Test the system and discover a critical security flaw in the naive approach.
Create the JPA entity and repository
Every URL shortener needs a place to store the mapping between a short code and the original long URL. In Spring Boot, you define this structure as a JPA entity class that maps directly to a database table.
- Create the package directory and file src/main/java/com/example/urlshortener/model/UrlMapping.java and paste the following code:
package com.example.urlshortener.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "url_mappings")
public class UrlMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 2048)
private String longUrl;
@Column(nullable = false, unique = true, length = 10)
private String shortCode;
@Column(nullable = false)
private long clickCount = 0;
@Column(nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}
What does this code do?
@Entity tells JPA this class represents a database table. @Table(name = "url_mappings") gives the table an explicit name.
- id is the primary key, auto-generated by the database.
- longUrl stores the original URL (up to 2048 characters).
- shortCode is the unique short identifier visitors will use.
- clickCount tracks how many times this short URL has been accessed.
- Add the constructors and getter/setter methods inside the class, before the closing }:
public UrlMapping() {}
public UrlMapping(String longUrl, String shortCode) {
this.longUrl = longUrl;
this.shortCode = shortCode;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLongUrl() { return longUrl; }
public void setLongUrl(String longUrl) { this.longUrl = longUrl; }
public String getShortCode() { return shortCode; }
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
public long getClickCount() { return clickCount; }
public void setClickCount(long clickCount) { this.clickCount = clickCount; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
Why explicit getters and setters?
JPA requires a no-arg constructor to instantiate entities from database rows. The getters and setters let the framework and your service code read and modify each field. The convenience constructor makes it easy to create new mappings in your service layer.
- Save UrlMapping.java.
Next, you need a repository interface that gives Spring Data JPA the instructions to query the database for your entity.
- Create the file src/main/java/com/example/urlshortener/repository/UrlMappingRepository.java and paste this code:
package com.example.urlshortener.repository;
import com.example.urlshortener.model.UrlMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UrlMappingRepository extends JpaRepository<UrlMapping, Long> {
// Spring Data JPA auto-generates the query from the method name
Optional<UrlMapping> findByShortCode(String shortCode);
}
What does this code do?
By extending JpaRepository, Spring auto-generates all basic CRUD operations (save, find, delete). The findByShortCode method is a derived query. Spring reads the method name and writes the SQL for you.
- Save UrlMappingRepository.java.
- Restart your server by pressing Ctrl+C in the terminal to stop it, then run:
./mvnw spring-boot:run
- Look at the terminal output. You should see a Hibernate SQL statement containing create table url_mappings in the logs. This confirms JPA detected your entity and created the table in H2.
Don't see the create table statement?
- Check that your entity file is in the correct package path: com.example.urlshortener.model. Spring Boot scans sub-packages of the main application class automatically.
- Confirm spring.jpa.show-sql=true is set in your application.properties.
- Make sure the import uses jakarta.persistence (not javax.persistence). Spring Boot 4.1.0 uses the Jakarta namespace.
Why isn't Spring Boot detecting my JPA entity?
Now you need a service class that contains the business logic for creating short URLs and resolving them back to their original destination.
- Create the file src/main/java/com/example/urlshortener/service/UrlShortenerService.java and paste this code:
package com.example.urlshortener.service;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.repository.UrlMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UrlShortenerService {
private final UrlMappingRepository repository;
public UrlShortenerService(UrlMappingRepository repository) {
this.repository = repository;
}
// Save with a temp code, then overwrite with the real ID
public UrlMapping shortenUrl(String longUrl) {
UrlMapping mapping = new UrlMapping();
mapping.setLongUrl(longUrl);
mapping.setShortCode("temp");
UrlMapping saved = repository.save(mapping);
saved.setShortCode(String.valueOf(saved.getId()));
return repository.save(saved);
}
}
What does this code do?
The shortenUrl method uses a two-save trick. It saves the entity once with a temporary short code to get the auto-generated ID from the database. Then it overwrites the short code with that ID (as a string) and saves again.
This means the first URL you shorten gets short code "1", the second gets "2", and so on. Simple, but you will see the problem with this soon.
- Add the remaining methods below the shortenUrl method, before the closing } of the class:
public Optional<UrlMapping> resolveUrl(String shortCode) {
return repository.findByShortCode(shortCode);
}
public void incrementClickCount(String shortCode) {
repository.findByShortCode(shortCode).ifPresent(mapping -> {
mapping.setClickCount(mapping.getClickCount() + 1);
repository.save(mapping);
});
}
public Optional<UrlMapping> getStats(String shortCode) {
return repository.findByShortCode(shortCode);
}
What do these methods do?
- resolveUrl looks up a mapping by its short code. Returns an Optional so the controller can handle missing codes gracefully.
- incrementClickCount bumps the counter each time someone visits a short URL.
- getStats retrieves the full mapping details including click count for analytics.
- Save UrlShortenerService.java.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before continuing.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these is present in your project, in order:
- UrlMapping.java has the entity fields chunk (package, imports, @Entity annotation, five fields) followed by the constructors and all getter/setter methods.
- UrlMappingRepository.java has the interface extending JpaRepository with the findByShortCode method.
- UrlShortenerService.java has the shortenUrl method (two-save trick) plus resolveUrl, incrementClickCount, and getStats.
Create the REST controller
The controller is what connects your service to the outside world via HTTP endpoints. It receives incoming requests, calls the service, and returns the appropriate HTTP response.
- Create the file src/main/java/com/example/urlshortener/controller/UrlShortenerController.java and paste the class scaffold:
package com.example.urlshortener.controller;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.service.UrlShortenerService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.util.Map;
import java.util.Optional;
@RestController
public class UrlShortenerController {
private final UrlShortenerService service;
public UrlShortenerController(UrlShortenerService service) {
this.service = service;
}
}
What does this code do?
@RestController tells Spring this class handles HTTP requests and returns JSON responses. The constructor injects your UrlShortenerService automatically via Spring's dependency injection.
- Add the POST endpoint inside the class, below the constructor:
// Accepts a JSON body with a "url" field and returns the short code
@PostMapping("/api/shorten")
public ResponseEntity<Map<String, String>> shortenUrl(@RequestBody Map<String, String> request) {
String longUrl = request.get("url");
if (longUrl == null || longUrl.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "URL is required"));
}
UrlMapping mapping = service.shortenUrl(longUrl);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"shortCode", mapping.getShortCode(),
"shortUrl", "http://localhost:8080/" + mapping.getShortCode(),
"longUrl", mapping.getLongUrl()
));
}
What does this endpoint do?
POST /api/shorten is the creation endpoint. It reads the url field from the JSON body, validates it is not empty, then calls the service to generate a short code. It returns HTTP 201 (Created) with the short code, full short URL, and original URL.
- Add the redirect endpoint below the POST method:
// Redirects the visitor to the original long URL
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
Optional<UrlMapping> mapping = service.resolveUrl(shortCode);
if (mapping.isEmpty()) {
return ResponseEntity.notFound().build();
}
service.incrementClickCount(shortCode);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(mapping.get().getLongUrl()))
.build();
}
What does this endpoint do?
GET /{shortCode} is the redirect endpoint. When someone visits localhost:8080/1, Spring maps "1" to the shortCode path variable. It returns HTTP 302 (Found) with a Location header pointing to the original URL, and increments the click counter.
- Add the stats endpoint below the redirect method:
// Returns click analytics for a given short code
@GetMapping("/api/stats/{shortCode}")
public ResponseEntity<Map<String, Object>> getStats(@PathVariable String shortCode) {
Optional<UrlMapping> mapping = service.getStats(shortCode);
if (mapping.isEmpty()) {
return ResponseEntity.notFound().build();
}
UrlMapping url = mapping.get();
return ResponseEntity.ok(Map.of(
"shortCode", url.getShortCode(),
"longUrl", url.getLongUrl(),
"clickCount", url.getClickCount(),
"createdAt", url.getCreatedAt().toString()
));
}
What does this endpoint do?
GET /api/stats/{shortCode} is the analytics endpoint. It returns the full mapping details including how many times the short URL has been clicked. This is how production shorteners power their dashboards.
- Save UrlShortenerController.java.
- Restart your server by pressing Ctrl+C then running:
./mvnw spring-boot:run
- Confirm you see "Started UrlShortenerApplication" in the logs with no errors.
- Test the POST endpoint by opening a new terminal and running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}' http://localhost:8080/api/shorten
- You should see a JSON response containing "shortCode":"1" and the full short URL.
Seeing a compilation error or 404?
- If you see a compilation error, check that all four files are in their correct package paths. The controller must import both the model and service classes.
- If you get a 404, make sure you are posting to /api/shorten (not /shorten).
- If you get a 415 Unsupported Media Type, confirm the -H "Content-Type: application/json" header is included in your curl command.
Why is my Spring Boot controller not responding to requests?
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before continuing.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these is present in your controller file, in order:
- The class scaffold with all imports, @RestController annotation, and the constructor injecting UrlShortenerService.
- The POST /api/shorten method that validates input and returns the short code.
- The GET /{shortCode} redirect method returning HTTP 302 with a Location header.
- The GET /api/stats/{shortCode} method returning click analytics.
Test the full flow and spot the vulnerability
Your URL shortener is working. Time to push it and see how the short codes behave when you create multiple URLs.
- Create two more short URLs by running these commands:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.github.com"}' http://localhost:8080/api/shorten
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.stackoverflow.com"}' http://localhost:8080/api/shorten
- Look at the short codes in each response. You should see "2" and "3".
Before you test the redirect, what do you expect to see when you visit one of these short codes? Will it return JSON, or will something else happen?
- Test the redirect by running:
curl -i http://localhost:8080/1
You should see HTTP/1.1 302 with a Location: https://www.google.com header. The -i flag shows the response headers so you can confirm the redirect status code.
- Try a non-existent code to verify error handling:
curl -i http://localhost:8080/999
- You should see HTTP/1.1 404 since no mapping exists for that code.
Now look at the pattern. Your short codes are 1, 2, 3. Can you see the problem?
The enumeration vulnerability
Anyone can guess that /4, /5, /6 will be the next codes. An attacker could write a simple loop to enumerate every URL in your system. Sequential integer codes expose your system's total scale and make every stored URL discoverable.
This is exactly the flaw you will fix in the next step using Base62 encoding.
Your naive URL shortener works end to end. You can shorten URLs, redirect visitors, and track clicks. In the next step, you will replace those guessable integer codes with compact Base62-encoded strings that are much harder to enumerate.
Implement Base62 Short Code Generation
In the previous step, you noticed that your short codes are just sequential numbers. Anyone who sees /1 can guess that /2, /3, and /4 also exist. That is a classic enumeration vulnerability.
Base62 encoding solves this by converting numeric IDs into compact alphanumeric strings. Instead of /1, the short code becomes /b. This is the standard approach discussed in system design interviews for URL shorteners.
In this step, get ready to:
- Create a Base62 encoder utility class with encode and decode methods.
- Update the shortening service to generate alphanumeric codes instead of raw numbers.
- Verify that short codes are now non-guessable and redirects still work.
Create the Base62Encoder utility class
Base62 uses 62 characters (digits, lowercase letters, and uppercase letters) to represent numbers as short strings. The algorithm repeatedly divides the number by 62 and maps each remainder to a character.
- Create the package directory for the utility by running this command from your project root:
mkdir -p src/main/java/com/example/urlshortener/util
- Create a new file called Base62Encoder.java inside the src/main/java/com/example/urlshortener/util/ directory.
- Paste the following code into the file:
package com.example.urlshortener.util;
public class Base62Encoder {
// 62 characters: digits + lowercase + uppercase
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = ALPHABET.length();
// Convert a numeric ID into a compact alphanumeric string
public static String encode(long number) {
if (number == 0) {
return String.valueOf(ALPHABET.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (number > 0) {
sb.append(ALPHABET.charAt((int) (number % BASE)));
number /= BASE;
}
return sb.reverse().toString();
}
// Convert a Base62 string back to the original number
public static long decode(String encoded) {
long result = 0;
for (int i = 0; i < encoded.length(); i++) {
result = result * BASE + ALPHABET.indexOf(encoded.charAt(i));
}
return result;
}
}
What does this code do?
The encode method takes a number (like the database ID 1) and converts it to a Base62 string. It works like converting decimal to hexadecimal, but with 62 symbols instead of 16.
The decode method reverses the process. You will not use decode in this project, but it is essential in production systems for analytics lookups.
- Save Base62Encoder.java.
- Confirm the file exists by running:
ls src/main/java/com/example/urlshortener/util/
You should see Base62Encoder.java in the output.
File not appearing?
- Make sure you created the util directory first. The mkdir -p command creates all parent directories that do not exist.
- Check that the file is inside the util folder, not directly in urlshortener/.
- help me find where my Base62Encoder.java file ended up
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
package com.example.urlshortener.util;
public class Base62Encoder {
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = ALPHABET.length();
public static String encode(long number) {
if (number == 0) {
return String.valueOf(ALPHABET.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (number > 0) {
sb.append(ALPHABET.charAt((int) (number % BASE)));
number /= BASE;
}
return sb.reverse().toString();
}
public static long decode(String encoded) {
long result = 0;
for (int i = 0; i < encoded.length(); i++) {
result = result * BASE + ALPHABET.indexOf(encoded.charAt(i));
}
return result;
}
}
Update the service to use Base62 encoding
Now you need to wire the encoder into your shortening logic. Instead of storing the raw database ID as the short code, you will encode it with Base62 first.
- In UrlShortenerService.java, add this import at the top of the file, below the existing imports:
import com.example.urlshortener.util.Base62Encoder;
- Inside the shortenUrl method, find this line:
saved.setShortCode(String.valueOf(saved.getId()));
- Replace it with these two lines:
String shortCode = Base62Encoder.encode(saved.getId());
saved.setShortCode(shortCode);
What changed?
Before, the code stored the raw numeric ID (like "1") as the short code. Now it encodes that ID with Base62 first, producing an alphanumeric string (like "b") that does not reveal the underlying sequence.
- Save UrlShortenerService.java.
- Stop your running server with Ctrl+C in the terminal, then restart it by running:
./mvnw spring-boot:run
Wait until you see Started UrlShortenerApplication in the terminal output.
Seeing a compilation error?
- Check that your import statement says com.example.urlshortener.util.Base62Encoder exactly. A typo in the package path causes an unresolved import.
- Make sure Base62Encoder.java is in the util folder, not elsewhere. Spring Boot scans based on package structure.
- help me fix my Base62Encoder compilation error
Test the Base62 short codes
Before you run the test, think about what you expect. If the first database ID is 1 and your Base62 alphabet starts with 0123456789abc..., what character should ID 1 map to?
- Open a new terminal tab and create a short URL by running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}' http://localhost:8080/api/shorten
You should see a JSON response where shortCode is "b" instead of the old "1". ID 1 maps to index 1 in the alphabet, which is the character b (since index 0 is 0).
- Confirm the redirect still works by running:
curl -i http://localhost:8080/b
You should see HTTP/1.1 302 with a Location: https://www.google.com header. The redirect works with the new alphanumeric code.
- Create a second short URL to see the pattern by running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.github.com"}' http://localhost:8080/api/shorten
This time the short code should be "c" (ID 2 maps to index 2 in the alphabet). The codes are no longer obvious sequential integers.
Three short code generation strategies
In a system design interview, you would discuss three approaches to generating short codes:
- Counter + Base62 (what you just built): Collision-free by construction because each ID is unique. Simple but codes are still technically sequential.
- Hash-based (MD5/SHA): Deterministic and non-sequential, but collisions are possible at scale. Requires collision-handling logic.
- Random Base62 with retry: Non-guessable with no single point of failure. The production recommendation, but adds complexity for collision checks.
Counter + Base62 is the simplest collision-free starting point. It teaches the encoding concept clearly while being easy to reason about in an interview.
Still seeing numeric codes?
- Make sure you restarted the server after saving. Changes only take effect after a restart.
- Confirm you replaced the String.valueOf(saved.getId()) line, not added the new lines alongside it.
- If you see "1" instead of "b", the old code is still running. Double-check the import is present.
- help me debug why my short codes are still numbers
✔️ Awesome, I've got everything!
Great. Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete UrlShortenerService.java with the Base62 changes:
Scroll up and confirm you have these pieces in your service file, in order:
- The import com.example.urlshortener.util.Base62Encoder; line near the top.
- Inside shortenUrl: the line String shortCode = Base62Encoder.encode(saved.getId()); followed by saved.setShortCode(shortCode);.
- No leftover String.valueOf(saved.getId()) line.
Your short codes are now alphanumeric and harder to guess. Next up, you will add click analytics and explore why production shorteners use HTTP 302 instead of 301.
Add Click Analytics and Redirect Semantics
Your shortener now generates compact Base62 codes instead of guessable integers. The core URL creation and redirect flow works.
A URL shortener without analytics is incomplete. Production systems like Bitly track every click because that data is the product. The redirect status code you choose (302 vs 301) determines whether click tracking is even possible. This is the trade-off interviewers always ask about.
In this step, get ready to:
- Verify the stats endpoint tracks clicks correctly.
- Understand why HTTP 302 preserves analytics and 301 breaks it.
- Test edge cases like missing short codes and invalid URLs.
Verify the stats endpoint tracks clicks
Your controller already has a GET /api/stats/{shortCode} endpoint that returns click analytics. Time to prove it works end-to-end.
- Make sure your Spring Boot server is still running in the terminal. If it stopped, restart it by running:
./mvnw spring-boot:run
- Open a second terminal. Create a short URL by running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}' http://localhost:8080/api/shorten
You should see a JSON response with a shortCode like 1 (or the next Base62-encoded ID if you have existing entries).
- Visit the short URL to trigger a redirect and increment the click count. Replace the short code with your actual value:
curl -s http://localhost:8080/1 -o /dev/null
- Now check the stats endpoint to see the click count by running:
curl http://localhost:8080/api/stats/1
You should see a JSON response containing clickCount with a value of 1, along with shortCode, longUrl, and createdAt.
What does the stats endpoint return?
The /api/stats/{shortCode} endpoint simulates the analytics dashboard that production shorteners provide. It returns four fields:
- shortCode and longUrl identify which mapping you are inspecting.
- clickCount tells you how many times the short URL has been accessed.
- createdAt shows when the URL was originally shortened.
Seeing clickCount as 0?
Make sure you visited the redirect endpoint (GET /1) before checking stats. The stats endpoint itself does not increment the counter.
Also confirm you are using the same short code in both commands. If your server restarted, the H2 in-memory database was wiped and you need to create a fresh short URL.
Help me figure out why my clickCount is stuck at 0.
Verify HTTP 302 redirect behavior
The redirect status code your shortener uses has a massive impact on whether analytics work at all. Let's see exactly what the server sends back.
- Use curl -i to inspect the full HTTP response headers by running:
curl -i http://localhost:8080/1
You should see HTTP/1.1 302 on the first line, followed by a Location: https://www.google.com header. That 302 is the key design decision.
Why 302 instead of 301?
HTTP 301 means "permanently moved." When a browser sees a 301, it caches the redirect locally. Every future visit to that short URL goes directly to the destination without ever hitting your server again.
HTTP 302 means "temporarily moved." The browser does not cache it. Every single click passes through your server, giving you the chance to increment clickCount each time.
Production shorteners choose 302 specifically to preserve click tracking. If you used 301, your analytics would be permanently broken after the first visit from each browser.
- Visit the same short URL two more times to bump the click count:
curl -s http://localhost:8080/1 -o /dev/null
curl -s http://localhost:8080/1 -o /dev/null
- Check the stats again to confirm the count increased by running:
curl http://localhost:8080/api/stats/1
The clickCount should now show 3 (one from earlier plus two just now). Every redirect hit your server and was counted.
Interview talking point
When asked about 301 vs 302 in a system design interview, frame it as a trade-off: 301 reduces server load (browser handles it locally) but sacrifices analytics. 302 preserves full visibility at the cost of handling every redirect request. Most production shorteners choose 302 because the analytics data is more valuable than the saved bandwidth.
Test edge cases
A robust API handles invalid input gracefully. Let's verify the two most common failure modes.
- Try accessing a short code that does not exist by running:
curl -i http://localhost:8080/nonexistent
You should see HTTP/1.1 404 with no body. The controller checks if the mapping exists and returns a 404 when it does not.
- Try creating a URL with a blank body by running:
curl -i -X POST -H "Content-Type: application/json" -d '{"url":""}' http://localhost:8080/api/shorten
You should see HTTP/1.1 400 with an error message. The controller validates that the URL is not null or blank before processing.
Why handle these at the controller level?
Returning proper HTTP status codes (404 for not found, 400 for bad input) is not just good practice. It makes your API predictable for clients. Any consumer of your shortener API can check the status code without parsing error messages.
Getting a 500 instead of 400?
If you see a 500 error when posting an empty URL, make sure the null/blank check in your controller runs before calling the service. The if (longUrl == null || longUrl.isBlank()) guard must appear before service.shortenUrl(longUrl).
Why am I getting a 500 error instead of a 400 when posting an empty URL?
Before you run the final verification, predict: if you create a brand new short URL and visit it exactly 3 times, what will the stats endpoint show for clickCount?
- Run this full flow to confirm the end-to-end analytics pipeline:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.github.com"}' http://localhost:8080/api/shorten
Note the shortCode from the response (it will be 2 if this is your second URL). Then visit it 3 times and check stats:
curl -s http://localhost:8080/2 -o /dev/null
curl -s http://localhost:8080/2 -o /dev/null
curl -s http://localhost:8080/2 -o /dev/null
curl http://localhost:8080/api/stats/2
You should see "clickCount":3 in the response. Every redirect passed through your server thanks to the 302 status code, and each one incremented the counter.
Your shortener now tracks every click and handles errors properly. But every redirect currently hits the database directly. URL shorteners have a 100:1 read-to-write ratio. Next up, you will add a caching layer so hot URLs are served from memory instead of the database.
Add Caching for Read-Heavy Optimization
Your URL shortener works end to end. Every redirect resolves correctly, and click analytics are tracking. But there is a problem hiding in plain sight.
Every single redirect hits the database. URL shorteners have a 100:1 read-to-write ratio. One URL gets created, then redirected hundreds of times. Without caching, the database becomes the bottleneck long before traffic grows. In this step, you will add a Caffeine cache layer that serves hot URLs from memory instead.
In this step, get ready to:
- Enable Spring's caching infrastructure with @EnableCaching.
- Add cache annotations to the service layer for read-heavy optimization.
- Verify cache hits and misses through application logs.
Enable caching and add @Cacheable to the resolve method
Spring Boot already has Caffeine on the classpath and the cache spec configured in application.properties. But caching annotations do nothing until you flip the switch with @EnableCaching.
- In UrlShortenerApplication.java, find the @SpringBootApplication annotation.
- Add @EnableCaching directly below it, and add the corresponding import at the top of the file.
Your file should now look like this:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
What does @EnableCaching do?
This single annotation activates Spring's cache interceptor. When a method marked @Cacheable is called, the interceptor checks the cache first. On a cache hit, the method body never executes. On a miss, the method runs and the result is stored for next time.
Because Caffeine is on the classpath and configured in application.properties, Spring auto-configures a CaffeineCacheManager with max 10,000 entries and a 1-hour write expiry.
Now you need to tell Spring which method to cache. The read path is resolveUrl. Every redirect calls it.
- Open UrlShortenerService.java.
- Find the resolveUrl method.
- Add @Cacheable(value = "urls", key = "#shortCode") directly above the method signature.
- Add the import org.springframework.cache.annotation.Cacheable at the top of the file.
- Save both files.
- Stop the server with Ctrl+C and restart it by running:
./mvnw spring-boot:run
- Create a short URL by running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}' http://localhost:8080/api/shorten
- Access the short URL by running:
curl -i http://localhost:8080/1
Look at the server terminal. You see Hibernate SQL logged for the database query. Run that same curl command a second time and watch the server output.
On the second access, no Hibernate SQL query appears for the short code lookup. The cache served the result from memory.
Still seeing SQL on the second request?
Make sure you added @EnableCaching to UrlShortenerApplication.java, not just the @Cacheable annotation on the method. Without @EnableCaching, all cache annotations are ignored.
Also confirm you fully restarted the server after making changes. Help me debug why my cache annotations are not working.
Add cache eviction and logging
The cache stores URL lookups, but when a click happens the clickCount changes in the database. Without evicting the stale entry, the cache serves outdated data forever.
- In UrlShortenerService.java, add these imports at the top of the file:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.CacheEvict;
- Add the logger field as the first line inside the class, above the repository field:
private static final Logger logger = LoggerFactory.getLogger(UrlShortenerService.class);
- Inside the resolveUrl method, add this log statement as the first line in the method body (above the return statement):
logger.info("Cache MISS - querying database for shortCode: {}", shortCode);
Why does this log line prove the cache works?
When @Cacheable intercepts a call and finds the key in the cache, it returns the cached value immediately. The method body never executes. That means this logger.info line only prints on a cache miss. If you see it once but not on the next call, caching is working.
- Add @CacheEvict(value = "urls", key = "#shortCode") above the incrementClickCount method.
Your resolveUrl and incrementClickCount methods should now look like this:
@Cacheable(value = "urls", key = "#shortCode")
public Optional<UrlMapping> resolveUrl(String shortCode) {
logger.info("Cache MISS - querying database for shortCode: {}", shortCode);
return repository.findByShortCode(shortCode);
}
@CacheEvict(value = "urls", key = "#shortCode")
public void incrementClickCount(String shortCode) {
repository.findByShortCode(shortCode).ifPresent(mapping -> {
mapping.setClickCount(mapping.getClickCount() + 1);
repository.save(mapping);
});
}
What does @CacheEvict do here?
Every time a user clicks a short URL, incrementClickCount fires. The @CacheEvict annotation removes that short code's entry from the urls cache. The next redirect for the same code triggers a fresh database lookup, which re-populates the cache with the updated click count.
This is the core trade-off of cache-aside. You sacrifice a cache hit on the next read to guarantee data consistency. In production with concurrent traffic, thousands of reads happen between each write, so the cache still absorbs the vast majority of load.
- Save UrlShortenerService.java.
- Stop the server with Ctrl+C and restart it by running:
./mvnw spring-boot:run
- Create a fresh short URL by running:
curl -X POST -H "Content-Type: application/json" -d '{"url":"https://www.example.com"}' http://localhost:8080/api/shorten
- Access the short URL by running:
curl -i http://localhost:8080/1
In the server terminal, you should see Cache MISS - querying database for shortCode: 1 logged. This confirms the method body executed on the first access.
Why does every sequential redirect show a cache miss?
The redirect endpoint calls resolveUrl (which caches the result) and then incrementClickCount (which evicts it). So each request populates the cache and then immediately invalidates it.
This is the expected trade-off for data consistency. In production with thousands of concurrent users, many reads hit the cache between writes. The cache absorbs the 100:1 read-to-write ratio that makes URL shorteners read-heavy.
Test the caching behavior and understand the cache-aside pattern
Before you run the final verification, think about what you expect to see. When you call the stats endpoint for a short code, does that evict the cache or leave it intact?
- Access the short URL three times to build up click counts:
curl -i http://localhost:8080/1
- Run that command two more times (three total).
- Check your stats endpoint to confirm click tracking still works:
curl http://localhost:8080/api/stats/1
You should see clickCount showing 3 (or higher if you accessed it earlier). The cache eviction ensures the database always has the accurate count.
What is the cache-aside pattern?
The pattern you just implemented is called cache-aside. On a read, your code checks the cache first. On a miss, it queries the database and populates the cache. On a write, it evicts the stale entry so the next read fetches fresh data.
In production, you would replace Caffeine with Redis and set a 24-hour TTL. Hot URLs (the top 20% that receive 80-90% of traffic) stay cached in memory, and the database only handles cold reads and all writes.
Why Caffeine instead of Redis?
Caffeine is an in-process cache that requires zero infrastructure setup. It implements the same cache-aside mechanics that Redis provides in production. Spring's @Cacheable abstraction is identical regardless of the backing store. Swapping to Redis later means changing one dependency and one config line.
✔️ Awesome, I've got everything!
Great. Double check you have saved both UrlShortenerApplication.java and UrlShortenerService.java.
ⓧ I'd like to double check the full code
Scroll up and confirm these pieces are in place in order:
- UrlShortenerApplication.java has @EnableCaching below @SpringBootApplication and the org.springframework.cache.annotation.EnableCaching import.
- UrlShortenerService.java imports Logger, LoggerFactory, CacheEvict, and Cacheable.
- The logger field is declared as the first field in the class.
- resolveUrl has @Cacheable(value = "urls", key = "#shortCode") and the logger.info line inside.
- incrementClickCount has @CacheEvict(value = "urls", key = "#shortCode").
Here is the full UrlShortenerApplication.java for reference:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
Your URL shortener now serves hot URLs from memory instead of hitting the database on every request. In a system design interview, you can confidently explain the cache-aside pattern, why you chose Caffeine locally but would use Redis in production, and how eviction keeps data consistent.
Secret mission
Add URL Expiration with TTL and Lazy Deletion
Production URL shorteners support link expiration. Can you add time-to-live (TTL) support with both lazy deletion on read and active background cleanup? This combines entity changes, endpoint modifications, cache eviction, and Spring's scheduling framework.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with an in-memory database, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The url-shortener project folder on your Desktop.
- The Spring Boot server process (running in your terminal).
- H2 in-memory database (exists only while the server runs, no data persists after shutdown).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the URL shortener.
- Your Spring Boot server continues running in the terminal.
- The H2 database lives in memory and resets each time you restart the server.
- There are no costs associated with keeping this project running locally.
Pause - I'll come back to this later
Shut down the running server process to free up memory and your terminal. Your project files remain on disk so you can restart any time.
- Switch to the terminal where the Spring Boot server is running.
- Press Ctrl+C to stop the server.
- Your url-shortener folder and all source files remain on your Desktop.
- To resume later, open the project in VS Code and run ./mvnw spring-boot:run again.
Delete - I don't want to use this again
Remove all project files from your machine. Since the database is in-memory, stopping the server already destroys all stored URLs.
- Switch to the terminal where the Spring Boot server is running and press Ctrl+C to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/url-shortener
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\url-shortener
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see url-shortener listed.
Nice Work!
Nice Work!
Nice work! You just built a fully functional URL shortener API from scratch, complete with Base62 encoding, HTTP redirect semantics, click analytics, and a caching layer.
You've learned how to:
- Build a URL shortener REST API with collision-free Base62-encoded short codes and proper HTTP 302 redirects that preserve click analytics.
- Implement a Caffeine cache layer using the cache-aside pattern to optimize for read-heavy traffic and confirm cache hits vs misses through application logs.
- Explain the three short code generation strategies (counter+Base62, hash-based, random) and the 301 vs 302 redirect trade-off with confidence in a system design interview.
- Secret Mission: Added URL expiration with TTL using both lazy deletion on read and active background cleanup with a scheduled task.
Ready to quiz yourself?