A small URL shortener built for the coding exercise brief.
- Backend — Spring Boot 3.3 / Java 21 with Spring MVC, Spring Data JPA and PostgreSQL.
- Frontend — Next.js 16 (App Router) / React 19 with Tailwind v4.
- Tests — every feature was developed test-first (JUnit 5, Mockito,
@WebMvcTest,@DataJpaTest+ Testcontainers; Vitest, React Testing Library + MSW). - Containerised — one
docker compose upfor db + api + ui.
┌──────────────┐ HTTP/JSON ┌──────────────┐ JDBC ┌──────────────┐
│ Next.js UI │ ──────────────► │ Spring Boot │ ─────────► │ PostgreSQL │
│ :3000 │ │ :8080 │ │ :5432 │
└──────────────┘ └──────────────┘ └──────────────┘
Backend layout (backend/src/main/java/com/example/urlshortener/):
| Package | Responsibility |
|---|---|
domain |
Value objects (Alias, FullUrl) and the Base62AliasEncoder (id → alias). Pure Java, fully unit-tested. |
persistence |
ShortUrlEntity + Spring Data ShortUrlRepository, plus the short_url_id_seq sequence in schema.sql. |
application |
ShortUrlService — implements the shorten flow (dedup → next id → encode → save), plus custom-alias guard and find/list/delete. |
web |
DTOs, UrlController, GlobalExceptionHandler, ApiError. |
config |
AppProperties (app.*), ApplicationConfig, CorsConfig. |
1. input: longURL
2. longURL in DB? ─yes─► 3. return existing shortURL
│ no
▼
4. nextval('short_url_id_seq') ──► 5. base62-encode the id ──► 6. save (id, alias, longURL)
A monotonic database sequence feeds a bijective base62 encoder, so two
distinct ids always produce two distinct aliases — random collisions
between auto-generated aliases are eliminated by construction. Custom
aliases share the same alias column (protected by a unique index); on
the rare chance an encoded id happens to shadow an existing custom
alias, the service walks forward in the sequence until it finds a free
slot.
Implements openapi.yaml. All endpoints are JSON.
| Method | Path | Status codes | Notes |
|---|---|---|---|
| POST | /shorten |
201, 400 |
Body: { "fullUrl": "...", "customAlias": "..." } (alias optional) |
| GET | /{alias} |
302 (Location), 404, 400 |
Public redirect endpoint |
| DELETE | /{alias} |
204, 404, 400 |
|
| GET | /urls |
200 |
Returns [{ alias, fullUrl, shortUrl }] |
Validation rules:
fullUrlmust behttp/https, ≤ 2048 chars, with a host.customAlias(and any path alias) must match^[A-Za-z0-9_-]+$, ≤ 64 chars, and is not one of the reserved names (shorten,urls).- A duplicate custom alias yields
400withAlias '...' is already taken. - A blank
customAliasis treated as "no alias" and a random one is generated.
Errors use a uniform envelope:
{
"timestamp": "2026-05-03T18:00:00Z",
"status": 400,
"error": "Bad Request",
"message": "URL must use the http or https scheme"
}Prereqs: Docker + Docker Compose.
docker compose up --buildWhen everything is healthy:
- UI: http://localhost:3000
- API: http://localhost:8080
- DB: localhost:5432 (user/password/db:
urlshortener)
The frontend image is built with NEXT_PUBLIC_API_BASE_URL=http://localhost:8080. To point it at a different backend, override that build arg in docker-compose.yml (or rebuild with docker compose build --build-arg NEXT_PUBLIC_API_BASE_URL=...).
docker run --rm -d --name url-shortener-db \
-e POSTGRES_USER=urlshortener \
-e POSTGRES_PASSWORD=urlshortener \
-e POSTGRES_DB=urlshortener \
-p 5432:5432 postgres:16-alpinecd backend
./mvnw spring-boot:run # or: mvn spring-boot:runConfigurable via env (defaults shown):
| Env var | Default |
|---|---|
APP_PUBLIC_BASE_URL |
http://localhost:8080 |
APP_CORS_ALLOWED_ORIGINS |
http://localhost:3000 |
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/urlshortener |
SPRING_DATASOURCE_USERNAME |
urlshortener |
SPRING_DATASOURCE_PASSWORD |
urlshortener |
SPRING_JPA_HIBERNATE_DDL_AUTO |
update |
cd frontend
npm install
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 npm run devOpen http://localhost:3000.
cd backend
mvn testWhat runs:
- Pure unit —
AliasTest,FullUrlTest,Base62AliasEncoderTest,ShortUrlServiceTest(Mockito). - Web slice —
UrlControllerTest,CorsConfigTest(@WebMvcTest). - Persistence slice —
ShortUrlRepositoryTest(@DataJpaTestagainst a Testcontainers Postgres). - Full context —
UrlShortenerApplicationTests(@SpringBootTest).
The repository / Spring-context tests need a Docker daemon (Testcontainers spins up postgres:16-alpine).
cd frontend
npm test # vitest run, headless
npm run test:watch # watch mode
npm run lint # eslintCovers the typed API client (with MSW), the ShortenForm and UrlList components, and the HomeView orchestrator (load → submit → refresh, error propagation, delete).
- Open http://localhost:3000.
- Paste a URL into "Full URL" (e.g.
https://nextjs.org/docs). - (Optional) type a custom alias like
docs. - Click Shorten — the new entry appears in the list with a copyable short URL.
- Click Delete to remove it; click the short URL to follow the redirect.
# Create with an auto-generated alias (id from short_url_id_seq, base62-encoded)
curl -i -X POST http://localhost:8080/shorten \
-H 'Content-Type: application/json' \
-d '{"fullUrl":"https://nextjs.org/docs"}'
# HTTP/1.1 201
# {"shortUrl":"http://localhost:8080/B"} # for id=1; later ids grow as needed
# Create with a custom alias
curl -i -X POST http://localhost:8080/shorten \
-H 'Content-Type: application/json' \
-d '{"fullUrl":"https://nextjs.org/docs","customAlias":"docs"}'
# HTTP/1.1 201
# {"shortUrl":"http://localhost:8080/docs"}
# Follow the redirect
curl -i http://localhost:8080/docs
# HTTP/1.1 302
# Location: https://nextjs.org/docs
# List all
curl http://localhost:8080/urls
# Delete
curl -i -X DELETE http://localhost:8080/docs
# HTTP/1.1 204- Persistence — chose Postgres over an embedded H2 so the deployed shape matches what tests run against (Testcontainers). The table is auto-managed by Hibernate (
ddl-auto=update); theshort_url_id_seqsequence is created byschema.sql(idempotentCREATE SEQUENCE IF NOT EXISTS). A real deployment would use Flyway/Liquibase to manage both. - Alias generation — a database sequence (
short_url_id_seq) feeds a bijective base62 encoder, so distinct ids always produce distinct aliases. Auto-vs-auto random collisions are mathematically impossible. The custom-alias path is a separate guard that returns400immediately if the alias is taken; if a future encoded id ever shadows an existing custom alias, the service walks forward in the sequence until it finds a free slot. - Dedup on the auto-alias path — shortening the same URL twice without a custom alias returns the existing entry instead of creating a second one. The custom-alias path skips this on purpose: if you explicitly ask for
/foo, you get/foo(or400if it's taken), even if the URL is already shortened under another alias. - Reserved aliases —
shortenandurlsare blocked at theAliasvalue object level so they can't shadow API routes. - Error model —
IllegalArgumentExceptionfrom value objects andAliasAlreadyTakenExceptionboth map to400with the exception message in the response body, so the UI can show it verbatim. - CORS — driven by
app.cors-allowed-origins. Defaults tohttp://localhost:3000for local dev; in compose it's set to the same. - Reading
customAlias = " "— treated as "no alias provided" both in the controller and the API client, so a stray whitespace from the form doesn't break shortening. - Frontend state — kept inside
HomeView(no global store) since the page has a single concern. Initial load lives in an async IIFE insideuseEffectto keep React 19'sset-state-in-effectlint rule happy. - Time spent — roughly half a day (≈4 hours) end-to-end across backend, frontend, tests, dockerisation and docs.
The original task description is preserved below for context.
Build a simple URL shortener in a preferably JVM-based language (e.g. Java, Kotlin).
It should:
- Accept a full URL and return a shortened URL.
- A shortened URL should have a randomly generated alias.
- Allow a user to customise the shortened URL if they want to (e.g. user provides
my-custom-aliasinstead of a random string).- Persist the shortened URLs across restarts.
- Expose a decoupled web frontend built with a modern framework (e.g., React, Next.js, Vue.js, Angular, Flask with templates).
- Expose a RESTful API to perform create/read/delete operations on URLs (per
openapi.yaml).- Include the ability to delete a shortened URL via the API.
- Have tests.
- Be containerised (e.g. Docker).
- Include instructions for running locally.