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.mdfirst.
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
chilibrary) that maps URLs to handler functions - A
/healthendpoint 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 bothmain.goand any future file can import it and callconfig.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 aConfigby value (not a pointer) since it's small and, once built, nothing needs to mutate it in place.getEnvis 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 returnfallbackinstead. 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.ResponseWriteris how you write the response back to the client — it's an interface (see Go Basics Part 3) with methods likeWrite,WriteHeader, andHeader().r *http.Requestis 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 beforew.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.StatusOKis just a predefined constant equal to200— 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 tow(which is a stream, anio.Writer), then encodes our map as JSON and writes it out.map[string]stringis a map withstringkeys andstringvalues — 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.Muxhappens to have aServeHTTP(w, r)method, which means it automatically satisfies the standard library'shttp.Handlerinterface (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 shapefunc(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 a500response, instead of crashing the entire server process over one bad request.
r.Get("/health", handlers.Health)— registershandlers.Healthto run forGETrequests 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()thenr := router.New()— build our two pieces using the constructors we just wrote.srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}— instead of calling the simplerhttp.ListenAndServe(addr, handler)directly, we build an*http.Serverstruct 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.ListenAndServealone 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 (withoutgo), the code below it — the part that waits for Ctrl+C — would never run; the program would just sit insideListenAndServepermanently. Running it as a goroutine lets it serve requests in the background whilemain()'s primary execution moves on to the next lines.if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed—ListenAndServealways returns a non-nil error when it stops (even on a normal, intentional shutdown) —http.ErrServerClosedspecifically means "this was a deliberateShutdown()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 thequitchannel 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 onquit. 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 acontext.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()—deferschedules a function call to run right before the surrounding function (main, here) returns, regardless of how it returns.cancelreleases resources associated with the timeout context once we're done with it — always pairWithTimeout/WithCancelwith adefer 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 inctx. 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 ofr := router.New()(calls it and gets the*chi.Muxback). 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.