Table of Contents
- 02 — URL Shortener
- Overview
- Learning Objectives
- Requirements
- Architecture
- Suggested Project Layout
- Data Model / Database
- API Design
- Tech Stack
- Implementation Milestones
- Testing Strategy
- Deployment
- Documentation Deliverables
- Stretch Goals / Future Improvements
- Lessons-Learned Prompts
- Portfolio & Resume
02 — URL Shortener¶
Overview¶
A small but production-shaped HTTP REST service that turns long URLs into short,
shareable codes (e.g. https://sho.rt/aB3xK) and redirects visitors back to the
original destination. It is the second project in the Learn Go series and is
rated Beginner+: you already know variables, functions, structs, and slices,
and you are now ready to wire those pieces into a real server.
The headline lesson is dependency inversion via interfaces. We define a
single Store interface and implement it twice — first with an in-memory map,
then with SQLite — without touching a single HTTP handler when we swap them. By
the end you will viscerally understand why Go programmers reach for small
interfaces and hand-rolled dependency injection instead of frameworks.
The service exposes a JSON REST API to create, inspect, and delete links, plus a plain redirect endpoint for the short codes themselves. Short codes are produced with base62 encoding so they stay compact and URL-safe.
Learning Objectives¶
By completing this project you will be able to:
- Build an HTTP server with the standard library
net/httpand the Go 1.22http.ServeMuxmethod+wildcard routing patterns (POST /api/links,GET /{code}). - Design and consume a small
Storeinterface and inject implementations by hand (constructor functions), demonstrating dependency inversion. - Provide two interchangeable backends (in-memory map, SQLite) behind one interface and prove they are interchangeable with a shared test suite.
- Encode integers and random bytes into base62 short codes and reason about collisions, uniqueness, and length.
- Write idiomatic REST handlers: JSON decode/encode, correct status codes (201/302/404/409), and structured error responses.
- Test HTTP code with
net/http/httptest(NewRecorder,NewServer) and write table-driven tests, including an interface conformance suite run against everyStore. - Package the service as a single static binary in a multi-stage Docker image, configured entirely through environment variables.
Requirements¶
Functional¶
- Create short URL — Accept a JSON body
{ "url": "https://..." }, validate it, generate a unique short code, persist the mapping, and return201 Createdwith the code and full short URL. - Redirect —
GET /{code}looks up the long URL and responds with a302 Found(Location:header). Unknown codes return404 Not Found. - Collision handling — Generated codes must be unique. On a storage-level
uniqueness violation the service retries with a new code (random strategy) or
relies on the auto-increment id (sequential strategy). Custom aliases that
already exist return
409 Conflict. - Optional custom alias — The create request may include
"custom_alias": "my-link". If supplied and available, it becomes the code; if taken, return409 Conflict. Aliases are validated against a character whitelist[A-Za-z0-9_-]and a length bound. - Hit counter — Every successful redirect increments a per-link
Hitscounter. The stats endpoint exposes the current count. - Stats —
GET /api/links/{code}returns the link metadata (long URL, created time, hit count, expiry) as JSON. - Delete —
DELETE /api/links/{code}removes a link, returning204 No Content(or404if absent).
Non-Functional¶
- Single dependency-light binary — Standard library first; third-party packages only where they earn their place (SQLite driver).
- Storage-agnostic core — Handlers and service logic depend only on the
Storeinterface, never on a concrete backend. - Configurable —
PORTandDB_PATH(andBASE_URL) come from the environment with sane defaults. - Observable — Structured request logging via
log/slog; meaningful error messages in JSON. - Concurrency-safe — The in-memory store guards its map with a
sync.RWMutex; SQLite access is safe for concurrent handlers. - Tested — Core service and handler logic covered by table-driven tests; both stores pass an identical conformance suite.
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")]
sql[("SQLiteStore<br/>links table")]
client -->|"HTTP request"| mux
mux --> handlers
handlers --> svc
svc -->|"depends on interface only"| store
store -.implemented by.-> mem
store -.implemented by.-> sql
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 SQLiteStore both satisfy it. main.go
decides which concrete type to construct and injects it. Nothing above the
interface knows or cares which backend is running.
Suggested Project Layout¶
Follows the community golang-standards/project-layout conventions.
02-url-shortener/
├── cmd/
│ └── server/
│ └── main.go # wiring: read env, pick Store, start http.Server
├── internal/
│ ├── shortener/
│ │ ├── service.go # Service: Create/Resolve/Stats/Delete, base62
│ │ ├── base62.go # Encode(uint64) / Decode(string)
│ │ └── service_test.go
│ ├── store/
│ │ ├── store.go # the Store interface + Link type + errors
│ │ ├── memory.go # MemoryStore (map[string]*Link + RWMutex)
│ │ ├── sqlite.go # SQLiteStore (database/sql)
│ │ └── store_test.go # shared conformance suite for ALL stores
│ └── httpapi/
│ ├── router.go # NewRouter(*Service) *http.ServeMux
│ ├── handlers.go # JSON encode/decode, status codes
│ └── handlers_test.go # httptest-based handler tests
├── migrations/
│ └── 0001_init.sql # CREATE TABLE links ...
├── Dockerfile
├── go.mod
├── go.sum
└── README.md
internal/ keeps the packages unimportable from outside the module — a clear
signal that these are implementation details of this service.
Data Model / Database¶
The Link domain type¶
// internal/store/store.go
type Link struct {
Code string `json:"code"` // short code, e.g. "aB3xK"
LongURL string `json:"long_url"` // original destination
CreatedAt time.Time `json:"created_at"`
Hits int64 `json:"hits"` // redirect counter
ExpiresAt *time.Time `json:"expires_at,omitempty"` // optional TTL
}
The Store interface¶
type Store interface {
// Save persists a new link. Returns ErrCodeTaken if Code already exists.
Save(ctx context.Context, l *Link) error
// Get returns the link for a code, or ErrNotFound.
Get(ctx context.Context, code string) (*Link, error)
// IncrementHits atomically bumps the hit counter for a code.
IncrementHits(ctx context.Context, code string) error
// Delete removes a link, returning ErrNotFound if absent.
Delete(ctx context.Context, code string) error
// NextID returns a monotonic id, used by the base62 sequential strategy.
NextID(ctx context.Context) (uint64, error)
Close() error
}
var (
ErrNotFound = errors.New("link not found")
ErrCodeTaken = errors.New("code already taken")
)
In-memory map structure¶
// internal/store/memory.go
type MemoryStore struct {
mu sync.RWMutex
links map[string]*Link // key: code
nextID uint64 // monotonic counter for sequential base62
}
Reads take RLock, writes take Lock. Save checks for an existing key to
honour the ErrCodeTaken contract; IncrementHits mutates under the write lock.
SQLite schema¶
-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
long_url TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
hits INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_links_code ON links(code);
Base62 codes¶
Two viable strategies — pick one and document the trade-off in the README:
- Sequential: take the row's auto-increment
id(orNextID) andbase62.Encode(id). Codes are short, dense, and never collide, but they are enumerable (id 1 →b, id 1000 →g8). - Random: generate N random bytes (
crypto/rand), base62-encode to a fixed length (~7 chars ≈ 62^7 ≈ 3.5 trillion combos). Non-enumerable but requires a uniqueness check + retry loop onErrCodeTaken.
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" // 62 chars
func Encode(n uint64) string { /* repeated div/mod by 62 */ }
API Design¶
All JSON. Errors use a consistent envelope: { "error": "message" }.
POST /api/links — create a short link¶
Request:
POST /api/links
Content-Type: application/json
{
"url": "https://go.dev/doc/effective_go",
"custom_alias": "effgo"
}
Success — 201 Created:
{
"code": "effgo",
"short_url": "https://sho.rt/effgo",
"long_url": "https://go.dev/doc/effective_go",
"created_at": "2026-06-26T12:00:00Z",
"hits": 0
}
Status codes:
| Code | When |
|---|---|
201 Created |
Link created |
400 Bad Request |
Missing/invalid url, malformed JSON, bad alias chars |
409 Conflict |
custom_alias already in use |
GET /{code} — redirect¶
302 FoundwithLocation: https://go.dev/doc/effective_go, hit counter incremented.404 Not Foundif the code is unknown (or expired).
GET /api/links/{code} — stats¶
{
"code": "effgo",
"long_url": "https://go.dev/doc/effective_go",
"created_at": "2026-06-26T12:00:00Z",
"hits": 42,
"expires_at": null
}
200 OKon success,404 Not Foundotherwise.
DELETE /api/links/{code} — delete¶
204 No Contenton success.404 Not Foundif the code does not exist.
Routing (Go 1.22 ServeMux)¶
mux := http.NewServeMux()
mux.HandleFunc("POST /api/links", h.Create)
mux.HandleFunc("GET /api/links/{code}", h.Stats)
mux.HandleFunc("DELETE /api/links/{code}", h.Delete)
mux.HandleFunc("GET /{code}", h.Redirect) // keep last; catch-all for codes
Read path params with r.PathValue("code") — no third-party router required.
Tech Stack¶
- Language: Go 1.22+ (for method-aware
ServeMuxpatterns andr.PathValue). - HTTP: standard library
net/http;log/slogfor structured logs. - Routing: stdlib
http.ServeMuxusing Go 1.22 patterns. Optionally swap togo-chi/chilater for middleware ergonomics — a good exercise once the stdlib version works. - SQLite driver: prefer
modernc.org/sqlite(pure Go, no cgo, so the static-binary/Docker story stays trivial). Alternative:mattn/go-sqlite3(cgo, mature, faster) — note the cgo build implications. - Database access: stdlib
database/sqlso the SQLite store stays portable. - IDs/codes:
crypto/randfor random codes; custombase62package. - Testing:
testing,net/http/httptest.
Implementation Milestones¶
Phase 1 — In-memory, end to end
-
go mod init, project skeleton,cmd/server/main.gothat serves "ok". - Define
store.Storeinterface,Linktype, and sentinel errors. - Implement
base62.Encode/Decodewith unit tests. - Implement
MemoryStorewithRWMutex; satisfyStore. - Implement
shortener.Service(Create/Resolve/Stats/Delete) on the interface, with collision retry. - Implement
httpapihandlers + router; wireMemoryStoreinmain.go. - Manual
curlsmoke test of all four endpoints.
Phase 2 — Swap to SQLite behind the interface (no handler changes!)
- Add
migrations/0001_init.sqland a tiny migrate-on-start helper. - Implement
SQLiteStoreagainstdatabase/sql; satisfy the sameStoreinterface. - In
main.go, select the store fromDB_PATH(memory if unset) — the only file that changes. - Run the shared conformance suite against both stores; confirm green.
- Confirm handlers, service, and tests are byte-for-byte unchanged. This is the payoff moment.
Phase 3 — Polish
- Structured logging middleware, graceful shutdown (
http.Server.Shutdown). - Dockerfile (multi-stage) + README curl examples.
- Optional: link expiry (
ExpiresAt),go-chirefactor.
Testing Strategy¶
- Base62 unit tests — round-trip
Decode(Encode(n)) == nacross a range and edge values (0, 1, large ids). - Shared
Storeconformance suite — a table-driven test instore_test.gothat takes aStorefactory and exercises Save/Get/Increment/ Delete plus theErrNotFound/ErrCodeTakencontracts. Run it once forMemoryStoreand once for a temp-fileSQLiteStore. Identical behaviour from both proves the interface is honest.
func runStoreSuite(t *testing.T, newStore func(t *testing.T) store.Store) {
t.Run("save and get", func(t *testing.T) { /* ... */ })
t.Run("duplicate code -> ErrCodeTaken", func(t *testing.T) { /* ... */ })
t.Run("missing code -> ErrNotFound", func(t *testing.T) { /* ... */ })
}
- Handler tests —
httptest.NewRecorder+http.NewRequestfor fast, in-process assertions on status codes, headers (Location), and JSON bodies. Drive the handlers with aMemoryStorefor speed. - End-to-end — a couple of
httptest.NewServertests that exercise the full router (create → redirect → stats) over a real socket withhttp.Client. - Table-driven everywhere — one test function, many
{name, input, want}cases; assert with subtests so failures pinpoint the case. - Run
go test ./... -raceto catch data races in the in-memory store.
Deployment¶
- Single binary —
CGO_ENABLED=0 go build -o server ./cmd/server. Using the pure-Gomodernc.org/sqlitedriver keepsCGO_ENABLED=0viable, yielding a fully static binary. - Configuration (env vars):
| Var | Default | Purpose |
|---|---|---|
PORT |
8080 |
Listen port |
DB_PATH |
(empty → memory) | SQLite file path; empty selects MemoryStore |
BASE_URL |
http://localhost:8080 |
Prefix for returned short_url |
- Dockerfile (multi-stage):
FROM golang:1.22 AS build
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
ENV PORT=8080 DB_PATH=/data/links.db
EXPOSE 8080
VOLUME ["/data"]
ENTRYPOINT ["/server"]
- Hosting — Deploy to fly.io (
fly launch, mount a volume at/datafor the SQLite file) or Render (Docker service + persistent disk). SetBASE_URLto your real hostname so short URLs resolve.
Documentation Deliverables¶
- README.md with quick-start,
go run/Docker instructions, env-var table, and copy-pastecurlexamples for every endpoint:
curl -s -X POST localhost:8080/api/links \
-H 'content-type: application/json' \
-d '{"url":"https://go.dev"}'
curl -si localhost:8080/abc # observe 302 + Location
curl -s localhost:8080/api/links/abc
curl -X DELETE localhost:8080/api/links/abc
- API reference — the endpoint/status-code tables from this spec, kept in sync (or generated as an OpenAPI sketch as a stretch goal).
- godoc — package-level doc comments on
shortener,store, andhttpapi; exported types and theStoreinterface fully documented. Verify withgo doc ./internal/.... - Architecture note — a short section explaining the
Storeinterface and why phase 2 required no handler changes.
Stretch Goals / Future Improvements¶
- Redis cache — read-through cache in front of the
Store(itself just anotherStoreimplementation that wraps a delegate — interfaces again!). - Rate limiting — per-IP token bucket middleware on
POST /api/links. - Analytics — record referrer, user-agent, and timestamp per hit; expose time-series stats.
- QR codes —
GET /api/links/{code}/qrreturning a PNG of the short URL. - Link expiry — honour
ExpiresAt, return410 Gone, sweep expired rows. - Custom domains — map tenant domains to link namespaces.
- Postgres backend — a third
Storeimplementation to drive the interface-swap lesson home.
Lessons-Learned Prompts¶
Reflect on these after finishing (great raw material for a blog post or README):
- When you swapped
MemoryStoreforSQLiteStore, 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 (a handful of methods) easier to satisfy with a second implementation than a large one? How did this shape your method set?
- How did the shared conformance test suite increase your confidence that the two stores are truly interchangeable? What behavioural contracts (errors, ordering) did it pin down?
- Compare the sequential vs random base62 strategies you considered. 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 would, and where did it start to strain?
Portfolio & Resume¶
Resume Bullets¶
- Built a production-shaped URL shortener in Go (stdlib
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
Storeinterface and swapped the backend from an in-memory map to SQLite with zero changes to handlers or business logic, demonstrating dependency inversion. - Achieved high confidence via table-driven
httptesthandler tests and a shared interface conformance suite validating every storage implementation; shipped as a static, CGO-free binary in a multi-stage distroless Docker image.
Interview Talking Points¶
- The
Storeinterface is the headline. Lead with it: handlers and the service depend only on a small interface, so the concrete backend is an injected detail decided inmain.go. This is dependency inversion in practice — explain how it made the SQLite migration a one-file change. - Interchangeability is proven, not asserted — describe the shared
conformance test suite run against both stores, and the error contracts
(
ErrNotFound,ErrCodeTaken) it locks in. - Idiomatic Go choices — standard library over frameworks,
internal/for encapsulation,RWMutexfor the in-memory concurrency story, and CGO-free SQLite for a trivial deployment. - Trade-off awareness — be ready to discuss sequential vs random base62 codes
(compactness vs enumerability) and where you'd add a Redis cache (as yet
another
Storedecorator) if traffic grew.