snip backend
Go service for the snip URL shortener. Hexagonal (ports & adapters) with constructor-based dependency injection, Postgres for source of truth, Redis as the redirect hot-path cache.
One binary, three commands
cmd/snip builds a single binary with three subcommands so one Docker image can
back three docker-compose services:
| Command | Default port | Serves |
|---|---|---|
api |
8080 |
JSON API under /api/v1/* (links CRUD, PIN, OAuth/OIDC login) |
frontend |
8081 |
The built SPA — owns /, /login, /dashboard + assets |
redirect |
8082 |
GET /{code} → 302, plus the fast self-contained enter-PIN page |
PORT=8080 ./snip api
PORT=8081 ./snip frontend
PORT=8082 ./snip redirect
In production a gateway routes by path (/api/*→api, app routes→frontend, the
rest→redirect). See the repo-root Caddyfile + docker-compose.yml.
Architecture (hexagon)
cmd/snip entrypoint: parses subcommand, graceful shutdown
internal/
domain/ entities + sentinel errors (no deps)
port/ interfaces the core depends on (the hexagon edges)
service/ application core — depends only on ports
link_service.go create/list/update/delete/pin
redirect_service.go cache-first resolve + pin verify (hot path)
clicks.go batched async click recorder
auth_service.go OAuth/OIDC login + sessions
shortcode.go base62 codes, validation, reserved-path guard
adapter/ implementations of the ports
memory/ in-memory (tests + STORE=memory)
postgres/ pgxpool repos + advisory-locked migrations
rediscache/ go-redis resolution cache
identity/ OIDC provider (covers Google + generic OIDC)
security/ bcrypt hasher + HMAC session tokens
httpx/ inbound HTTP adapters (api / frontend / redirect)
config/ env config
di/ wires adapters → services per command
The core (service) imports only port and domain; nothing in it knows
about Postgres, Redis, HTTP or OAuth. di is the only package that imports
concrete adapters.
Performance design
The redirect path is the hot one and is built to avoid Postgres:
GET /{code}readscode:{code}from Redis. On a hit it 302s immediately — no DB.- On a miss it reads Postgres once, then caches the tiny resolution
(
{longURL, hasPin, pinHash}) with a 24h TTL. - Unknown codes are negatively cached for 60s so scanners can't hammer the DB.
- PINs are verified against the cached bcrypt hash — protected links also skip the DB.
- Clicks never block the redirect: they're counted in memory and flushed to
click_daily(+links.total_clicks) in batches (CLICK_FLUSH_SECONDS, default 10), collapsing bursts into one write.
Two login methods
Both google and a generic oidc provider implement the same
IdentityProvider port (Google is itself an OIDC issuer). Each is optional —
absent credentials simply hide that button.
GET /api/v1/auth/{provider}/login→ 302 to the providerGET /api/v1/auth/{provider}/callback→ sets an HttpOnly session cookie, 302s to/dashboardGET /api/v1/auth/me,POST /api/v1/auth/logout,GET /api/v1/auth/providers
Sessions are stateless HMAC-signed tokens (no server store). State is validated with a double-submit cookie.
Reserved paths
Custom aliases can't collide with app-owned paths — api, login, dashboard,
assets, healthz, etc. are rejected at creation (service.ReservedCodes).
Configuration (env)
| Var | Default | Notes |
|---|---|---|
STORE |
postgres |
or memory (no infra) |
DATABASE_URL |
postgres://snip:snip@localhost:5432/snip?sslmode=disable |
|
REDIS_ADDR / REDIS_PASSWORD / REDIS_DB |
localhost:6379 / `` / 0 |
|
SESSION_SECRET |
dev default | set in prod |
PUBLIC_URL |
http://localhost:8080 |
builds OAuth callback URLs |
POST_LOGIN_REDIRECT |
/dashboard |
|
SHORT_DOMAIN |
snip.to |
display host on the PIN page |
FRONTEND_DIST |
./web |
built SPA directory (frontend cmd) |
COOKIE_SECURE |
false |
set true behind HTTPS |
CLICK_FLUSH_SECONDS |
10 |
click batch interval |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
— | enables Google login |
OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET |
— | enables OIDC login |
Develop & test
go test ./... # unit + HTTP integration tests (in-memory, no infra)
STORE=memory PORT=8080 go run ./cmd/snip api # run with zero infrastructure
The test suite uses the in-memory adapters, so it needs neither Postgres nor Redis. The real adapters are exercised via docker-compose.