Building It

Wire handlers, a service and a store into one app struct, with main as the only place that names concrete types.

Syntaxa := &app{svc: svc}; mux.HandleFunc("POST /notes", a.handleCreate)

Building the service is wiring, not invention. Each piece has its own lesson behind it. The work is connecting them in one direction, in one place.

Three layers, one direction

A handler speaks HTTP and nothing else: it reads the request, then writes a status and a body. A service holds the rules, such as a note needing text that is not blank. A store runs the SQL. Handlers call the service. The service calls the store. Nothing calls upwards. The service never imports net/http and the handler never writes SQL. That separation is what lets you test the rules with no server running, and put a fake store under them in a test.

Dependencies in one struct

Handlers need the service, the logger and the config. Put those in a single struct, conventionally called app, and write the handlers as methods on it. Each handler then reaches what it needs through its receiver, so there are no package-level variables to collide over. main becomes the only function that names concrete types.

The order in main

Load the config, build the logger, open the database pool, ping it, run the migrations, build the store, build the service, build the app, assemble the routes, wrap them in middleware, start an http.Server with timeouts, then wait for a signal and shut down. Any step that fails should stop the process with a message naming the step.

What trips people

Decoding the request body straight into the domain struct lets a client set the id or the created time. Decode into a small request type holding only the fields a client may send, and map errors to status codes in one function rather than in every handler.

Example

Example · go
// Command notes wires the three layers: a store, a service that holds the
// rules, and a handler that only speaks HTTP. Against a real server:
// curl -i -X POST localhost:8080/notes -d '{"text":"milk"}'
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

type Note struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
}

// Store is all the service knows about storage. The Postgres store and the
// one below both satisfy it.
type Store interface {
	Create(text string) (Note, error)
}

type memStore struct{ n int }

func (m *memStore) Create(text string) (Note, error) {
	m.n++
	return Note{ID: m.n, Text: text}, nil
}

// service holds the rules and never mentions HTTP.
type service struct{ store Store }

func (s *service) Add(text string) (Note, error) {
	if strings.TrimSpace(text) == "" {
		return Note{}, errors.New("text must not be blank")
	}
	return s.store.Create(text)
}

// app carries every dependency the handlers use.
type app struct{ svc *service }

