← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Java Project

URL Shortener

Turn a long URL into a short code and redirect real HTTP requests back to the original. Teaches a thread-safe ConcurrentHashMap store, collision-safe code generation, and serving on a virtual-thread-per-connection executor.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a URL shortener: give it a long link, it returns a short code; give back the code, it returns the original link. It teaches two-way lookups (code↔URL), generating unique keys, and persisting a small store — the core of any link service.

Where this shows up: bit.ly and every link shortener, QR-code targets, affiliate links, any system that maps a short key to a longer value — which includes caches, session stores, and lookup services generally.

2 How to Think About It

Two operations, one shared map: shortening picks a code and remembers what it points to; resolving looks the code back up and redirects there for real.

The plan — in plain English
1. Generate a random code. → 2. Retry if that code is already taken, so a collision can never silently overwrite an existing URL. → 3. Store the mapping in a thread-safe map. → 4. Redirect a request for that code with a real HTTP 307 and a Location header.

Long URL comes in

Generate a short code

Save code to URL mapping

Return the short code

Short code comes in

Look up the URL

Return the long URL

3 The Build — explained part by part

Here is the complete shortener. ConcurrentHashMap and a virtual-thread executor are both from the course’s Concurrency and Virtual Threads lessons, applied to a genuinely concurrent server rather than a toy example.

JavaUrlShortener.java
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;

/**
 * URL Shortener: maps short codes to long URLs in a thread-safe in-memory
 * store, served over real HTTP on a virtual-thread-per-connection executor.
 */
public class UrlShortener {

    private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private static final SecureRandom RANDOM = new SecureRandom();

    static class Store {
        private final ConcurrentHashMap<String, String> codeToUrl = new ConcurrentHashMap<>();

        /** Retries until it finds a code not already in use, so collisions can never overwrite a URL. */
        String shorten(String longUrl) {
            String code;
            do {
                code = randomCode(6);
            } while (codeToUrl.putIfAbsent(code, longUrl) != null);
            return code;
        }

        String resolve(String code) {
            return codeToUrl.get(code);
        }
    }

    static String randomCode(int length) {
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
        }
        return sb.toString();
    }

    static HttpServer start(int port, Store store) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/shorten", exchange -> {
            String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).trim();
            if (body.isBlank()) {
                exchange.sendResponseHeaders(400, -1);
                exchange.close();
                return;
            }
            String code = store.shorten(body);
            byte[] resp = code.getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(200, resp.length);
            exchange.getResponseBody().write(resp);
            exchange.close();
        });
        server.createContext("/", exchange -> {
            String code = exchange.getRequestURI().getPath().substring(1);
            String longUrl = store.resolve(code);
            if (longUrl == null) {
                exchange.sendResponseHeaders(404, -1);
            } else {
                exchange.getResponseHeaders().add("Location", longUrl);
                exchange.sendResponseHeaders(307, -1);
            }
            exchange.close();
        });
        // Virtual threads: one per connection, cheap enough that this scales far past a fixed thread pool.
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.start();
        return server;
    }

    public static void main(String[] args) throws IOException {
        start(8081, new Store());
        System.out.println("Listening on http://localhost:8081/shorten");
    }
}
⚠ No in-browser playground here
Java compiles to JVM bytecode and needs a real JDK to run, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once a JDK is installed.
What each part does — in plain words
ConcurrentHashMap<String, String> instead of a plain HashMap — the JDK’s own thread-safe map, needed because the HTTP server can call shorten and resolve concurrently from different connections at once.

codeToUrl.putIfAbsent(code, longUrl) != null inside a do/while loop — putIfAbsent is atomic: it inserts only if the key is genuinely absent and tells you whether it did, in one call, with no separate check-then-insert race condition the way a plain containsKey followed by put would have.

Executors.newVirtualThreadPerTaskExecutor() — from the course’s Virtual Threads lesson: each incoming connection gets its own cheap virtual thread instead of competing for a small fixed pool of expensive platform threads, letting this tiny server scale far past what a naive thread-per-connection design managed before Project Loom.

307 Temporary Redirect with a Location header, not 301 — a temporary redirect is the honest choice here since the short code’s mapping is not meant to be permanent or cached forever by the browser, unlike a real permanent URL change.
Common mistakes — and how to avoid them
✗ Checking containsKey then calling put separately to avoid overwriting an existing code — two concurrent requests can both pass the check before either writes, both then overwriting the same code.
✓ Use the atomic putIfAbsent, as shorten does, which cannot race with itself.
✗ Using java.util.Random for the short code — predictable codes would let someone guess other users’ shortened URLs.
✓ Use SecureRandom, as randomCode does, the same choice the password generator project makes for the same reason.

4 Test & Prove Each Part

We test the store's shorten/resolve logic directly, and separately prove the real server redirects correctly over actual HTTP.

Shortening then resolving returns the original URL
An unknown code resolves to null instead of throwing
Shortening the same URL twice gives two different codes, both resolvable
randomCode produces a code of exactly the requested length
A real running server shortens a URL and redirects to it with a 307 and correct Location header
JavaUrlShortenerTest.java
import com.sun.net.httpserver.HttpServer;
import org.junit.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;

public class UrlShortenerTest {

    @Test
    public void shortenThenResolveReturnsTheOriginalUrl() {
        UrlShortener.Store store = new UrlShortener.Store();
        String code = store.shorten("https://example.com/page");
        assertEquals("https://example.com/page", store.resolve(code));
    }

