Table of Contents
- 02 — URL Shortener
- Overview
- Demo
- Architecture
- Tech Stack
- Getting Started
- Project Layout
- API
- Testing Strategy
- Lessons Learned
- Future Improvements
- 🎒 Portfolio
02 — URL Shortener¶
A production-shaped HTTP service that turns long URLs into short, shareable codes and redirects visitors back to the original destination.
Overview¶
This service exposes a small JSON REST API to create, inspect, and delete short links, plus a plain redirect endpoint for the codes themselves. Short codes are produced with base62 so they stay compact and URL-safe.
The headline lesson is dependency inversion via a small interface. We define
a single store.Store interface and implement it
twice — an in-memory map and a JSON-file-backed store — without touching a
single HTTP handler when we swap them. See the full brief in the SPEC.
Note on dependencies. The SPEC's stretch backend is SQLite. To keep this project standard-library only (no third-party imports, no
go.modof its own — it shares the repo-root module), the second backend here is a JSON-file store instead of SQLite. The lesson is identical: two backends, one interface, zero handler changes.
Demo¶
# Create a short link
$ curl -s -X POST localhost:8080/api/links \
-H 'content-type: application/json' \
-d '{"url":"https://go.dev/doc/effective_go"}'
{
"code": "Qk3aZ1p",
"short_url": "http://localhost:8080/Qk3aZ1p",
"long_url": "https://go.dev/doc/effective_go",
"created_at": "2026-06-26T12:00:00Z",
"hits": 0
}
# Follow the redirect (note the 302 + Location header)
$ curl -si localhost:8080/Qk3aZ1p | head -n 2
HTTP/1.1 302 Found
Location: https://go.dev/doc/effective_go
# Inspect stats, then delete
$ curl -s localhost:8080/api/links/Qk3aZ1p
$ curl -s -X DELETE -o /dev/null -w '%{http_code}\n' localhost:8080/api/links/Qk3aZ1p
204
Architecture¶
flowchart TD
client["Client<br/>(curl / browser)"]
subgraph server["HTTP Server (cmd/server)"]
mux["http.ServeMux<br/>(Go 1.22 patterns)"]
handlers["httpapi handlers<br/>create / redirect / stats / delete"]
svc["shortener.Service<br/>base62 codes, validation, hit counts"]
store["Store interface<br/>(store.go)"]
end
mem[("MemoryStore<br/>map + RWMutex")]
file[("FileStore<br/>JSON file + atomic rename")]
client -->|"HTTP request"| mux
mux --> handlers
handlers --> svc
svc -->|"depends on interface only"| store
store -.implemented by.-> mem
store -.implemented by.-> file
classDef iface fill:#fdf6e3,stroke:#b58900,stroke-width:2px;
class store iface;
The crucial point: Service and the handlers hold a value of type
store.Store (the interface). MemoryStore and FileStore both satisfy it.
main.go is the only file that decides which concrete type to construct and
injects it. Nothing above the interface knows which backend is running — that is
the Dependency Inversion Principle in practice.
Tech Stack¶
Go 1.22 · net/http (ServeMux + r.PathValue) · log/slog · encoding/json
· os file persistence · crypto/rand · sync.RWMutex · testing /
net/http/httptest. Standard library only.
Getting Started¶
Prerequisites¶
- Go 1.22+ (required for method-aware
ServeMuxpatterns andr.PathValue).
Run¶
From this directory:
make run # in-memory store on :8080
make run-file # JSON-file store at ./data/links.json
# or directly:
go run ./cmd/server -addr :8080
go run ./cmd/server -addr :8080 -store-file ./data/links.json
From the repo root you can also run:
Configuration (flags override env defaults):
| Flag | Env | Default | Purpose |
|---|---|---|---|
-addr |
ADDR |
:8080 |
Listen address |
-base-url |
BASE_URL |
http://localhost<addr> |
Prefix for returned short_url |
-store-file |
STORE_FILE |
(empty → in-memory) | JSON store path; empty selects memory |
Test¶
make test # go test ./...
make test-race # go test -race ./... (proves the in-memory store is race-free)
make cover # go test -cover ./...
Project Layout¶
Follows the golang-standards/project-layout conventions.
02-url-shortener/
├── cmd/
│ └── server/
│ └── main.go # wiring: read flags/env, pick Store, start http.Server
├── internal/
│ ├── shortener/
│ │ ├── base62.go # Encode(uint64) / Decode(string) + Alphabet
│ │ ├── service.go # Service: Create/Resolve/Stats/Delete, validation
│ │ ├── base62_test.go
│ │ └── service_test.go
│ ├── store/
│ │ ├── store.go # the Store interface + Link type + sentinel errors
│ │ ├── memory.go # MemoryStore (map[string]*Link + RWMutex)
│ │ ├── file.go # FileStore (JSON file, atomic write)
│ │ └── store_test.go # shared conformance suite for ALL stores
│ └── httpapi/
│ ├── router.go # NewRouter(*Handler) + logging middleware
│ ├── handlers.go # JSON encode/decode, status codes, error envelope
│ └── handlers_test.go # httptest-based handler + end-to-end tests
├── Makefile
└── README.md
internal/ keeps these packages unimportable from outside the module — a clear
signal that they are implementation details of this service.
API¶
All JSON. Errors use a consistent envelope: { "error": "message" }.
| Method | Path | Description | Success | Errors |
|---|---|---|---|---|
POST |
/api/links |
Create a short link | 201 |
400, 409 |
GET |
/{code} |
Redirect to long URL | 302 |
404 |
GET |
/api/links/{code} |
Link stats (JSON) | 200 |
404 |
DELETE |
/api/links/{code} |
Delete a link | 204 |
404 |
GET |
/healthz |
Liveness probe | 200 |
— |
POST /api/links body: { "url": "https://...", "custom_alias": "optional",
"expires_at": "2026-12-31T00:00:00Z" }. The alias is validated against
[A-Za-z0-9_-] (1–64 chars); a taken alias returns 409 Conflict. Generated
codes use a crypto/rand strategy with collision retry against the store's
ErrCodeTaken contract.
Testing Strategy¶
- Base62 unit tests — round-trip
Decode(Encode(n)) == nacross edge values (0, 1, 61, 62,MaxUint64) plus decode-error cases. - Shared
Storeconformance suite — one table-driven suite instore_test.gois run against bothMemoryStoreand a temp-fileFileStore. Identical behaviour from both — including theErrNotFound/ErrCodeTakencontracts and "Get returns a copy" isolation — proves the interface is honest. A separate test reopens the file to verify persistence. - Service tests — deterministic clock and a scripted random reader exercise validation, custom-alias conflicts, the collision-retry loop, hit counting, and expiry.
- Handler + end-to-end tests —
httptest.NewRecorderfor fast in-process status/header/JSON assertions, andhttptest.NewServerto drive the full create → redirect flow over a real socket. - Run with
-raceto catch data races in the in-memory store.
Lessons Learned¶
Reflection prompts (great raw material for a blog post):
- When you swapped
MemoryStoreforFileStore, exactly which files changed — and why wasmain.gothe only one? What property of theStoreinterface made that possible? - How does depending on the
Storeinterface instead of a concrete type illustrate the Dependency Inversion Principle in Go specifically? - Why is a small interface (six methods) easier to satisfy with a second implementation than a large one? How did it shape your method set?
- How did the shared conformance suite increase your confidence that the two stores are truly interchangeable? Which behavioural contracts (error values, returned-copy isolation) did it pin down?
- Compare the sequential vs. random base62 strategies. How does each interact with the storage layer's uniqueness/collision handling?
- Where did hand-rolled dependency injection (constructor functions in
main) feel cleaner than a framework, and where did it start to strain?
Future Improvements¶
- SQLite/Postgres backend (a third
Storeto drive the lesson home). - Read-through cache as a
Storedecorator (interfaces again). - Per-IP token-bucket rate limiting on
POST /api/links. -
410 Gonefor expired links + a background sweeper. - QR-code endpoint
GET /api/links/{code}/qr.
🎒 Portfolio¶
Résumé bullets:
- "Built a production-shaped URL shortener in Go using only the standard library
(
net/http, Go 1.22ServeMuxrouting) exposing a JSON REST API with correct HTTP semantics (201/302/404/409) and base62 short codes." - "Designed a storage-agnostic core around a single six-method
Storeinterface and swapped the backend from an in-memory map to a JSON-file store with zero changes to handlers or business logic — dependency inversion in practice." - "Proved backend interchangeability with a shared conformance test suite run
against every implementation, plus table-driven
httptesthandler tests and a race-checked concurrent in-memory store."
Interview talking points:
- The
Storeinterface is the headline — handlers and the service depend only on a small interface, so the concrete backend is an injected detail decided inmain.go. The file-store migration was a one-file change. - Interchangeability is proven, not asserted — the shared conformance suite
locks in the
ErrNotFound/ErrCodeTakencontracts and returned-copy isolation for both backends. - Idiomatic Go choices — standard library over frameworks,
internal/for encapsulation,sync.RWMutexfor the in-memory concurrency story, atomic temp-file-and-rename for durable JSON persistence, and graceful shutdown viasignal.NotifyContext+http.Server.Shutdown. - Trade-off awareness — sequential vs. random base62 codes (compactness vs.
enumerability), and where a cache layer would slot in as yet another
Store.
⬅ Projects · Repo README