Files
go-simple-api/lessons/lesson-01-project-skeleton-chi-routing.md
2026-07-16 10:13:46 +03:30

12 KiB

Lesson 1 — Project Skeleton & chi Routing

New Go concepts in this lesson: packages/imports/modules, structs, pointers, interfaces (implicitly satisfied), goroutines. If any of those feel shaky, review 00-go-basics-3-interfaces-errors-concurrency-packages.md first.

What we're building

By the end of this lesson you'll have a real, runnable HTTP server with:

  • A standard Go project layout you'll keep extending for the rest of the course
  • A router (using the chi library) that maps URLs to handler functions
  • A /health endpoint that returns JSON
  • A graceful shutdown sequence — the server finishes in-flight requests before exiting on Ctrl+C, instead of just dying mid-request

Project structure (final shape, built up over the whole course)

go-simple-api/
├── cmd/api/main.go
├── internal/
│   ├── config/config.go
│   ├── handlers/health.go
│   └── router/router.go
├── go.mod

(More folders get added lesson by lesson — this is just what exists after Lesson 1.)

Setup

mkdir go-simple-api && cd go-simple-api
go mod init git.hamidsoltani.com/hamid/go-simple-api
go get github.com/go-chi/chi/v5@latest

A quick word on that module path: git.hamidsoltani.com/hamid/go-simple-api isn't a real, fetchable URL — it's just a naming convention (commonly your Git host + username + project name). It becomes the prefix for every internal import in this project, e.g. git.hamidsoltani.com/hamid/go-simple-api/internal/config. If you're following along with your own project, use your own path here — just stay consistent with it everywhere.

go get github.com/go-chi/chi/v5@latest downloads chi, a small, popular HTTP router for Go. Why use a router library instead of the standard library's own http.ServeMux? chi gives us URL parameters (/users/{id}), route groups, and a large ecosystem of compatible middleware (rate limiting, CORS, request logging) that we'll use throughout this course — the standard library's router is fine for very simple cases but doesn't have these built in.

internal/config/config.go

package config

import "os"

type Config struct {
	Port string
}

func Load() Config {
	return Config{
		Port: getEnv("PORT", "8080"),
	}
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

Line by line:

  • package config — its own package, so both main.go and any future file can import it and call config.Load().
  • type Config struct { Port string } — a plain struct holding settings. We'll add many more fields to this over the course (database settings, Redis settings, OAuth settings...) — this is the ONE place all of the app's configuration lives.
  • func Load() Config — returns a Config by value (not a pointer) since it's small and, once built, nothing needs to mutate it in place.
  • getEnv is unexported (lowercase — see Go Basics Part 2 on capitalization) — nothing outside this file needs to call it directly. os.Getenv(key) reads an environment variable; if it's empty (unset), we return fallback instead. This is how you avoid hardcoding things like ports directly in your code.

internal/handlers/health.go

package handlers

import (
	"encoding/json"
	"net/http"
)

func Health(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{
		"status": "ok",
	})
}
  • Every HTTP handler in Go (with or without chi) has this exact function signature: func(w http.ResponseWriter, r *http.Request).
    • w http.ResponseWriter is how you write the response back to the client — it's an interface (see Go Basics Part 3) with methods like Write, WriteHeader, and Header().
    • r *http.Request is a pointer to a struct describing the incoming request — method, URL, headers, body, etc.
  • w.Header().Set("Content-Type", "application/json") — sets a response header. This must happen before w.WriteHeader(...) is called — once you write the status code, the headers are locked in and can't be changed afterward.
  • w.WriteHeader(http.StatusOK) — writes the HTTP status code (200). http.StatusOK is just a predefined constant equal to 200 — using the named constant instead of the raw number is more readable and less error-prone.
  • json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) — from Go Basics Part 3: creates a JSON encoder that writes directly to w (which is a stream, an io.Writer), then encodes our map as JSON and writes it out. map[string]string is a map with string keys and string values — see Go Basics Part 3 on maps.

internal/router/router.go

package router

import (
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
)

