Skip to content

Table of Contents

02 — URL Shortener

A production-shaped HTTP service that turns long URLs into short, shareable codes and redirects visitors back to the original destination.

status Go deps tests

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.mod of 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 ServeMux patterns and r.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:

go run ./projects/02-url-shortener/cmd/server

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)) == n across edge values (0, 1, 61, 62, MaxUint64) plus decode-error cases.
  • Shared Store conformance suite — one table-driven suite in store_test.go is run against both MemoryStore and a temp-file FileStore. Identical behaviour from both — including the ErrNotFound / ErrCodeTaken contracts 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 testshttptest.NewRecorder for fast in-process status/header/JSON assertions, and httptest.NewServer to drive the full create → redirect flow over a real socket.
  • Run with -race to catch data races in the in-memory store.

Lessons Learned

Reflection prompts (great raw material for a blog post):

  1. When you swapped MemoryStore for FileStore, 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 (six methods) easier to satisfy with a second implementation than a large one? How did it shape your method set?
  4. 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?
  5. Compare the sequential vs. random base62 strategies. 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, and where did it start to strain?

Future Improvements

  • SQLite/Postgres backend (a third Store to drive the lesson home).
  • Read-through cache as a Store decorator (interfaces again).
  • Per-IP token-bucket rate limiting on POST /api/links.
  • 410 Gone for 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.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 six-method Store interface 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 httptest handler tests and a race-checked concurrent in-memory store."

Interview talking points:

  • The Store interface is the headline — handlers and the service depend only on a small interface, so the concrete backend is an injected detail decided in main.go. The file-store migration was a one-file change.
  • Interchangeability is proven, not asserted — the shared conformance suite locks in the ErrNotFound / ErrCodeTaken contracts and returned-copy isolation for both backends.
  • Idiomatic Go choices — standard library over frameworks, internal/ for encapsulation, sync.RWMutex for the in-memory concurrency story, atomic temp-file-and-rename for durable JSON persistence, and graceful shutdown via signal.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