    @Test
    public void unknownCodeResolvesToNull() {
        UrlShortener.Store store = new UrlShortener.Store();
        assertEquals(null, store.resolve("nosuchcode"));
    }

    @Test
    public void shorteningTheSameUrlTwiceGivesTwoDifferentCodes() {
        UrlShortener.Store store = new UrlShortener.Store();
        String a = store.shorten("https://example.com");
        String b = store.shorten("https://example.com");
        assertNotEquals(a, b);
        assertEquals("https://example.com", store.resolve(a));
        assertEquals("https://example.com", store.resolve(b));
    }

    @Test
    public void randomCodeHasTheRequestedLength() {
        assertEquals(6, UrlShortener.randomCode(6).length());
        assertEquals(10, UrlShortener.randomCode(10).length());
    }

    @Test
    public void realServerShortensAndRedirects() throws Exception {
        UrlShortener.Store store = new UrlShortener.Store();
        HttpServer server = UrlShortener.start(0, store);
        try {
            int port = server.getAddress().getPort();
            HttpClient client = HttpClient.newBuilder()
                    .followRedirects(HttpClient.Redirect.NEVER)
                    .build();

            HttpRequest shortenReq = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/shorten"))
                    .POST(HttpRequest.BodyPublishers.ofString("https://thecodex.expert/coding/"))
                    .build();
            HttpResponse<String> shortenResp = client.send(shortenReq, HttpResponse.BodyHandlers.ofString());
            assertEquals(200, shortenResp.statusCode());
            String code = shortenResp.body();

            HttpRequest resolveReq = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/" + code))
                    .GET().build();
            HttpResponse<Void> resolveResp = client.send(resolveReq, HttpResponse.BodyHandlers.discarding());
            assertEquals(307, resolveResp.statusCode());
            assertNotNull(resolveResp.headers().firstValue("Location").orElse(null));
            assertEquals("https://thecodex.expert/coding/", resolveResp.headers().firstValue("Location").get());
        } finally {
            server.stop(0);
        }
    }
}

Compile and run with javac -cp junit-4.13.2.jar and hamcrest-core-1.3.jar UrlShortener.java UrlShortenerTest.java then java -cp .:junit-4.13.2.jar:hamcrest-core-1.3.jar org.junit.runner.JUnitCore UrlShortenerTest. The last test starts a real server on an OS-assigned ephemeral port (port 0), makes real HTTP calls against it with HttpClient configured to not auto-follow redirects, and inspects the actual 307 status and Location header — genuinely exercising the virtual-thread executor, not just the in-memory store.

5 The Interface

INPUTROUTESPOST /shorten, GET /:code
What it expects
curl -X POST localhost:8081/shorten -d 'https://example.com/very/long/path'
curl -v localhost:8081/AbC123
OUTPUTOUTPUTa short code, then a real redirect
What it returns
AbC123

HTTP/1.1 307 Temporary Redirect
Location: https://example.com/very/long/path

6 Run It & Automate It

Save the code as UrlShortener.java and compile it with javac — that turns your source into .class bytecode files, which java then runs on the JVM. No separate install step: any real JDK ships both tools.

Run it locally
javac UrlShortener.java && java UrlShortener
Then shorten a URL and follow the redirect from another terminal, exactly as shown below.

A CI tool like Jenkins runs the same compile-then-test steps automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ java UrlShortener
Listening on http://localhost:8081/shorten

$ curl -X POST http://localhost:8081/shorten -d 'https://thecodex.expert/coding/'
M5tyI4
$ curl -D - -o /dev/null http://localhost:8081/M5tyI4
HTTP/1.1 307 Temporary Redirect
Location: https://thecodex.expert/coding/
If it breaks — how to fix it
🚨 curl gets a 404 for a code that was just returned.
Codes are stored only in memory — if the server was restarted between shortening and resolving, the map is empty again. Keep the same server process running for both steps.
🚨 java.net.BindException: Address already in use
Port 8081 is already taken, likely by a previous run of this same server. Stop it first, or change the port.
GroovyJenkinsfile
// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
    agent any                          // run on any available machine
    environment {
        CP = 'junit-4.13.2.jar:hamcrest-core-1.3.jar'   // JUnit + its one dependency
    }

    stages {
        stage('Get the code') {
            steps { checkout scm }     // download the latest code
        }
        stage('Set up JDK') {
            steps {
                sh 'java -version'           // confirm a JDK is installed
                sh 'javac -cp "$CP" *.java'   // compile the program and its tests together
            }
        }
        stage('Run the tests') {
            steps {
                sh 'java -cp ".:$CP" org.junit.runner.JUnitCore UrlShortenerTest'
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed — look above.' }
    }
}
🎯 Try this next — make it yours
  1. Add expiry. Store a creation time alongside each URL and reject codes older than a day. (Teaches: Instant/Duration and a background cleanup task.)
  2. Track click counts. Count how many times each code is resolved. (Teaches: ConcurrentHashMap.compute for atomic read-modify-write updates.)
  3. Persist across restarts. Combine this with the to-do list project’s file-based persistence approach. (Teaches: loading a ConcurrentHashMap’s contents from disk at startup.)
What you learned
You learned ConcurrentHashMap’s atomic putIfAbsent for genuinely race-free collision handling, a virtual-thread-per-connection executor for a server that scales past a fixed thread pool, and why a temporary (307) redirect is the honest choice for a mapping that is not permanent. Related: Virtual Threads, Concurrency.