URL Shortener β from 0 to big-tech scale
The same service (a URL shortener) evolved stage by stage as traffic grows. Each stage: the bottleneck β the fix β real Java code β how to say each line of code out loud in English.
- Bottleneck
- none β first get something working
- Fix
- One Spring Boot app + Postgres. Only shorten / redirect.
REAL BOTTLENECK at this stage: there is none yet. Stage 0 is the "0 to 1K requests" baseline where the only goal is a correct, working request path you can demo and reason about β a single Spring Boot app talking to one Postgres database. Do NOT add a cache, a queue, sharding, or a custom id algorithm here; at 1K rows Postgres alone is overkill, and adding infrastructure now is premature optimization that hides the through-line. TRADE-OFF ACCEPTED: the short key comes from a Postgres BIGSERIAL sequence (one DB round-trip to mint each id) and is then Base62-encoded. This is dead simple and collision-free, but it leaks information (keys are sequential, so anyone can guess /1, /2, /3 and enumerate every URL) and it couples id minting to a single database β which is exactly why Stage 3 swaps in Snowflake behind the SAME IdGenerator interface. INTERFACE-NOW vs LATER: I introduce IdGenerator as an interface in Stage 0 even though there is only one implementation, because I already KNOW a second implementation (Snowflake) is coming and the whole spine depends on that seam existing; I do NOT introduce a CacheService interface yet, because there is no second store to justify it (it arrives in Stage 1). That is the discipline: an interface appears the moment a real second implementation forces it, not one stage earlier. ONE INTERVIEW TALKING POINT: "My read path is fixed for the entire design β Controller to Service to (cache to) Repository. In Stage 0 the key is a DB sequence Base62-encoded; later stages only change WHICH bean answers behind an interface β IdGenerator goes from DbSequence to Snowflake, the cache impl gets swapped β and not a single call site in UrlService changes. The call graph never reshapes." EDGE CASES you can say out loud: redirect of an unknown key returns 404 (NotFoundException to the GlobalExceptionHandler), not a 500; redirect uses HTTP 302 (temporary) rather than 301 so we keep control of the URL and can later add click analytics on every hit instead of letting browsers cache the redirect forever; Flyway owns the schema so the table shape is versioned and reproducible rather than guessed by Hibernate auto-DDL.
Code
// build.gradle β this file tells Gradle (the build tool) what libraries to download
// and how to package the app. Spring Boot reads which libraries are on the classpath
// and AUTO-CONFIGURES beans for them (this is the magic called 'auto-configuration').
plugins {
id 'java'
// The Spring Boot plugin lets us run `./gradlew bootRun` and build a single runnable jar.
id 'org.springframework.boot' version '3.3.2'
// Manages dependency VERSIONS so we don't have to pin each one by hand.
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1'
java {
// Spring Boot 3 requires Java 17 or newer.
sourceCompatibility = '17'
}
repositories {
// Where Gradle downloads libraries from.
mavenCentral()
}
dependencies {
// 'web' brings in Spring MVC + an embedded Tomcat server, so the app serves HTTP.
implementation 'org.springframework.boot:spring-boot-starter-web'
// 'data-jpa' brings in JPA + Hibernate, so we map Java objects to DB rows.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// 'validation' enables @Valid / @NotBlank checks on incoming request bodies.
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Flyway runs versioned SQL migrations on startup so the DB schema is reproducible.
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
// The Postgres JDBC driver β the code that actually talks to a Postgres database.
runtimeOnly 'org.postgresql:postgresql'
// Test libraries (JUnit + Spring test support).
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}# application.yml β Spring reads this on startup to configure beans.
# YAML uses indentation to nest settings.
spring:
datasource:
# The JDBC URL of the Postgres database the app connects to.
url: jdbc:postgresql://localhost:5432/urlshortener
username: postgres
password: postgres
jpa:
hibernate:
# 'validate' means: on startup, CHECK that the Java entities match the DB tables,
# but DO NOT create or alter tables. Flyway owns the schema, not Hibernate.
# (The dangerous alternative, 'update', lets Hibernate silently change prod tables.)
ddl-auto: validate
properties:
hibernate:
# Log the SQL Hibernate generates, nicely formatted β great for learning.
format_sql: true
# Print the SQL statements to the console so you can see what JPA actually runs.
show-sql: true
flyway:
# Turn Flyway ON: it runs the versioned SQL files in db/migration before the app starts.
enabled: true
# The base of the short URL we hand back to clients, e.g. http://localhost:8080/aB3xQ
app:
base-url: http://localhost:8080-- Flyway runs this file ONCE and records that it ran, in a table called flyway_schema_history.
-- The filename format is strict: V<version>__<description>.sql (two underscores).
-- 'V1' is the version; running it twice is prevented by Flyway's checksum tracking.
CREATE TABLE short_url (
-- BIGSERIAL = a 64-bit auto-incrementing integer backed by a Postgres SEQUENCE.
-- This sequence is exactly the 'DB-sequence id' the Stage-0 IdGenerator reads.
id BIGSERIAL PRIMARY KEY,
-- The public short key, e.g. 'aB3xQ'. UNIQUE so two URLs can never share a key.
short_key VARCHAR(16) NOT NULL UNIQUE,
-- The original long URL we redirect to. TEXT has no length limit.
long_url TEXT NOT NULL,
-- When the row was created. NOT NULL so every row is auditable.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Optional expiry; NULL means 'never expires'.
expires_at TIMESTAMPTZ,
-- Optional owner identifier (e.g. an API key or user id). NULL allowed in Stage 0.
owner VARCHAR(64)
);
-- An index on short_key so the redirect lookup (WHERE short_key = ?) is fast.
-- (UNIQUE already creates an index, but naming it makes intent explicit for the reader.)
CREATE INDEX idx_short_url_short_key ON short_url (short_key);package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// @SpringBootApplication is THREE annotations in one:
// @Configuration β this class can define beans
// @EnableAutoConfiguration β wire beans automatically based on what's on the classpath
// @ComponentScan β find @Component/@Service/@RestController in this package and below
@SpringBootApplication
public class UrlShortenerApplication {
// The normal Java entry point. SpringApplication.run() boots everything:
// it starts the embedded Tomcat server, creates the bean container, runs Flyway,
// and connects to Postgres. After this line the app is live and serving HTTP.
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}package com.example.urlshortener;
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.Instant;
// @Entity tells JPA/Hibernate: this class maps to a database table.
// @Table names the exact table (otherwise it guesses from the class name).
@Entity
@Table(name = "short_url")
public class ShortUrl {
// @Id marks the primary-key field.
// @GeneratedValue(IDENTITY) means: let the DATABASE generate the id (the BIGSERIAL
// sequence) on INSERT, then read it back. This IS the Stage-0 DB-sequence strategy.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Maps to the short_key column; unique + not null mirror the SQL migration.
@Column(name = "short_key", nullable = false, unique = true, length = 16)
private String shortKey;
@Column(name = "long_url", nullable = false)
private String longUrl;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "expires_at")
private Instant expiresAt;
@Column(name = "owner")
private String owner;
// JPA REQUIRES a no-argument constructor to build the object when reading from the DB.
protected ShortUrl() {
}
// A convenience constructor for our own code to create new rows.
public ShortUrl(String longUrl, Instant expiresAt, String owner) {
this.longUrl = longUrl;
this.expiresAt = expiresAt;
this.owner = owner;
this.createdAt = Instant.now();
// Note: id and shortKey are filled in AFTER insert (id by the DB, shortKey by the service).
}
// Standard getters/setters β JPA and Jackson (JSON) read fields through these.
public Long getId() { return id; }
public String getShortKey() { return shortKey; }
public void setShortKey(String shortKey) { this.shortKey = shortKey; }
public String getLongUrl() { return longUrl; }
public Instant getCreatedAt() { return createdAt; }
public Instant getExpiresAt() { return expiresAt; }
public String getOwner() { return owner; }
}package com.example.urlshortener;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
// This is an INTERFACE with no body β and we never implement it ourselves.
// Spring Data JPA reads the interface at startup and GENERATES a working bean:
// save(), findById(), delete(), etc. all just work.
//
// CrudRepository<ShortUrl, Long> means: this repository stores ShortUrl entities
// whose primary key (id) type is Long.
public interface UrlRepository extends CrudRepository<ShortUrl, Long> {
// A DERIVED QUERY METHOD. Spring Data parses the METHOD NAME 'findByShortKey'
// and writes the SQL for us: SELECT * FROM short_url WHERE short_key = ?
// Returning Optional makes 'not found' explicit instead of a null surprise.
Optional<ShortUrl> findByShortKey(String shortKey);
}package com.example.urlshortener;
// An interface with a single method. This is a deliberate SEAM.
// Today its only implementation is DbSequenceIdGenerator (a Postgres sequence).
// In Stage 3 we add SnowflakeIdGenerator β and because UrlService depends on THIS
// interface, not the concrete class, we swap the bean and change zero call sites.
//
// Rule of thumb honored here: introduce an interface the moment a real second
// implementation is coming and the spine depends on the seam β not one stage too early.
public interface IdGenerator {
// Return the next numeric id. In Stage 0 it comes from the DB sequence;
// in Stage 3 it comes from a Snowflake algorithm. Callers don't care which.
long nextId();
}package com.example.urlshortener;
// A pure utility class: no Spring annotations, no state, just math.
// Base62 uses digits + lowercase + uppercase = 62 characters, so a number becomes
// a short, URL-safe string. id 125 -> '21', a big id -> 'aZ9k', etc.
public final class Base62Encoder {
// The 62 symbols. The index of a character is its 'digit value' in base 62.
private static final String ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = ALPHABET.length(); // 62
// private constructor: this class is never instantiated, only its static methods are used.
private Base62Encoder() {
}
// Encode a non-negative number into a base62 string.
public static String encode(long value) {
// 0 is a special case: the loop below would produce an empty string for it.
if (value == 0) {
return String.valueOf(ALPHABET.charAt(0));
}
StringBuilder sb = new StringBuilder();
// Repeatedly take the remainder mod 62 to get the next digit, then divide.
while (value > 0) {
int remainder = (int) (value % BASE);
sb.append(ALPHABET.charAt(remainder));
value /= BASE;
}
// Digits came out least-significant first, so reverse to read normally.
return sb.reverse().toString();
}
}package com.example.urlshortener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
// @Component marks this class so Spring creates and manages ONE instance of it β a bean.
// Because it implements IdGenerator, Spring will inject it anywhere an IdGenerator is needed.
@Component
public class DbSequenceIdGenerator implements IdGenerator {
// JdbcTemplate is Spring's thin helper for running raw SQL without boilerplate
// (no manual Connection/Statement/try-finally). Spring auto-configures one for us.
private final JdbcTemplate jdbcTemplate;
// CONSTRUCTOR INJECTION: Spring sees this constructor needs a JdbcTemplate and
// passes the bean in automatically. No @Autowired needed for a single constructor.
public DbSequenceIdGenerator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// @Override = we are implementing the IdGenerator.nextId() method.
@Override
public long nextId() {
// Ask Postgres for the next value of the sequence that backs short_url.id.
// BIGSERIAL secretly creates a sequence named '<table>_<column>_seq'.
// queryForObject runs the SQL and returns exactly one value, cast to Long.
Long next = jdbcTemplate.queryForObject(
"SELECT nextval('short_url_id_seq')", Long.class);
return next;
}
}package com.example.urlshortener;
import jakarta.validation.constraints.NotBlank;
// A DTO (Data Transfer Object): a plain object that mirrors the JSON the client sends.
// We keep it separate from the ShortUrl entity so the public API and the DB can evolve
// independently. A Java 'record' is a compact, immutable data carrier.
public record ShortenRequest(
// @NotBlank rejects null/empty/whitespace-only longUrl with a 400 (via @Valid).
@NotBlank String longUrl,
// Optional fields β may be null. Wired up fully in later stages.
String customAlias,
Long ttlSeconds
) {
}package com.example.urlshortener;
import java.time.Instant;
// The shape of the JSON we return from POST /api/urls.
// Again a record: immutable, and Jackson serializes its fields to JSON automatically.
public record ShortenResponse(
String shortUrl, // full clickable URL, e.g. http://localhost:8080/aB3xQ
String shortKey, // just the key, e.g. aB3xQ
Instant expiresAt // when it expires, or null if never
) {
}package com.example.urlshortener;
// A custom unchecked exception (extends RuntimeException, so no 'throws' clause needed).
// We throw this when a short key isn't found; the GlobalExceptionHandler turns it into 404.
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}package com.example.urlshortener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
// @Service marks this as a bean holding business logic. Functionally it's like @Component,
// but it documents intent: this is the service layer between the controller and the data.
@Service
public class UrlService {
// Two collaborators, both injected. UrlService depends on the IdGenerator INTERFACE,
// not on DbSequenceIdGenerator β that's the seam that lets Stage 3 swap in Snowflake.
private final UrlRepository repository;
private final IdGenerator idGenerator;
// Constructor injection: Spring passes in the repository bean and the IdGenerator bean.
public UrlService(UrlRepository repository, IdGenerator idGenerator) {
this.repository = repository;
this.idGenerator = idGenerator;
}
// @Transactional: run this whole method in ONE database transaction. If anything throws,
// the INSERT is rolled back so we never leave a half-written row.
@Transactional
public ShortUrl shorten(String longUrl, Instant expiresAt, String owner) {
// 1) Ask the id seam for a numeric id (Stage 0: the DB sequence).
long id = idGenerator.nextId();
// 2) Compact that number into a short, URL-safe key.
String shortKey = Base62Encoder.encode(id);
// 3) Build the entity and stamp the key on it.
ShortUrl entity = new ShortUrl(longUrl, expiresAt, owner);
entity.setShortKey(shortKey);
// 4) Persist it. save() returns the managed entity (now with its DB id populated).
return repository.save(entity);
}
// readOnly=true is a hint: no writes happen here. Stage 2 uses this exact flag to
// route the read to a replica β so this method body never changes for that.
@Transactional(readOnly = true)
public ShortUrl resolve(String shortKey) {
// Look the key up; if absent, turn the empty Optional into a 404-mapped exception.
// This is the read path: Controller -> Service -> (cache, added later) -> Repository.
return repository.findByShortKey(shortKey)
.orElseThrow(() -> new NotFoundException("No URL for key: " + shortKey));
}
}package com.example.urlshortener;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
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.time.Instant;
// @RestController = @Controller + @ResponseBody: every method returns DATA (JSON or a
// redirect), not an HTML view name. This is the one HTTP door into the whole system.
@RestController
public class UrlController {
private final UrlService urlService;
// @Value injects a config value from application.yml (app.base-url) into this field.
// We use it to build the full short URL we return.
private final String baseUrl;
public UrlController(UrlService urlService,
@Value("${app.base-url}") String baseUrl) {
this.urlService = urlService;
this.baseUrl = baseUrl;
}
// @PostMapping maps HTTP POST /api/urls to this method.
// @Valid triggers the @NotBlank check on the body; @RequestBody binds JSON -> DTO.
@PostMapping("/api/urls")
public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest req) {
// Convert the optional TTL (seconds) into an absolute expiry instant, or null.
Instant expiresAt = req.ttlSeconds() == null
? null
: Instant.now().plusSeconds(req.ttlSeconds());
// Delegate ALL the work to the service β the controller stays thin.
ShortUrl saved = urlService.shorten(req.longUrl(), expiresAt, null);
// Build the JSON response DTO from the saved entity.
ShortenResponse body = new ShortenResponse(
baseUrl + "/" + saved.getShortKey(),
saved.getShortKey(),
saved.getExpiresAt());
// Return 201 Created with the body. ResponseEntity lets us set the status explicitly.
return ResponseEntity.status(HttpStatus.CREATED).body(body);
}
// @GetMapping with a path variable: GET /aB3xQ. @PathVariable pulls 'aB3xQ' out of the URL.
@GetMapping("/{shortKey}")
public ResponseEntity<Void> redirect(@PathVariable String shortKey) {
// Look up the long URL (throws NotFoundException -> 404 if missing).
ShortUrl target = urlService.resolve(shortKey);
// Return 302 Found with a Location header pointing at the long URL.
// We pick 302 (temporary), not 301 (permanent), so browsers don't cache it forever
// and we keep the chance to count every click later (Stage 4 analytics).
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(target.getLongUrl()))
.build();
}
}package com.example.urlshortener;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
// @RestControllerAdvice = one place that catches exceptions thrown by ANY @RestController
// and converts them into clean JSON error responses. Keeps try/catch out of controllers.
@RestControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler says: when a NotFoundException bubbles up, run THIS method.
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(NotFoundException ex) {
// Respond 404 Not Found with a small JSON body { "error": "..." }.
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
}Spring / Java used here β what each one is
What: @SpringBootApplication is a single annotation that bundles three others: @Configuration (this class can define beans), @EnableAutoConfiguration (Spring wires beans automatically from what's on the classpath), and @ComponentScan (Spring scans this package and below for components).
Here: It marks UrlShortenerApplication as the one entry point and root of bean wiring, so a single main() boots the whole app β server, beans, Flyway, and the DB connection.
@SpringBootApplication is the all-in-one annotation that turns a plain class into the application's startup and auto-wiring root.
What: A static method that bootstraps a Spring Boot application: it creates the application context (the bean container), starts the embedded web server, and runs startup tasks.
Here: It's the one line in main() that actually launches everything β after it returns, the app is live and accepting HTTP requests.
SpringApplication.run is the single call that boots the entire application and starts the embedded server.
What: @RestController combines @Controller and @ResponseBody, so every handler method returns data (serialized to JSON) or a response directly, rather than the name of an HTML page.
Here: UrlController is a pure JSON/redirect API with no HTML views, so @RestController is exactly right for the single HTTP entry point.
@RestController makes a class a web API whose methods return data as JSON instead of HTML pages.
What: @Service is a stereotype annotation that marks a class as a Spring-managed bean holding business logic; functionally it behaves like @Component but documents that this is the service layer.
Here: UrlService sits between the controller and the repository and holds the shorten/resolve logic, so @Service signals its role and lets Spring inject it.
@Service marks the business-logic layer so Spring manages it as a bean and can inject it into the controller.
What: @Component is the base stereotype that marks any class so Spring creates and manages exactly one instance of it β a bean β and can inject it elsewhere.
Here: DbSequenceIdGenerator is a plain helper bean (not a controller, service, or repository), so @Component is the right generic marker.
@Component tells Spring to create and manage one instance of this class as a bean.
What: In Spring Data, a repository is an INTERFACE you extend; Spring generates the implementation at runtime. (The @Repository annotation also translates DB exceptions into Spring's common exception types.)
Here: UrlRepository extends CrudRepository so we get save/find methods for free without writing any SQL or implementation class.
A Spring Data repository is an interface Spring auto-implements so you get database methods without writing code.
What: A built-in Spring Data interface that provides standard create/read/update/delete operations for an entity type and its primary-key type.
Here: By extending CrudRepository<ShortUrl, Long>, UrlRepository instantly gains save(), findById(), delete() for ShortUrl rows keyed by a Long id.
CrudRepository gives a repository ready-made save and find operations for one entity type.
What: A Spring Data 'derived query': Spring reads the METHOD NAME and generates the matching SQL automatically (findByShortKey becomes WHERE short_key = ?).
Here: We need to look up a row by its short key on every redirect, and naming the method this way means we never write the query ourselves.
A derived query method lets Spring build the SQL just from the method name like findByShortKey.
What: @Entity marks a Java class as something JPA maps to a database table; each instance corresponds to one row.
Here: ShortUrl is our persistent domain object, so @Entity lets Hibernate read and write it to the short_url table.
@Entity tells JPA that this class maps to a database table, one object per row.
What: @Table explicitly names the database table an entity maps to, overriding the default name JPA would derive from the class name.
Here: We pin the table name to short_url so it matches the Flyway migration exactly, with no guessing.
@Table fixes the exact database table name that an entity maps to.
What: @Id marks the field that is the table's primary key.
Here: ShortUrl.id is the primary key, so JPA needs @Id to know which field uniquely identifies each row.
@Id marks which field is the primary key of the entity.
What: @GeneratedValue tells JPA the primary key is generated automatically; strategy=IDENTITY means the database itself produces the value (an auto-increment / sequence) on INSERT.
Here: This is the Stage-0 DB-sequence id strategy: Postgres's BIGSERIAL mints the id and JPA reads it back after insert.
@GeneratedValue with IDENTITY lets the database auto-generate the primary key on insert.
What: @Column configures how a field maps to a table column β its name, nullability, length, and uniqueness.
Here: We use it to match each ShortUrl field to its exact column (short_key, long_url, etc.) and mirror the SQL constraints.
@Column maps a field to a specific database column and sets its constraints.
What: Instant is a java.time class representing a single point on the UTC timeline (a timestamp), independent of time zone.
Here: createdAt and expiresAt are absolute moments, so Instant maps cleanly to Postgres TIMESTAMPTZ.
Instant is a timezone-independent timestamp marking one exact moment in time.
What: JPA requires every entity to have a no-argument constructor so Hibernate can instantiate the object before filling its fields when loading a row.
Here: ShortUrl declares a protected no-arg constructor purely to satisfy JPA, while a richer constructor is used by our own code.
JPA needs a no-argument constructor so it can build an entity instance when reading from the database.
What: A small interface declaring nextId(); it's a deliberate seam (an extension point) so the id strategy can change without touching callers.
Here: UrlService depends on this interface, so Stage 3 can swap DbSequence for Snowflake with zero changes to the service.
IdGenerator is the interface seam that lets us change how ids are minted without touching the service.
What: @Override is a compile-time check asserting that a method really overrides or implements a method from a superclass or interface; the compiler errors if it doesn't.
Here: On DbSequenceIdGenerator.nextId() it confirms we're correctly implementing IdGenerator's contract.
@Override tells the compiler to verify this method really implements or overrides an inherited one.
What: A dependency-injection style where a bean's collaborators are passed into its constructor; Spring supplies the matching beans automatically, and with a single constructor you don't even need @Autowired.
Here: UrlService, UrlController, and DbSequenceIdGenerator all receive their dependencies via the constructor, which keeps fields final and the object always fully built.
Constructor injection is when Spring supplies a bean's dependencies through its constructor automatically.
What: JdbcTemplate is Spring's helper that runs raw SQL while handling connections, statements, and cleanup for you, removing JDBC boilerplate.
Here: DbSequenceIdGenerator uses it to run one SELECT nextval(...) against Postgres to fetch the next sequence value.
JdbcTemplate is Spring's helper for running raw SQL without manual connection plumbing.
What: A JdbcTemplate method that runs a query expected to return exactly one row/value and maps it to the given type.
Here: We expect a single number back from SELECT nextval(...), so queryForObject(..., Long.class) returns it directly as a Long.
queryForObject runs a query that returns a single value and gives it back as the type you ask for.
What: A Java record is a compact, immutable class for carrying data; the compiler generates the constructor, accessors, equals, and hashCode for you.
Here: ShortenRequest and ShortenResponse are simple data carriers, so records keep them short and immutable.
A record is a concise immutable Java class whose boilerplate the compiler writes for you.
What: A DTO is a plain object whose only job is to carry data across a boundary β here, between the HTTP wire (JSON) and the application.
Here: Keeping ShortenRequest/ShortenResponse separate from the ShortUrl entity lets the API contract and the database schema evolve independently.
A DTO is a simple object that carries data across a boundary so the API and the database can change separately.
What: @RequestBody tells Spring to deserialize the HTTP request's JSON body into the method parameter object (using Jackson).
Here: It turns the incoming POST JSON into a ShortenRequest object the controller can use.
@RequestBody binds the JSON in the request body to a Java object parameter.
What: @Valid triggers bean validation on an object, checking its constraint annotations (like @NotBlank) and rejecting invalid input with a 400.
Here: It enforces that longUrl is present before we try to shorten it.
@Valid makes Spring validate the incoming object against its constraints before the method runs.
What: @NotBlank is a validation constraint that fails if a string is null, empty, or only whitespace.
Here: longUrl must always be a real value, so @NotBlank guards against blank input.
@NotBlank rejects a string that is null, empty, or just whitespace.
What: Shortcut annotations mapping an HTTP POST or GET request at a given path to a handler method.
Here: @PostMapping("/api/urls") handles shorten requests and @GetMapping("/{shortKey}") handles redirects.
@PostMapping and @GetMapping route POST and GET requests at a path to a specific method.
What: @PathVariable binds a piece of the URL path into a method parameter.
Here: In GET /{shortKey} it extracts the key segment from the URL so we can look it up.
@PathVariable pulls a value out of the URL path into a method argument.
What: @Value injects a value from configuration (like application.yml) into a field or parameter, using ${property.name} placeholder syntax.
Here: It injects app.base-url so the controller can build the full short URL it returns.
@Value injects a configuration property from application.yml into a field.
What: ResponseEntity represents a full HTTP response β status code, headers, and body β that you build explicitly and return from a handler.
Here: We use it to return 201 Created with a JSON body for shorten, and 302 Found with a Location header for redirect.
ResponseEntity is the object you build to control the exact HTTP status, headers, and body.
What: An enum of HTTP status codes (CREATED=201, FOUND=302, NOT_FOUND=404, ...).
Here: We pass HttpStatus.CREATED, FOUND, and NOT_FOUND to set the precise response code.
HttpStatus is the enum of standard HTTP status codes you select from.
What: java.net.URI represents a uniform resource identifier (a web address) in a structured, parsed form.
Here: We wrap the long URL in a URI to set it as the redirect's Location header.
URI is the Java type that represents a parsed web address.
What: Optional is a container that either holds a value or is empty, making the 'maybe absent' case explicit instead of returning null.
Here: findByShortKey returns Optional so the service must consciously handle the not-found case via orElseThrow.
Optional is a box that may or may not hold a value, forcing you to handle the empty case.
What: An Optional method that returns the contained value if present, or throws the exception you supply if the Optional is empty.
Here: resolve() uses it to turn a missing row into a NotFoundException (which becomes a 404).
orElseThrow gives you the value if present, or throws your exception when it's empty.
What: @Transactional wraps a method in a single database transaction that commits on success and rolls back if an exception is thrown; readOnly=true is a hint that no writes occur.
Here: shorten() uses it so the insert is atomic; resolve() uses readOnly=true, the exact flag Stage 2 will read to route reads to a replica.
@Transactional runs a method inside one database transaction that rolls back on failure.
What: @RestControllerAdvice marks a class that handles exceptions thrown by any @RestController across the whole app, returning JSON responses.
Here: GlobalExceptionHandler centralizes error-to-status mapping so controllers stay free of try/catch.
@RestControllerAdvice is one class that handles exceptions for all controllers and returns JSON errors.
What: @ExceptionHandler binds a specific exception type to a method that should handle it.
Here: It maps NotFoundException to the method that returns a 404 response.
@ExceptionHandler routes a particular exception type to the method that builds its response.
What: RuntimeException is the base class for unchecked exceptions in Java β ones you don't have to declare with 'throws' or catch.
Here: NotFoundException extends it so we can throw it freely from the service without cluttering signatures.
RuntimeException is the unchecked-exception base class you can throw without declaring it.
What: A static factory that creates a small immutable Map from the given key/value pairs.
Here: We build a tiny { "error": message } map as the JSON body of the 404 response.
Map.of creates a small immutable map inline from key-value pairs.
What: Flyway is a database-migration tool that runs versioned SQL files (named V1__, V2__, ...) in order, exactly once each, and records what it ran in a history table.
Here: It owns the schema so the short_url table is versioned and reproducible, instead of Hibernate guessing or altering it.
Flyway runs versioned SQL migrations in order so the database schema is reproducible.
What: A Hibernate setting; 'validate' means on startup Hibernate only CHECKS that entities match existing tables and never creates or alters them.
Here: We set validate so Flyway is the single source of truth for the schema and Hibernate can't silently change tables.
ddl-auto: validate makes Hibernate verify the schema matches without ever modifying tables.
What: BIGSERIAL is a Postgres pseudo-type that creates a 64-bit auto-incrementing column backed by a SEQUENCE object the database advances on each insert.
Here: It's the concrete Stage-0 id source: nextval on its sequence gives the number we Base62-encode into a short key.
BIGSERIAL is a Postgres auto-incrementing big-integer column backed by a sequence.
What: Spring Boot's web starter bundles a Tomcat servlet server INSIDE the app, so you run a normal Java jar and it serves HTTP β no separate server to install.
Here: It's why SpringApplication.run() alone makes the app reachable over HTTP on port 8080.
Embedded Tomcat is the web server packaged inside the app so it serves HTTP on its own.
What: A 302 is a temporary redirect: the browser follows the Location header now but does not cache the mapping permanently (unlike a 301).
Here: We redirect with 302 so we keep control of the mapping and can count every click later for analytics instead of letting browsers cache it.
HTTP 302 is a temporary redirect the browser follows now but won't cache forever.
Say it out loud, line by line π£
The write half of the spine: it mints a numeric id through the IdGenerator seam, Base62-encodes it into a short key, builds a ShortUrl entity, and saves it in one transaction.
Shorten asks the id generator for a fresh id, turns it into a short key, and persists a new short-URL row atomically.
@Transactionalpublic ShortUrl shorten(String longUrl, Instant expiresAt, String owner) {long id = idGenerator.nextId();String shortKey = Base62Encoder.encode(id);ShortUrl entity = new ShortUrl(longUrl, expiresAt, owner);entity.setShortKey(shortKey);return repository.save(entity);- mint a key
- β To generate a fresh, unique identifier on a write. Say this when describing the moment shorten() produces a new short key.
- atomic / rolls back
- β Either the whole operation commits or none of it does. Use when explaining why @Transactional wraps the insert.
- persist
- β To write an entity to durable storage (the database). Say 'we persist the entity' instead of 'we save the object'.
The read half of the spine: it looks up a row by its short key and turns a missing row into a 404-mapped exception. This is the call site where Stage 1 will insert the cache, with no change here.
Resolve looks up the short key and either returns the matching row or throws a not-found exception that becomes a 404.
@Transactional(readOnly = true)public ShortUrl resolve(String shortKey) {return repository.findByShortKey(shortKey).orElseThrow(() -> new NotFoundException("No URL for key: " + shortKey));- read path
- β The chain of calls that serves a lookup: Controller to Service to cache to Repository. Use it when claiming the call graph never reshapes.
- orElseThrow
- β Unwrap an Optional or throw if it's empty. Say this to describe converting a missing row into an error cleanly.
- read-only transaction
- β A transaction declared to do no writes. Mention it as the exact hook Stage 2 uses for replica routing.
The HTTP write endpoint: validates and binds the JSON body, converts an optional TTL into an expiry instant, delegates to the service, and returns 201 Created with a response DTO.
This endpoint validates the incoming JSON, hands the work to the service, and returns 201 with the new short URL.
@PostMapping("/api/urls")public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest req) {Instant expiresAt = req.ttlSeconds() == null ? null : Instant.now().plusSeconds(req.ttlSeconds());ShortUrl saved = urlService.shorten(req.longUrl(), expiresAt, null);ShortenResponse body = new ShortenResponse(baseUrl + "/" + saved.getShortKey(), saved.getShortKey(), saved.getExpiresAt());return ResponseEntity.status(HttpStatus.CREATED).body(body);- bind the body
- β Deserialize the request JSON into a Java object. Say 'Spring binds the body to the DTO' for @RequestBody.
- delegate to the service
- β Hand the actual logic to the service layer so the controller stays thin. A key phrase for explaining clean layering.
- thin controller
- β A controller that only translates HTTP and delegates, holding no business logic. Use it as a design talking point.
The HTTP read endpoint: resolves the key to a long URL and returns a 302 redirect with a Location header; a missing key becomes a 404 via the global handler.
This endpoint resolves the key and sends the browser a temporary redirect to the original long URL.
@GetMapping("/{shortKey}")public ResponseEntity<Void> redirect(@PathVariable String shortKey) {ShortUrl target = urlService.resolve(shortKey);return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(target.getLongUrl())).build();- 302 / temporary redirect
- β A redirect the browser follows now but won't cache permanently. Say why we chose it over 301 to keep click control.
- Location header
- β The response header that tells the browser where to go next. Mention it when describing how a redirect actually works.
- path variable
- β A value captured from the URL path. Use it to explain how /{shortKey} feeds the lookup.
The Stage-0 implementation of the IdGenerator seam: it asks Postgres for the next value of the sequence that backs short_url.id and returns it.
This implementation fetches the next value from the Postgres sequence and returns it as the new id.
@Overridepublic long nextId() {Long next = jdbcTemplate.queryForObject("SELECT nextval('short_url_id_seq')", Long.class);return next;- the seam
- β The interface boundary where one implementation can be swapped for another. Say 'this is the seam Snowflake plugs into in Stage 3'.
- nextval
- β The Postgres function that advances a sequence and returns the new value. Mention it as the concrete Stage-0 id source.
- queryForObject
- β A JdbcTemplate call expecting a single returned value. Use it when describing a one-value SQL read.
A pure function that converts a numeric id into a compact base62 string by repeatedly taking the value mod 62, then reversing the collected digits.
Encode turns a number into a short base62 string by repeatedly taking remainders mod 62 and reversing them.
if (value == 0) { return String.valueOf(ALPHABET.charAt(0)); }StringBuilder sb = new StringBuilder();while (value > 0) {int remainder = (int) (value % BASE);sb.append(ALPHABET.charAt(remainder));value /= BASE;return sb.reverse().toString();- base62
- β A numbering system using 62 symbols (0-9, a-z, A-Z) to make ids short and URL-safe. Say 'we base62-encode the id into the key'.
- pure function
- β A function with no side effects whose output depends only on its input. Use it to explain why Base62Encoder needs no Spring bean.
- least-significant first
- β Digits produced in reverse order, smallest place value first. Mention it to justify the final reverse().
Centralized error mapping: catches NotFoundException thrown anywhere in the controllers and returns a clean 404 with a small JSON error body.
This handler catches a not-found exception from any controller and returns a 404 with a JSON error message.
@ExceptionHandler(NotFoundException.class)public ResponseEntity<Map<String, String>> handleNotFound(NotFoundException ex) {return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", ex.getMessage()));- centralized error mapping
- β Translating exceptions to HTTP status codes in one place instead of in each controller. A clean-architecture talking point.
- bubbles up
- β An exception propagating from where it's thrown out to a handler. Say 'the not-found exception bubbles up to the advice'.