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

319 lines
12 KiB
Markdown

# 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
```bash
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](https://github.com/go-chi/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`
```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`
```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`
```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`
```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.ErrServerClosed`
`ListenAndServe` 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
```bash
go run ./cmd/api
```
In another terminal:
```bash
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.