Part ten
A whole service, file by file
The cookbook was five jobs in isolation. This is the repository they add up to — one small service, built in the order you would actually build it, with every file that has to exist before anyone will let you deploy it. The domain is a link shortener, chosen because it is small enough to hold in your head and still needs every production concern there is: validation, a database and a migration, idempotency under a race, authentication, rate limiting, structured logs, two health checks, tests that run in CI, and an image with nothing in it but your binary. It is the same service the Python and Java pages build, so you can read all three and see what the language actually changes — and here it changes a lot, because most of what those two reach for a framework to do, Go does with the standard library.
Go deep · 29
Where the files go, and why
Go has one layout rule the compiler actually enforces, and it is the useful one: nothing outside this module may import anything under internal/. That single directory name does the job Java needs an architecture test for. What it does not do is police the layers inside the module, which is what the rest of this topic is about.
shortlink/
go.mod the module path, the Go version, the direct deps
go.sum the hashes. Committed, and verified on every build.
Makefile the four commands everyone needs, named the same way
compose.yaml Postgres and Redis for local work, nothing else
Dockerfile two stages; the second one contains a binary
.golangci.yaml the linters, and the import rule from the table below
cmd/
shortlinkd/
main.go twelve lines. Calls run, prints the error, exits 1.
run.go wiring: config, pool, redis, routes, signals
internal/ the compiler's own boundary. Nothing outside imports it.
config/
config.go every variable the service reads, parsed once
shortlink/ the domain. Imports the standard library only.
link.go Link, Code, and the URL parse that is a security control
code.go crypto/rand over an alphabet with no confusable pairs
service.go the use cases. Knows no HTTP and no SQL.
errors.go the sentinels every layer above matches on
postgres/
pool.go one pool per process, sized on purpose
store.go the only file in the repository that contains SQL
migrate.go goose, over migrations embedded in the binary
migrations/
0001_create_links.sql
ratelimit/
redis.go one INCR and one PEXPIRE, atomically
httpapi/
server.go routes, middleware, and the five timeouts
links.go the management handlers
redirect.go GET /{code}: the only public route
auth.go API keys, hashed, compared in constant time
problem.go one error shape out, and the map that produces it
middleware.go request id, logging, recovery
internal/shortlink/code_test.go pure, fast, no container
internal/httpapi/problem_test.go every sentinel has a status
internal/postgres/store_test.go a real Postgres, and the race
internal/httpapi/api_test.go through the real mux, over httptest
.github/workflows/ci.yaml
Two decisions in that tree are worth defending in a review. There is no pkg/ — it is a convention some repositories carry and it means nothing to the toolchain, whereas internal/ means something exact. And the packages are named for what they are rather than for what they contain: postgres, not repository; httpapi, not handlers. A Go package name is a prefix on every identifier you export from it, so postgres.Store reads and repository.LinkRepository stutters.
| Package | May import | Must never import |
internal/shortlink | The standard library, and nothing else | pgx, net/http, redis. It is the part whose tests need no container. |
internal/postgres | shortlink, pgx | net/http. A store that writes a status code cannot be called from a worker. |
internal/httpapi | Everything | Nothing — but it should hold no branching that is not about HTTP. |
internal/config | The standard library | Everything else. It is imported by cmd, not by the layers. |
cmd/shortlinkd | All of the above | Nothing. It is the only place that knows the whole graph. |
The compiler will not check those rows for you, so a linter does. depguard is the direct equivalent of the rule Java needs ArchUnit for, and it costs eight lines:
linters:
enable:
- depguard
- errcheck
- errorlint
- bodyclose
- contextcheck
- sqlclosecheck
- gosec
- revive
linters-settings:
depguard:
rules:
domain:
files: ["**/internal/shortlink/**"]
deny:
- pkg: github.com/jackc/pgx
desc: the domain does not know a database exists
- pkg: net/http
desc: the domain does not know it is being served over HTTP
store:
files: ["**/internal/postgres/**"]
deny:
- pkg: net/http
desc: a store that writes status codes cannot be used by a worker
module github.com/jaspreet/shortlink
go 1.24
require (
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
github.com/jackc/pgx/v5 v5.7.1
github.com/pressly/goose/v3 v3.22.1
github.com/redis/go-redis/v9 v9.7.0
github.com/testcontainers/testcontainers-go v0.34.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0
golang.org/x/sync v0.10.0
)
.PHONY: up check test run image
up:
docker compose up -d
check:
go build ./...
go vet ./...
golangci-lint run
test:
go test -race -count=1 ./...
run: up
go run ./cmd/shortlinkd
image:
docker build -t shortlink:$(shell git rev-parse --short HEAD) .
-race is the single highest-value flag in the language, and it is off by default
The race detector instruments every memory access and reports the two goroutines, with stacks, the moment one writes what another reads without synchronisation. It costs perhaps five times the CPU and ten times the memory, which is why it is not on in production — and it is why go test -race belongs in CI on every package, every run. A data race in Go is not a hypothetical: an unsynchronised map write is a runtime crash that takes the whole process down, and it will happen for the first time under load, in production, in the middle of the night. The detector only reports races it actually observes, so it is exactly as good as your test coverage of concurrent paths — which is one more reason the idempotency test later in this part starts sixteen goroutines rather than two.
Go deep · 30
Configuration, read once, and every error at once
A service reads its environment exactly once, on the way up, into a struct. The failure you are designing against is the mistyped variable discovered on the tenth request instead of in the first second — and Go lets you go one better than the other two languages here, because errors.Join means one restart tells you everything that is wrong rather than the first thing.
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"time"
)
type Config struct {
Addr string
DatabaseURL string
RedisURL string
PublicBaseURL *url.URL
CodeLength int
DefaultTTL time.Duration
RateLimit int
RateWindow time.Duration
}
func Load() (Config, error) {
var errs []error
cfg := Config{
Addr: optional("SHORTLINK_ADDR", ":8080"),
DatabaseURL: required(&errs, "DATABASE_URL"),
RedisURL: required(&errs, "REDIS_URL"),
CodeLength: integer(&errs, "SHORTLINK_CODE_LENGTH", 7),
DefaultTTL: duration(&errs, "SHORTLINK_DEFAULT_TTL", 90*24*time.Hour),
RateLimit: integer(&errs, "SHORTLINK_RATE_LIMIT", 60),
RateWindow: duration(&errs, "SHORTLINK_RATE_WINDOW", time.Minute),
}
if raw := required(&errs, "SHORTLINK_PUBLIC_BASE_URL"); raw != "" {
u, err := url.Parse(raw)
switch {
case err != nil:
errs = append(errs, fmt.Errorf("SHORTLINK_PUBLIC_BASE_URL: %w", err))
case u.Scheme == "" || u.Host == "":
errs = append(errs, errors.New("SHORTLINK_PUBLIC_BASE_URL: want an absolute URL"))
default:
cfg.PublicBaseURL = u
}
}
if cfg.CodeLength < 4 || cfg.CodeLength > 32 {
errs = append(errs, fmt.Errorf("SHORTLINK_CODE_LENGTH: want 4..32, got %d", cfg.CodeLength))
}
return cfg, errors.Join(errs...)
}
func optional(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func required(errs *[]error, key string) string {
v := os.Getenv(key)
if v == "" {
*errs = append(*errs, fmt.Errorf("%s is required", key))
}
return v
}
func integer(errs *[]error, key string, fallback int) int {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil {
*errs = append(*errs, fmt.Errorf("%s: %w", key, err))
return fallback
}
return n
}
func duration(errs *[]error, key string, fallback time.Duration) time.Duration {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
d, err := time.ParseDuration(raw)
if err != nil {
*errs = append(*errs, fmt.Errorf("%s: %w", key, err))
return fallback
}
return d
}
Note which settings have a fallback and which do not. A port has a sensible default and a database connection string does not, because a default DSN is how a service quietly connects to something that happens to be listening. The rule generalises: anything that is a secret, or that names another system, has no default. Everything else may have one, and the default should be the value you would pick in production.
$ DATABASE_URL= REDIS_URL= go run ./cmd/shortlinkd
shortlinkd: load config: DATABASE_URL is required
REDIS_URL is required
SHORTLINK_PUBLIC_BASE_URL is required
$ echo $?
1
# Three seconds after the deploy started, and all three named at once.
# errors.Join prints its members one per line, which is exactly what you
# want a start-up failure to look like in a log aggregator.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: shortlink
POSTGRES_PASSWORD: local-only-not-a-secret
POSTGRES_DB: shortlink
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shortlink"]
interval: 2s
timeout: 2s
retries: 15
redis:
image: redis:7-alpine
ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 2s
retries: 15
Go deep · 31
The core, which imports only the standard library
This package knows nothing about HTTP, SQL or Redis, which is why its tests run in milliseconds and why the rules survive the day somebody swaps the database. It is also where Go differs most honestly from the other two languages in this series: Go cannot make an invalid value unconstructable, so the discipline is one parse function at the edge, a re-check in the domain, and a constraint in the database.
package shortlink
import (
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
type Code string
func (c Code) String() string { return string(c) }
var codeShape = regexp.MustCompile(`^[a-z0-9]{4,32}$`)
func ParseCode(raw string) (Code, error) {
c := strings.ToLower(strings.TrimSpace(raw))
if !codeShape.MatchString(c) {
return "", fmt.Errorf("%w: %q is not a short code", ErrInvalid, raw)
}
return Code(c), nil
}
type Link struct {
Code Code
Target *url.URL
OwnerID string
CreatedAt time.Time
ExpiresAt time.Time
}
func (l Link) Expired(now time.Time) bool {
return !l.ExpiresAt.IsZero() && !now.Before(l.ExpiresAt)
}
const maxTargetLen = 2048
func ParseTarget(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
switch {
case raw == "":
return nil, fmt.Errorf("%w: url is required", ErrInvalid)
case len(raw) > maxTargetLen:
return nil, fmt.Errorf("%w: url longer than %d bytes", ErrInvalid, maxTargetLen)
}
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalid, err)
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return nil, fmt.Errorf("%w: only http and https can be shortened", ErrInvalid)
}
if u.Host == "" {
return nil, fmt.Errorf("%w: url has no host", ErrInvalid)
}
if u.User != nil {
return nil, fmt.Errorf("%w: credentials in a url are not stored", ErrInvalid)
}
return u, nil
}
package shortlink
import (
"crypto/rand"
"fmt"
)
const alphabet = "23456789abcdefghijkmnpqrstuvwxyz"
func NewCode(n int) (Code, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("read random bytes: %w", err)
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return Code(b), nil
}
package shortlink
import "errors"
var (
ErrInvalid = errors.New("invalid request")
ErrNotFound = errors.New("link not found")
ErrExpired = errors.New("link expired")
ErrCodeTaken = errors.New("code already in use")
ErrIdempotencyClash = errors.New("concurrent request with the same idempotency key")
ErrNoCode = errors.New("no free code")
)
var All = []error{
ErrInvalid, ErrNotFound, ErrExpired,
ErrCodeTaken, ErrIdempotencyClash, ErrNoCode,
}
Validating the host is not the same as blocking a private address
It is tempting to add a check here that refuses 127.0.0.1, 10.0.0.0/8 and 169.254.169.254, and for a service that ever fetches the target — to unfurl a preview, to check the link is alive — you must, or your shortener is a proxy into your own VPC with the cloud metadata endpoint one request away. But do it at fetch time, against the resolved IP, not here against the hostname: a name that resolves to a public address when the link is created can resolve to a private one when it is followed, and no check written at creation time can see that. In Go the tool for it is a net.Dialer with a Control function that inspects the address the resolver actually returned and refuses to connect. This service only ever hands the URL back to a browser, so the parse above is the whole control — and the day somebody adds a preview fetcher is the day this note becomes code.
Go deep · 32
The schema, the pool, and migrations inside the binary
Three things with three different lifetimes: the schema, which the migrations own and the code is only allowed to assume; the pool, which is created once per process and sized on purpose; and the connection, which belongs to one query and is handed back. Go makes the third one easy to get wrong in exactly one way, and the linter in the last topic exists to catch it.
CREATE TABLE links (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL,
target_url TEXT NOT NULL,
owner_id TEXT NOT NULL,
idempotency_key TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
CONSTRAINT links_code_key UNIQUE (code)
);
CREATE UNIQUE INDEX links_owner_idempotency_key
ON links (owner_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX links_owner_created_at ON links (owner_id, created_at DESC);
CREATE TABLE api_keys (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id TEXT NOT NULL,
label TEXT NOT NULL,
key_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
CONSTRAINT api_keys_key_hash_key UNIQUE (key_hash)
);
DROP TABLE api_keys;
DROP TABLE links;
package postgres
import (
"context"
"database/sql"
"embed"
"fmt"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
var migrations embed.FS
func Migrate(ctx context.Context, dsn string) error {
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open for migrate: %w", err)
}
defer db.Close()
goose.SetBaseFS(migrations)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose dialect: %w", err)
}
if err := goose.UpContext(ctx, db, "migrations"); err != nil {
return fmt.Errorf("migrate: %w", err)
}
return nil
}
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 2
cfg.MaxConnLifetime = 25 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.HealthCheckPeriod = 30 * time.Second
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return pool, nil
}
Go deep · 33
The store, and the race you cannot check for
Two requests arrive in the same millisecond with the same Idempotency-Key. Both look for an existing link, both find none, both insert. Reading before writing does not fix that and no amount of application code can, because the gap between the read and the write is where the other request lives. The database closes it. What Go contributes is that closing it costs one errors.As and no transaction gymnastics at all.
package postgres
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jaspreet/shortlink/internal/shortlink"
)
const (
codeUnique = "links_code_key"
idempotencyUnique = "links_owner_idempotency_key"
)
type Store struct{ pool *pgxpool.Pool }
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Insert(ctx context.Context, l shortlink.Link, idempotencyKey string) error {
const q = `
INSERT INTO links (code, target_url, owner_id, idempotency_key, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`
_, err := s.pool.Exec(ctx, q,
string(l.Code), l.Target.String(), l.OwnerID,
nullable(idempotencyKey), l.CreatedAt, l.ExpiresAt)
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
switch pgErr.ConstraintName {
case codeUnique:
return shortlink.ErrCodeTaken
case idempotencyUnique:
return shortlink.ErrIdempotencyClash
}
}
return fmt.Errorf("insert link: %w", err)
}
func (s *Store) ByCode(ctx context.Context, code shortlink.Code) (shortlink.Link, error) {
const q = `
SELECT code, target_url, owner_id, created_at, expires_at
FROM links WHERE code = $1`
return s.one(ctx, q, string(code))
}
func (s *Store) ByIdempotencyKey(ctx context.Context, ownerID, key string) (shortlink.Link, error) {
const q = `
SELECT code, target_url, owner_id, created_at, expires_at
FROM links WHERE owner_id = $1 AND idempotency_key = $2`
return s.one(ctx, q, ownerID, key)
}
func (s *Store) DeleteOwned(ctx context.Context, ownerID string, code shortlink.Code) error {
const q = `DELETE FROM links WHERE code = $1 AND owner_id = $2`
tag, err := s.pool.Exec(ctx, q, string(code), ownerID)
if err != nil {
return fmt.Errorf("delete link: %w", err)
}
if tag.RowsAffected() == 0 {
return shortlink.ErrNotFound
}
return nil
}
func (s *Store) one(ctx context.Context, q string, args ...any) (shortlink.Link, error) {
var (
l shortlink.Link
code string
raw string
exp *time.Time
)
err := s.pool.QueryRow(ctx, q, args...).
Scan(&code, &raw, &l.OwnerID, &l.CreatedAt, &exp)
switch {
case errors.Is(err, pgx.ErrNoRows):
return shortlink.Link{}, shortlink.ErrNotFound
case err != nil:
return shortlink.Link{}, fmt.Errorf("query link: %w", err)
}
target, err := url.Parse(raw)
if err != nil {
return shortlink.Link{}, fmt.Errorf("stored url for %s: %w", code, err)
}
l.Code = shortlink.Code(code)
l.Target = target
if exp != nil {
l.ExpiresAt = *exp
}
return l, nil
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}
Go had nothing to undo here, and that is the whole difference
The Java version of this file needs a separate bean, annotated REQUIRES_NEW, purely so that the failed insert does not poison a transaction the caller was in the middle of. Go has no ambient transaction: pool.Exec outside a Begin is its own implicit transaction, so a unique violation rolls back exactly one statement and the caller simply tries a different code. The trap has not gone away, it has become visible — the moment this Exec is inside a pgx.Tx, Postgres marks that transaction aborted and every later statement in it fails with current transaction is aborted until you roll back. The fix there is a savepoint (tx.Begin nested inside a transaction is one) or doing the insert outside the transaction, which is what this code does by default rather than by accident.
package shortlink
import (
"context"
"errors"
"fmt"
"net/url"
"time"
)
type Store interface {
Insert(ctx context.Context, l Link, idempotencyKey string) error
ByCode(ctx context.Context, code Code) (Link, error)
ByIdempotencyKey(ctx context.Context, ownerID, key string) (Link, error)
DeleteOwned(ctx context.Context, ownerID string, code Code) error
}
const maxCodeAttempts = 5
type Service struct {
store Store
now func() time.Time
codeLength int
defaultTTL time.Duration
}
func NewService(store Store, now func() time.Time, codeLength int, ttl time.Duration) *Service {
return &Service{store: store, now: now, codeLength: codeLength, defaultTTL: ttl}
}
type NewLink struct {
OwnerID string
Target *url.URL
Alias Code
TTL time.Duration
IdempotencyKey string
}
func (s *Service) Shorten(ctx context.Context, req NewLink) (Link, error) {
if req.IdempotencyKey != "" {
switch existing, err := s.store.ByIdempotencyKey(ctx, req.OwnerID, req.IdempotencyKey); {
case err == nil:
return existing, nil
case !errors.Is(err, ErrNotFound):
return Link{}, err
}
}
now := s.now()
ttl := req.TTL
if ttl == 0 {
ttl = s.defaultTTL
}
for attempt := 0; attempt < maxCodeAttempts; attempt++ {
code := req.Alias
if code == "" {
generated, err := NewCode(s.codeLength)
if err != nil {
return Link{}, err
}
code = generated
}
link := Link{
Code: code, Target: req.Target, OwnerID: req.OwnerID,
CreatedAt: now, ExpiresAt: now.Add(ttl),
}
err := s.store.Insert(ctx, link, req.IdempotencyKey)
switch {
case err == nil:
return link, nil
case errors.Is(err, ErrIdempotencyClash):
return s.store.ByIdempotencyKey(ctx, req.OwnerID, req.IdempotencyKey)
case errors.Is(err, ErrCodeTaken) && req.Alias != "":
return Link{}, fmt.Errorf("%w: %s", ErrCodeTaken, code)
case errors.Is(err, ErrCodeTaken):
continue
default:
return Link{}, err
}
}
return Link{}, ErrNoCode
}
func (s *Service) Resolve(ctx context.Context, code Code) (Link, error) {
link, err := s.store.ByCode(ctx, code)
if err != nil {
return Link{}, err
}
if link.Expired(s.now()) {
return Link{}, ErrExpired
}
return link, nil
}
func (s *Service) Delete(ctx context.Context, ownerID string, code Code) error {
return s.store.DeleteOwned(ctx, ownerID, code)
}
Go deep · 34
The HTTP edge, and the twelve lines of main
Since Go 1.22 the standard ServeMux routes on method and on path parameters, which removed the last honest reason to import a router. What is left is a handler set that does only HTTP — decode, call, encode, choose a status — and a run function that owns every lifetime in the process.
package httpapi
import (
"context"
"log/slog"
"net"
"net/http"
"net/url"
"time"
"github.com/jaspreet/shortlink/internal/shortlink"
)
type Server struct {
links *shortlink.Service
keys KeyStore
limiter Limiter
base *url.URL
log *slog.Logger
ready func(ctx context.Context) error
}
type Deps struct {
Links *shortlink.Service
Keys KeyStore
Limiter Limiter
BaseURL *url.URL
Log *slog.Logger
Ready func(ctx context.Context) error
}
func New(d Deps) *Server {
return &Server{
links: d.Links, keys: d.Keys, limiter: d.Limiter,
base: d.BaseURL, log: d.Log, ready: d.Ready,
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("POST /v1/links", s.authenticate(http.HandlerFunc(s.createLink)))
mux.Handle("GET /v1/links/{code}", s.authenticate(http.HandlerFunc(s.readLink)))
mux.Handle("DELETE /v1/links/{code}", s.authenticate(http.HandlerFunc(s.deleteLink)))
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
mux.HandleFunc("GET /{code}", s.redirect)
return requestID(logging(s.log, recovery(s.log, mux)))
}
func NewHTTPServer(ctx context.Context, addr string, h http.Handler, log *slog.Logger) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError),
BaseContext: func(net.Listener) context.Context { return ctx },
}
}
package httpapi
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/jaspreet/shortlink/internal/shortlink"
)
type createRequest struct {
URL string `json:"url"`
Alias string `json:"alias,omitempty"`
TTLDays int `json:"ttl_days,omitempty"`
}
type linkResponse struct {
Code string `json:"code"`
ShortURL string `json:"short_url"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func (s *Server) createLink(w http.ResponseWriter, r *http.Request) {
caller, _ := callerFrom(r.Context())
var body createRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
s.problem(w, r, invalid(err))
return
}
target, err := shortlink.ParseTarget(body.URL)
if err != nil {
s.problem(w, r, err)
return
}
req := shortlink.NewLink{
OwnerID: caller.OwnerID,
Target: target,
TTL: time.Duration(body.TTLDays) * 24 * time.Hour,
IdempotencyKey: r.Header.Get("Idempotency-Key"),
}
if body.Alias != "" {
alias, err := shortlink.ParseCode(body.Alias)
if err != nil {
s.problem(w, r, err)
return
}
req.Alias = alias
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Second)
defer cancel()
link, err := s.links.Shorten(ctx, req)
if err != nil {
s.problem(w, r, err)
return
}
short := s.base.JoinPath(link.Code.String())
w.Header().Set("Location", short.String())
s.writeJSON(w, http.StatusCreated, s.response(link))
}
func (s *Server) readLink(w http.ResponseWriter, r *http.Request) {
caller, _ := callerFrom(r.Context())
code, err := shortlink.ParseCode(r.PathValue("code"))
if err != nil {
s.problem(w, r, err)
return
}
link, err := s.links.Resolve(r.Context(), code)
if err != nil || link.OwnerID != caller.OwnerID {
s.problem(w, r, orNotFound(err))
return
}
s.writeJSON(w, http.StatusOK, s.response(link))
}
func (s *Server) deleteLink(w http.ResponseWriter, r *http.Request) {
caller, _ := callerFrom(r.Context())
code, err := shortlink.ParseCode(r.PathValue("code"))
if err != nil {
s.problem(w, r, err)
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Second)
defer cancel()
if err := s.links.Delete(ctx, caller.OwnerID, code); err != nil {
s.problem(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) response(l shortlink.Link) linkResponse {
out := linkResponse{
Code: l.Code.String(),
ShortURL: s.base.JoinPath(l.Code.String()).String(),
URL: l.Target.String(),
CreatedAt: l.CreatedAt,
}
if !l.ExpiresAt.IsZero() {
out.ExpiresAt = &l.ExpiresAt
}
return out
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, v any) {
buf, err := json.Marshal(v)
if err != nil {
s.log.Error("marshal response", "error", err)
http.Error(w, `{"title":"Internal server error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(buf)
}
package httpapi
import (
"net/http"
"github.com/jaspreet/shortlink/internal/shortlink"
)
func (s *Server) redirect(w http.ResponseWriter, r *http.Request) {
code, err := shortlink.ParseCode(r.PathValue("code"))
if err != nil {
s.problem(w, r, shortlink.ErrNotFound)
return
}
link, err := s.links.Resolve(r.Context(), code)
if err != nil {
s.problem(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, link.Target.String(), http.StatusFound)
}
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "shortlinkd: %v\n", err)
os.Exit(1)
}
}
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/redis/go-redis/v9"
"github.com/jaspreet/shortlink/internal/config"
"github.com/jaspreet/shortlink/internal/httpapi"
"github.com/jaspreet/shortlink/internal/postgres"
"github.com/jaspreet/shortlink/internal/ratelimit"
"github.com/jaspreet/shortlink/internal/shortlink"
)
func run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
log := slog.New(httpapi.ContextHandler{
Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
}).With("service", "shortlink")
slog.SetDefault(log)
if err := postgres.Migrate(ctx, cfg.DatabaseURL); err != nil {
return err
}
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
redisOpts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
return fmt.Errorf("parse redis url: %w", err)
}
rdb := redis.NewClient(redisOpts)
defer rdb.Close()
store := postgres.NewStore(pool)
svc := shortlink.NewService(store, time.Now, cfg.CodeLength, cfg.DefaultTTL)
api := httpapi.New(httpapi.Deps{
Links: svc,
Keys: postgres.NewKeyStore(pool),
Limiter: ratelimit.NewRedis(rdb, cfg.RateLimit, cfg.RateWindow),
BaseURL: cfg.PublicBaseURL,
Log: log,
Ready: func(ctx context.Context) error {
if err := pool.Ping(ctx); err != nil {
return fmt.Errorf("postgres: %w", err)
}
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("redis: %w", err)
}
return nil
},
})
srv := httpapi.NewHTTPServer(ctx, cfg.Addr, api.Handler(), log)
serverFailed := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Addr)
serverFailed <- srv.ListenAndServe()
}()
select {
case err := <-serverFailed:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
case <-ctx.Done():
log.Info("signal received, draining")
}
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Info("stopped cleanly")
return nil
}
The order of the deferred closes is the order of the dependencies, backwards
defer pool.Close() runs after defer rdb.Close(), which runs after srv.Shutdown returns, because deferred calls run last-in first-out. That is exactly right and it is free: the pool was created before Redis, Redis before the server, so the teardown unwinds in the reverse order without anybody maintaining a list. Get it wrong — close the pool before the server drains — and every request still in flight gets use of closed connection instead of an answer, on every single deploy. This is the one place where Go’s least fashionable feature quietly does a job that other languages need a lifecycle framework and an annotation for.
Go deep · 35
Failure is part of the contract
A service has two APIs and only one of them is documented. The other is what happens when things go wrong, and it is the one clients actually integrate against: which statuses mean retry, which mean stop, and whether the body parses when it is not a 200. The taxonomy part said an error should carry a decision; this is that idea with a status code on the end of it.
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/jaspreet/shortlink/internal/shortlink"
)
type problemDetail struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
}
const problemBase = "https://study.jaspreet.info/problems/"
var catalogue = []struct {
err error
status int
slug string
title string
}{
{shortlink.ErrInvalid, http.StatusBadRequest, "invalid-request", "Invalid request"},
{shortlink.ErrNotFound, http.StatusNotFound, "link-not-found", "Link not found"},
{shortlink.ErrExpired, http.StatusGone, "link-expired", "Link expired"},
{shortlink.ErrCodeTaken, http.StatusConflict, "code-taken", "Code taken"},
{shortlink.ErrIdempotencyClash, http.StatusConflict, "in-flight", "Request in flight"},
{shortlink.ErrNoCode, http.StatusServiceUnavailable, "no-code", "Try again"},
}
func (s *Server) problem(w http.ResponseWriter, r *http.Request, err error) {
for _, entry := range catalogue {
if errors.Is(err, entry.err) {
write(w, problemDetail{
Type: problemBase + entry.slug,
Title: entry.title,
Status: entry.status,
Detail: err.Error(),
})
return
}
}
s.log.ErrorContext(r.Context(), "unhandled error",
"error", err, "method", r.Method, "path", r.URL.Path)
write(w, problemDetail{
Type: problemBase + "internal",
Title: "Internal server error",
Status: http.StatusInternalServerError,
})
}
func write(w http.ResponseWriter, p problemDetail) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(p.Status)
_ = json.NewEncoder(w).Encode(p)
}
func invalid(err error) error {
return fmt.Errorf("%w: %s", shortlink.ErrInvalid, err)
}
func orNotFound(err error) error {
if err == nil {
return shortlink.ErrNotFound
}
return err
}
$ curl -sS -i -X POST localhost:8080/v1/links \
-H 'Authorization: Bearer sl_live_notarealkey' \
-H 'Content-Type: application/json' \
-d '{"url":"javascript:alert(1)"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://study.jaspreet.info/problems/invalid-request",
"title":"Invalid request","status":400,
"detail":"invalid request: only http and https can be shortened"}
# a typo in a field name, because DisallowUnknownFields is on:
$ curl -sS -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/links \
-H 'Authorization: Bearer sl_live_notarealkey' \
-d '{"ur1":"https://example.com"}'
400
# and the one every client actually codes against:
$ curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/gone4evr
410
errors.Is, never ==, and the linter that knows the difference
Every error in this service is wrapped with %w on the way up, which is what makes the message useful — insert link: invalid request: url has no host tells you where as well as what. It also means err == shortlink.ErrInvalid is false for every one of them, because the value you are holding is a *fmt.wrapError with the sentinel somewhere inside it. errors.Is unwraps until it finds a match, errors.As does the same for a concrete type, and errorlint in the golangci config fails the build on a bare comparison to an error value. This is the single most common Go bug that gets through review, because the broken version works right up until somebody adds context to an error three layers down.
Go deep · 36
Who is calling, and how often
Two controls that fail in opposite directions. Authentication decides whether a request happens at all, so it must be impossible to skip — which is why it wraps the handler rather than being a call at the top of one. Rate limiting decides how much of you a caller gets, so it must be shared across every replica — which is why it is in Redis and not in a map behind a mutex.
package httpapi
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/jaspreet/shortlink/internal/shortlink"
)
type APIKey struct {
OwnerID string
Label string
Digest string
}
type KeyStore interface {
ByDigest(ctx context.Context, digest string) (APIKey, error)
}
type Limiter interface {
Allow(ctx context.Context, ownerID string) (bool, error)
}
type callerKey struct{}
func withCaller(ctx context.Context, k APIKey) context.Context {
return context.WithValue(ctx, callerKey{}, k)
}
func callerFrom(ctx context.Context) (APIKey, bool) {
k, ok := ctx.Value(callerKey{}).(APIKey)
return k, ok
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
presented, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || presented == "" {
s.unauthorized(w)
return
}
sum := sha256.Sum256([]byte(presented))
digest := hex.EncodeToString(sum[:])
key, err := s.keys.ByDigest(r.Context(), digest)
if err != nil {
if !errors.Is(err, shortlink.ErrNotFound) {
s.log.ErrorContext(r.Context(), "key lookup failed", "error", err)
}
s.unauthorized(w)
return
}
if subtle.ConstantTimeCompare([]byte(key.Digest), []byte(digest)) != 1 {
s.unauthorized(w)
return
}
allowed, err := s.limiter.Allow(r.Context(), key.OwnerID)
if err != nil {
s.log.WarnContext(r.Context(), "rate limiter unavailable", "error", err)
} else if !allowed {
s.tooManyRequests(w)
return
}
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), key)))
})
}
func (s *Server) unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", "Bearer")
write(w, problemDetail{
Type: problemBase + "unauthorized", Title: "Unauthorized",
Status: http.StatusUnauthorized, Detail: "a valid API key is required",
})
}
func (s *Server) tooManyRequests(w http.ResponseWriter) {
w.Header().Set("Retry-After", "60")
write(w, problemDetail{
Type: problemBase + "rate-limited", Title: "Too many requests",
Status: http.StatusTooManyRequests, Detail: "the per-minute quota for this key is spent",
})
}
postgres.KeyStore is four lines of the same shape as the store above — one SELECT owner_id, label, key_hash FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL, one Scan, pgx.ErrNoRows mapped to shortlink.ErrNotFound. Worth noticing what is not there: no cache. One indexed lookup per request is a fraction of a millisecond, and a cache is a place for a revoked key to keep working. Add one when a profile says to, with a short TTL, and know that you have chosen a revocation delay.
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var increment = redis.NewScript(`
local hits = redis.call('INCR', KEYS[1])
if hits == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return hits
`)
type Redis struct {
client *redis.Client
permitted int
window time.Duration
}
func NewRedis(client *redis.Client, permitted int, window time.Duration) *Redis {
return &Redis{client: client, permitted: permitted, window: window}
}
func (r *Redis) Allow(ctx context.Context, ownerID string) (bool, error) {
bucket := time.Now().UnixMilli() / r.window.Milliseconds()
key := fmt.Sprintf("rl:%s:%d", ownerID, bucket)
ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
defer cancel()
hits, err := increment.Run(ctx, r.client, []string{key}, r.window.Milliseconds()).Int64()
if err != nil {
return false, fmt.Errorf("rate limit: %w", err)
}
return hits <= int64(r.permitted), nil
}
SHA-256 for an API key, bcrypt for a password — and swapping them is a bug both ways
A password is a low-entropy secret a human chose, so the defence against a stolen table is to make each guess expensive: bcrypt, scrypt or argon2, deliberately slow, salted per row. An API key is 256 bits out of crypto/rand; there is no dictionary to run against it and no amount of GPU that gets through the space, so a plain SHA-256 is enough. It is also necessary: put bcrypt on this path and you have added a hundred milliseconds of CPU to every authenticated request, which at any real rate is a fleet of pods spent hashing. Fast hash for high-entropy secrets, slow hash for human-chosen ones, and be able to say why in one sentence — this gets asked constantly.
The other half is the key’s life: generate it with crypto/rand, show the plaintext exactly once at creation, store only the digest, and give every key a revoked_at so revocation is an update you can audit rather than a delete you cannot. A prefix like sl_live_ costs nothing and lets a secret scanner find it in a public repository before an attacker does.
Failing open is a decision, and it has to be made on purpose
The middleware above logs and continues when Redis is unreachable, which means a Redis outage becomes unlimited traffic rather than a total outage. That is the right trade for this service and the wrong one for a limiter protecting something that genuinely cannot absorb the load — a payment endpoint, a third-party quota you are billed for, a login path being credential-stuffed. Whichever you choose, choose it in the code with a comment saying so, because the version that gets chosen by accident is always fail-open, and it is discovered during the incident.
Go deep · 37
Logs, and the two health checks
The question you are building for is “a customer says their request failed at 14:32, what happened”, answerable from a laptop in under a minute. That needs every log line from one request to carry the same id — and Go’s answer, unusually, is better than the thread-local one the other two languages use, because it is the context you were already passing.
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"regexp"
"time"
)
type requestIDKey struct{}
type ContextHandler struct{ slog.Handler }
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
var safeID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if !safeID.MatchString(id) {
var b [16]byte
_, _ = rand.Read(b[:])
id = hex.EncodeToString(b[:])
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
func logging(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.InfoContext(r.Context(), "request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
func recovery(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
switch {
case v == nil:
return
case v == http.ErrAbortHandler:
panic(v)
default:
log.ErrorContext(r.Context(), "panic in handler", "value", v, "path", r.URL.Path)
write(w, problemDetail{
Type: problemBase + "internal", Title: "Internal server error",
Status: http.StatusInternalServerError,
})
}
}()
next.ServeHTTP(w, r)
})
}
package httpapi
import (
"context"
"net/http"
"time"
)
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.ready(ctx); err != nil {
s.log.WarnContext(ctx, "not ready", "error", err)
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"unavailable"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ready"}`))
}
{
"time": "2026-08-31T14:32:07.418Z",
"level": "WARN",
"msg": "rate limiter unavailable",
"service": "shortlink",
"error": "rate limit: dial tcp 10.0.3.9:6379: i/o timeout",
"request_id": "6f1c2a943d554c1e9f0a2b7e6d4a1c33"
}
That is slog.NewJSONHandler wrapped in the twelve-line ContextHandler above, and nothing else — no logging library, no encoder configuration, no init function. request_id is there because the middleware put it in the context and the handler read it back out; service is there because run called .With("service", "shortlink") once at start-up, which is the cheap way to tag every line a process will ever emit.
Go deep · 38
Tests that would actually have caught it
Three kinds, and each buys something different. The table-driven ones run in microseconds and cover the parsing that is really a security control. The catalogue test buys back, at the cost of one slice, the exhaustiveness a sealed type would have given you. And one test starts a real Postgres in a container, because the single guarantee this service makes is a guarantee about a unique index, and no fake has one.
package shortlink_test
import (
"errors"
"testing"
"github.com/jaspreet/shortlink/internal/shortlink"
)
func TestParseTarget(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want error
}{
{"plain https", "https://example.com/a/b?c=d", nil},
{"plain http", "http://example.com", nil},
{"trims", " https://example.com ", nil},
{"empty", "", shortlink.ErrInvalid},
{"javascript", "javascript:alert(1)", shortlink.ErrInvalid},
{"data uri", "data:text/html,<script>x</script>", shortlink.ErrInvalid},
{"file", "file:///etc/passwd", shortlink.ErrInvalid},
{"relative", "/just/a/path", shortlink.ErrInvalid},
{"no host", "https://", shortlink.ErrInvalid},
{"credentials", "https://user:pass@example.com", shortlink.ErrInvalid},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := shortlink.ParseTarget(tc.in)
if !errors.Is(err, tc.want) {
t.Fatalf("ParseTarget(%q) error = %v, want %v", tc.in, err, tc.want)
}
})
}
}
package httpapi
import (
"errors"
"testing"
"github.com/jaspreet/shortlink/internal/shortlink"
)
func TestEverySentinelHasAStatus(t *testing.T) {
for _, sentinel := range shortlink.All {
found := false
for _, entry := range catalogue {
if errors.Is(sentinel, entry.err) {
found = true
break
}
}
if !found {
t.Errorf("%v has no entry in the problem catalogue: it would reach a client as a 500", sentinel)
}
}
}
package postgres_test
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"golang.org/x/sync/errgroup"
"github.com/jaspreet/shortlink/internal/postgres"
"github.com/jaspreet/shortlink/internal/shortlink"
)
var dsn string
func TestMain(m *testing.M) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, "postgres:16-alpine",
tcpostgres.WithDatabase("shortlink"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second)),
)
if err != nil {
panic(err)
}
if dsn, err = container.ConnectionString(ctx, "sslmode=disable"); err != nil {
panic(err)
}
if err := postgres.Migrate(ctx, dsn); err != nil {
panic(err)
}
code := m.Run()
_ = testcontainers.TerminateContainer(container)
os.Exit(code)
}
func TestSameIdempotencyKeyCreatesOneLink(t *testing.T) {
ctx := context.Background()
svc := newService(t)
target, err := shortlink.ParseTarget("https://example.com/one")
if err != nil {
t.Fatal(err)
}
req := shortlink.NewLink{
OwnerID: "owner-1", Target: target, IdempotencyKey: "key-42",
}
var (
mu sync.Mutex
codes = map[shortlink.Code]int{}
)
release := make(chan struct{})
var g errgroup.Group
for range 16 {
g.Go(func() error {
<-release
link, err := svc.Shorten(ctx, req)
if err != nil {
return err
}
mu.Lock()
defer mu.Unlock()
codes[link.Code]++
return nil
})
}
close(release)
if err := g.Wait(); err != nil {
t.Fatalf("a caller failed instead of sharing the link: %v", err)
}
if len(codes) != 1 {
t.Fatalf("want every caller to get one code, got %d distinct: %v", len(codes), codes)
}
if got := countRows(t, ctx); got != 1 {
t.Fatalf("want 1 row in links, got %d", got)
}
}
The fourth test file in the tree, httpapi/api_test.go, is the one that ties the rest together: httptest.NewServer(api.Handler()), a real http.Client, and assertions about status codes and Content-Type. It is worth having separately from the store tests because it is the only place the middleware chain, the routing patterns and the problem shape are exercised together — and because httptest makes it cost nothing: no port to pick, no server to shut down, and it runs in the same process so a failure gives you the handler’s stack rather than a connection reset.
The fake and the container are answering different questions, so keep both
shortlink.Store is a four-method interface, so a fake is fifteen lines and the service’s retry logic can be tested against it in microseconds — including the paths that are awkward to provoke for real, like “the generator collided four times running”. What the fake cannot do is have a unique index, so it will happily let sixteen concurrent inserts through and tell you the code is correct. Use the fake for the branches and the container for the guarantee, and be suspicious of any test suite that has only one of the two: all fakes means the thing you actually promised is untested, all containers means a four-minute suite that nobody runs before pushing.
Go deep · 39
The image, the pipeline, and two runtime settings
Go’s deployment story is the best of the three languages in this series and it is almost entirely down to one property: the output is a single static binary with no runtime to install, so the runtime image can contain that binary and nothing else. What is left to get right is two environment settings that Go historically gets wrong inside a container, and both are one line.
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/shortlinkd ./cmd/shortlinkd
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/shortlinkd /shortlinkd
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/shortlinkd"]
Two settings the Go runtime does not read from the container, and both cost you real money
GOMAXPROCS. For most of Go’s life the runtime sized its scheduler from the number of CPUs it could see on the host, not from the cgroup CPU quota the container was given. On a 64-core node with a 0.5-CPU limit that means 64 scheduler threads and 64 garbage-collector workers fighting for half a core, and the symptom is latency that gets worse when you scale the node up. go.uber.org/automaxprocs is the standard fix — a blank import that reads the cgroup and sets the value — and recent Go versions have started doing this in the runtime itself, so check what your version does rather than assuming either way. Whichever it is, log the value at start-up; it is one line and it settles the argument.
GOMEMLIMIT. Go’s collector runs when the heap has doubled, which means a service that is idle for ten minutes and then allocates hard can sail past the container’s memory limit before a collection was due — and the kernel’s OOM killer does not negotiate. GOMEMLIMIT is a soft limit: set it to about 90% of the container limit and the collector works harder as you approach it instead of the process being killed with no stack trace and no log line. It is one environment variable and it converts a hard crash into a CPU cost.
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- run: go build ./...
- run: go vet ./...
- uses: golangci/golangci-lint-action@v6
with:
version: v1.62
- run: go test -race -count=1 ./...
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
- name: Build the image
run: docker build -t shortlink:${{ github.sha }} --build-arg VERSION=${{ github.sha }} .
| Before it is “done” | Why it is on this list |
| Migrations run as their own step, not on every start | run calls Migrate above, which is right for one replica and wrong for four. goose takes an advisory lock so the other three wait rather than corrupt anything — but they wait through their readiness deadline. Move it to a job before the rollout. |
/readyz on the readiness probe, /healthz on the liveness probe | They answer different questions. Pointing both at the one that checks Postgres restarts every replica the next time Postgres fails over. |
GOMEMLIMIT set, and the memory request equal to the limit | See the note above. A Go process killed by the OOM killer leaves no log line to find later. |
pprof served on a second listener, not the public one | The profiling part covered what each profile answers. net/http/pprof registers on http.DefaultServeMux the moment it is imported, so importing it and serving DefaultServeMux publishes a heap dump of your process to the internet. |
| Logs to stdout, and nowhere else | The platform collects stdout. A log file inside a container is a disk that fills and an incident nobody can explain. |
govulncheck on every build, not on a calendar | It is call-graph aware, so it is one of the few security gates whose output is short enough that people keep reading it. |
| Alerts on the 500 rate and on p99, not just dashboards | A dashboard is for after somebody noticed. An alert is for before. |
| A rollback that is one command, and that somebody has run | Every deploy plan has one. The ones that work were rehearsed on a Tuesday afternoon rather than discovered at 2am. |
Read this part beside the Python and Java ones and the differences are smaller than the languages suggest. All three put the rules in a package that imports nothing, make idempotency a database constraint rather than a check, keep one error shape, hash the API key, share the rate limit, and fail the boot rather than the tenth request. What Go changes is the amount of machinery in between. There is no container, no proxy, no annotation and no reflection: run is the dependency graph, defer is the lifecycle, context carries the deadline and the request id that the other two need a framework and a thread-local for, and the deployable artefact is one file. What that costs is the guarantees a richer type system was giving you — Code("") compiles, the error switch is not exhaustive, and the layering is a linter rather than a compiler — so each of those is bought back here with a test. That trade, and being able to name both halves of it, is the answer to give when somebody asks why you would choose Go for a service.