func New() *chi.Mux {
	r := chi.NewRouter()

	r.Use(middleware.RequestID)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	r.Get("/health", handlers.Health)

	return r
}
  • chi.NewRouter() returns a *chi.Mux — a pointer to chi's router type. *chi.Mux happens to have a ServeHTTP(w, r) method, which means it automatically satisfies the standard library's http.Handler interface (see Go Basics Part 3 on interfaces) — no explicit declaration needed, it "just works" because the method exists.
  • r.Use(...) registers middleware: a function that wraps every request passing through the router. Each of these has the shape func(http.Handler) http.Handler — takes the "next" handler in the chain, returns a new handler that does something extra before/after calling it.
    • middleware.RequestID — tags each request with a unique ID (useful later once we add structured logging, in Lesson 2).
    • middleware.Logger — chi's built-in logger; prints a line per request to your terminal (we'll replace this with our own structured JSON version in Lesson 2).
    • middleware.Recoverer — catches panics inside any handler and turns them into a 500 response, instead of crashing the entire server process over one bad request.
  • r.Get("/health", handlers.Health) — registers handlers.Health to run for GET requests to /health. Note we pass the function itself (handlers.Health), not a call to it (handlers.Health()) — chi will call it later, once per matching request.

cmd/api/main.go

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
)

func main() {
	cfg := config.Load()
	r := router.New()

	srv := &http.Server{
		Addr:    ":" + cfg.Port,
		Handler: r,
	}

	go func() {
		log.Printf("server starting on port %s", cfg.Port)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	log.Println("shutting down gracefully...")
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("forced shutdown: %v", err)
	}
	log.Println("server stopped")
}

This is the most concept-dense file in the lesson. Take it slowly:

  • cfg := config.Load() then r := router.New() — build our two pieces using the constructors we just wrote.
  • srv := &http.Server{Addr: ":" + cfg.Port, Handler: r} — instead of calling the simpler http.ListenAndServe(addr, handler) directly, we build an *http.Server struct ourselves (note the & — we want a pointer, since we're going to call methods on it later that need to operate on this exact instance). We do this specifically so we can call .Shutdown() on it further down — http.ListenAndServe alone gives you no way to stop it gracefully.
  • go func() { ... }()this is a goroutine (Go Basics Part 3, section 5). srv.ListenAndServe() blocks forever, serving requests until the server stops. If we called it directly here (without go), the code below it — the part that waits for Ctrl+C — would never run; the program would just sit inside ListenAndServe permanently. Running it as a goroutine lets it serve requests in the background while main()'s primary execution moves on to the next lines.
  • if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosedListenAndServe always returns a non-nil error when it stops (even on a normal, intentional shutdown) — http.ErrServerClosed specifically means "this was a deliberate Shutdown() call, not a real problem," so we only treat OTHER errors as fatal.
  • quit := make(chan os.Signal, 1) — a channel, Go's built-in mechanism for goroutines to communicate. We're using it here in its simplest form: as a way to "wait for a signal to arrive." (We don't go deeper into channels in this course — this is the only one you'll need.)
  • signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) — tells the Go runtime "when the OS sends this process a SIGINT (Ctrl+C) or SIGTERM (e.g. docker stop), send a value into the quit channel instead of just killing the process outright."
  • <-quit — this is the receive operation on a channel: it blocks (pauses) the current goroutine — here, main()'s own execution — until something arrives on quit. This is what actually keeps the program alive and waiting, instead of exiting immediately after starting the server goroutine.
  • context.WithTimeout(context.Background(), 5*time.Second) — builds a context.Context (we'll use these a lot more starting in Lesson 8) that automatically expires after 5 seconds. context.Background() is the standard "empty starting point" for building a new context.
  • defer cancel()defer schedules a function call to run right before the surrounding function (main, here) returns, regardless of how it returns. cancel releases resources associated with the timeout context once we're done with it — always pair WithTimeout/WithCancel with a defer cancel() immediately after creating them.
  • srv.Shutdown(ctx) — tells the server to stop accepting new connections, and wait for existing in-flight requests to finish, up to the 5-second deadline in ctx. This is what "graceful" shutdown means: requests that were already in progress get to complete normally instead of being cut off mid-response.

Try it

go run ./cmd/api

In another terminal:

curl http://localhost:8080/health

You should get back {"status":"ok"}.

Now go back to the terminal running the server and press Ctrl+C. You should see:

shutting down gracefully...
server stopped

instead of the process just vanishing instantly — that's the graceful shutdown sequence working.

Common mistakes at this stage

  • Forgetting the parentheses when calling a function: writing r := router.New (assigns the function itself) instead of r := router.New() (calls it and gets the *chi.Mux back). The compiler error looks like: cannot use r (variable of type func() *chi.Mux) as http.Handler value — if you see that shape of error, check for a missing ().
  • Forgetting defer db.Close() / defer cancel() on things that need cleanup — not an issue yet in this lesson, but a habit to build now, since it appears constantly starting in Lesson 3.

Once /health works and Ctrl+C shuts down cleanly, move on to Lesson 2 — structured JSON logging.