Step 1 — Set up the project
The module, the folders, and one route that proves it runs.
- Create the module and folder layout for the shortener.
- Write a JSON response helper and a main.go with one health route.
- Run the server and check it with curl.
Every command for this step
# 1. create the projectmkdir shortener && cd shortenergo mod init github.com/you/shortener# 2. folders and empty filesmkdir -p cmd/api internal/transport/webtouch cmd/api/main.go internal/transport/web/respond.go# 3. write the two files below, then# (go mod tidy adds and removes dependencies; see "Modules and tooling")go mod tidygo run ./cmd/api# 4. in a second terminalcurl -s localhost:8080/healthzcurl -si -X POST localhost:8080/healthz | head -1 # 405, for freeWhat this step gives you
By the end of this step you will have a running HTTP server that answers one route. No links and no database yet, only proof that the module, the folders and the server work.
Start every project this way. A server you can curl is a foundation; seven files written blind are a debugging session.
Create the module
The first two commands above create the folder and the module.
The module path is the import prefix for every package inside. Use your real repository path if you have one.
The folders you are starting with
File 1 — internal/transport/web/respond.go
Start with the helpers, because every handler you write afterwards will use them. Two functions: one writes any value as JSON, one writes an error in a consistent shape.
package webimport ( "encoding/json" "net/http")// JSON writes v as the response body with the given status code.func JSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) // headers first, then status, then body if v != nil { json.NewEncoder(w).Encode(v) }}// Error writes one consistent error shape, so clients never have to guess.func Error(w http.ResponseWriter, status int, msg string) { JSON(w, status, map[string]string{"error": msg})}File 2 — cmd/api/main.go
main.go registers one route and starts a server. main does nothing but call run() and exit on error. os.Exit skips deferred calls, so keeping it out of run means every defer you add there later still runs.
package mainimport ( "log/slog" "net/http" "os" "time" "github.com/you/shortener/internal/transport/web")func main() { if err := run(); err != nil { slog.Error("fatal", "err", err) os.Exit(1) // the only os.Exit in the program }}func run() error { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { web.JSON(w, 200, map[string]string{"status": "ok"}) }) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, } slog.Info("listening", "addr", srv.Addr) return srv.ListenAndServe()}Run it
go run ./cmd/api# in another terminalcurl -s localhost:8080/healthz# {"status":"ok"}http.Server are here from the first line rather than added later. The zero value means no timeout, so a client that sends its headers one byte at a time can hold a connection open for as long as it likes.- Get one route answering before you build anything else.
maincallsrun() errorand exits. There is oneos.Exit, outsiderun, so defers keep working.- Decide your JSON error shape once, in a helper, not in each handler.
- Set server timeouts from the start, not as a later fix.
/healthz. Then try POST /healthz and see that you get 405 without writing any code for it.- 1Add a
GET /versionroute that returns a hardcoded version string. - 2Remove the
Content-Typeheader fromJSONand see how curl and your browser react. - 3Change the port to come from the
PORTenvironment variable, with 8080 as the fallback.