func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) {
	// A request type, not Note: a client cannot set the id this way.
	var in struct {
		Text string `json:"text"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "invalid json", http.StatusBadRequest)
		return
	}
	n, err := a.svc.Add(in.Text)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	if err := json.NewEncoder(w).Encode(n); err != nil {
		return // the client went away; nothing left to tell it
	}
}

func main() {
	// main is the only place that names concrete types.
	a := &app{svc: &service{store: &memStore{}}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /notes", a.handleCreate)

	req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(`{"text":"milk"}`))
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	fmt.Println(rec.Code)
	fmt.Print(rec.Body.String())
}

// Output:
// 201
// {"id":1,"text":"milk"}

When to use it

  • Add a second endpoint without touching the database code, because the rules and the SQL live behind one interface.
  • Swap Postgres for an in-memory store in tests by passing a different value into the same service.
  • Give every handler the logger and config it needs without a single package-level variable.

More examples

The Postgres store behind the same interface

This is the real store the service runs against in production, satisfying the same interface the fake one does. Two details matter. RETURNING asks Postgres for the generated id and timestamp in the same round trip as the insert, so there is no second query and no guessing. And sql.ErrNoRows is turned into the package's own ErrNotFound: the handler then decides on 404 by calling errors.Is on an error it owns, instead of knowing which database the service uses. Every method takes a context so a cancelled request cancels the query.

Example · go
// internal/store/postgres.go
package store

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"
)

// ErrNotFound is the store's own error, so no layer above it needs to
// import database/sql to understand what happened.
var ErrNotFound = errors.New("note not found")

type Note struct {
	ID      string
	Text    string
	Created time.Time
}

type Postgres struct{ db *sql.DB }

func New(db *sql.DB) *Postgres { return &Postgres{db: db} }

// Create inserts one row and reads back what the database generated.
func (p *Postgres) Create(ctx context.Context, text string) (Note, error) {
	const q = `INSERT INTO notes (body) VALUES ($1)
	           RETURNING id, body, created_at`
	var n Note
	if err := p.db.QueryRowContext(ctx, q, text).Scan(&n.ID, &n.Text, &n.Created); err != nil {
		return Note{}, fmt.Errorf("create note: %w", err)
	}
	return n, nil
}

func (p *Postgres) Get(ctx context.Context, id string) (Note, error) {
	const q = `SELECT id, body, created_at FROM notes WHERE id = $1`
	var n Note
	err := p.db.QueryRowContext(ctx, q, id).Scan(&n.ID, &n.Text, &n.Created)
	if errors.Is(err, sql.ErrNoRows) {
		return Note{}, ErrNotFound // translated, not leaked
	}
	if err != nil {
		return Note{}, fmt.Errorf("get note %s: %w", id, err)
	}
	return n, nil
}

main, in dependency order

The order is the whole point: nothing is built before the things it needs, and every failure exits with a message naming the step. sql.Open only prepares a pool, so PingContext is what proves the address and password are right; without it a wrong DATABASE_URL produces a healthy-looking startup and a failure on the first request. Migrations run before the server listens, so no request can arrive against a half-built schema. Reach for this file shape in any service: one function, top to bottom, no init functions and no globals.

Example · go
// cmd/api/main.go
func main() {
	cfg, err := config.Load(os.Getenv)
	if err != nil {
		fmt.Fprintln(os.Stderr, "config:", err)
		os.Exit(1) // a missing setting is a startup failure, not a 500 later
	}
	log := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	db, err := sql.Open("pgx", cfg.DatabaseURL) // does not connect yet
	if err != nil {
		log.Error("open database", "err", err)
		os.Exit(1)
	}
	defer db.Close()
	db.SetMaxOpenConns(cfg.MaxConns)

	// This deadline covers startup only. Handing the same context to the
	// server would shut the service down five seconds after it started.
	startCtx, cancelStart := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelStart()
	if err := db.PingContext(startCtx); err != nil { // first real connection
		log.Error("database unreachable", "err", err)
		os.Exit(1)
	}
	if err := migrate.Up(startCtx, db); err != nil {
		log.Error("migrate", "err", err)
		os.Exit(1)
	}

	a := &app{svc: service.New(store.New(db)), log: log, cfg: cfg}
	srv := &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           a.routes(),
		ReadHeaderTimeout: 5 * time.Second,
		WriteTimeout:      30 * time.Second,
	}
	// The server's context ends on a signal, never on a timer.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := run(ctx, srv, log); err != nil { // listen, then graceful shutdown
		log.Error("server", "err", err)
		os.Exit(1)
	}
}

The middleware chain, long and short

Both forms produce the same handler; the nested call is just the assignments collapsed into one expression. The longhand version is worth writing while the order still surprises you, because the order is the part people get wrong: a panic recovery wrapper placed inside the logger never sees the panic, and a logger placed outside the request-id wrapper logs lines with no id. Use this when a service grows past two middlewares, and keep the comment that says which end runs first.

Example · go
func (a *app) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", a.handleHealthz)
	mux.HandleFunc("POST /notes", a.handleCreate)
	mux.HandleFunc("GET /notes/{id}", a.handleGet)

	// Longhand: each wrapper applied on its own line, innermost first.
	var h http.Handler = mux
	h = a.logRequest(h)
	h = a.requestID(h)
	h = a.recoverPanic(h)
	return h

	// Shorthand for exactly the same chain, written as nested calls:
	//   return a.recoverPanic(a.requestID(a.logRequest(mux)))
	// Read it outside-in. recoverPanic sees every request first, so it is
	// the one that can still answer 500 after a handler panics, and
	// requestID runs before logRequest so the log line has an id to print.
}

Discussion

  • Be the first to comment on this lesson.