Skip to content

Table of Contents

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/http and the Go 1.22 http.ServeMux method+wildcard routing patterns (POST /api/links, GET /{code}).
  • Design and consume a small Store interface 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 every Store.
  • 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 return 201 Created with the code and full short URL.
  • RedirectGET /{code} looks up the long URL and responds with a 302 Found (Location: header). Unknown codes return 404 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, return 409 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 Hits counter. The stats endpoint exposes the current count.
  • StatsGET /api/links/{code} returns the link metadata (long URL, created time, hit count, expiry) as JSON.
  • DeleteDELETE /api/links/{code} removes a link, returning 204 No Content (or 404 if 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 Store interface, never on a concrete backend.
  • ConfigurablePORT and DB_PATH (and BASE_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

// 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 (or NextID) and base62.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 on ErrCodeTaken.
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

GET /effgo HTTP/1.1
  • 302 Found with Location: https://go.dev/doc/effective_go, hit counter incremented.
  • 404 Not Found if the code is unknown (or expired).
HTTP/1.1 302 Found
Location: https://go.dev/doc/effective_go

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 OK on success, 404 Not Found otherwise.

DELETE /api/links/{code} — delete

  • 204 No Content on success.
  • 404 Not Found if 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 ServeMux patterns and r.PathValue).
  • HTTP: standard library net/http; log/slog for structured logs.
  • Routing: stdlib http.ServeMux using Go 1.22 patterns. Optionally swap to go-chi/chi later 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/sql so the SQLite store stays portable.
  • IDs/codes: crypto/rand for random codes; custom base62 package.
  • Testing: testing, net/http/httptest.

Implementation Milestones

Phase 1 — In-memory, end to end

  • go mod init, project skeleton, cmd/server/main.go that serves "ok".
  • Define store.Store interface, Link type, and sentinel errors.
  • Implement base62.Encode/Decode with unit tests.
  • Implement MemoryStore with RWMutex; satisfy Store.
  • Implement shortener.Service (Create/Resolve/Stats/Delete) on the interface, with collision retry.
  • Implement httpapi handlers + router; wire MemoryStore in main.go.
  • Manual curl smoke test of all four endpoints.

Phase 2 — Swap to SQLite behind the interface (no handler changes!)

  • Add migrations/0001_init.sql and a tiny migrate-on-start helper.
  • Implement SQLiteStore against database/sql; satisfy the same Store interface.
  • In main.go, select the store from DB_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-chi refactor.

Testing Strategy

  • Base62 unit tests — round-trip Decode(Encode(n)) == n across a range and edge values (0, 1, large ids).
  • Shared Store conformance suite — a table-driven test in store_test.go that takes a Store factory and exercises Save/Get/Increment/ Delete plus the ErrNotFound/ErrCodeTaken contracts. Run it once for MemoryStore and once for a temp-file SQLiteStore. 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 testshttptest.NewRecorder + http.NewRequest for fast, in-process assertions on status codes, headers (Location), and JSON bodies. Drive the handlers with a MemoryStore for speed.
  • End-to-end — a couple of httptest.NewServer tests that exercise the full router (create → redirect → stats) over a real socket with http.Client.
  • Table-driven everywhere — one test function, many {name, input, want} cases; assert with subtests so failures pinpoint the case.
  • Run go test ./... -race to catch data races in the in-memory store.

Deployment

  • Single binaryCGO_ENABLED=0 go build -o server ./cmd/server. Using the pure-Go modernc.org/sqlite driver keeps CGO_ENABLED=0 viable, 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 /data for the SQLite file) or Render (Docker service + persistent disk). Set BASE_URL to your real hostname so short URLs resolve.

Documentation Deliverables

  • README.md with quick-start, go run/Docker instructions, env-var table, and copy-paste curl examples 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, and httpapi; exported types and the Store interface fully documented. Verify with go doc ./internal/....
  • Architecture note — a short section explaining the Store interface and why phase 2 required no handler changes.

Stretch Goals / Future Improvements

  • Redis cache — read-through cache in front of the Store (itself just another Store implementation 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 codesGET /api/links/{code}/qr returning a PNG of the short URL.
  • Link expiry — honour ExpiresAt, return 410 Gone, sweep expired rows.
  • Custom domains — map tenant domains to link namespaces.
  • Postgres backend — a third Store implementation to drive the interface-swap lesson home.

Lessons-Learned Prompts

Reflect on these after finishing (great raw material for a blog post or README):

  1. When you swapped MemoryStore for SQLiteStore, exactly which files changed — and why was main.go the only one? What property of the Store interface made that possible?
  2. How does depending on the Store interface instead of a concrete type illustrate the Dependency Inversion Principle in Go specifically?
  3. 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?
  4. 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?
  5. Compare the sequential vs random base62 strategies you considered. How does each interact with the storage layer's uniqueness/collision handling?
  6. 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.22 ServeMux routing) 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 Store interface 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 httptest handler 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 Store interface 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 in main.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, RWMutex for 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 Store decorator) if traffic grew.