The Capstone — Build the Whole Service
Every exercise in this book so far handed you the app around the hole you were filling. The store already existed when you wrote handlers; the handlers already existed when you wrote middleware. That scaffolding is gone now. You get four files with signatures and TODOs, and you build the service.
The point isn't new syntax — there is none here you haven't already used. The point is that a service is a set of layers, and the boundaries between them are the design.
Three packages, one direction
main.go // wiring: construct, mount, serve
internal/task/ // the domain: what a Task IS and when it's valid
internal/store/ // storage: how Tasks are kept
internal/api/ // transport: HTTP in, JSON out
Dependencies point one way: api imports store, store imports task, and task imports nothing of yours. Nothing points back. That is the whole discipline, and you can check it mechanically — if task ever needs to import store, a type is in the wrong package.
This is why the domain sits at the bottom. task.Task doesn't know it will be stored in a map, or served as JSON, or that HTTP exists. It knows what a task is. Swap the store for Postgres later and task doesn't change; that's the payoff.
The transport layer owns HTTP, and only the transport layer
store.Create returns an error. It does not return 404, because 404 is not a storage concept — it's what HTTP happens to call "the thing you asked for isn't there". Deciding that a missing task means http.StatusNotFound is api's job, and keeping that decision in one layer is what lets you put the same store behind a CLI tomorrow.
The same rule runs the other way. store.Get returns (task.Task, bool) rather than an error, because "not found" is an ordinary, expected outcome of a lookup, not a failure. Reserve errors for things that went wrong.
Concurrency is the store's problem
An http.Server runs every request in its own goroutine, so your store is touched concurrently from the moment you serve it. A bare map crashes the process with fatal error: concurrent map writes — not an error you can handle, a hard stop. The mutex belongs inside the store, not in its callers: safety that each caller has to remember is safety you will eventually forget.
The hidden tests hammer the store from many goroutines at once, and separately check that the store owns a lock at all — so a map left unguarded won't survive grading.
Try it
Build it. The hidden tests live inside each package — internal/task, internal/store, and internal/api — because that is where Go tests go, and because each layer should be provable without the ones above it. The API tests drive your handler with httptest.NewRecorder, exactly as the book has all along.
When it passes, you have a small, correctly layered, race-free JSON service that you wrote end to end.
Practice
1. Capstone — The Tasks API
★ CapstoneAdvancedBuild the Tasks API across three packages.
main.gois written for you; you implement the rest. Every signature is already in place — fill in the bodies.internal/task— the domain.Task{ID int, Title string, Done bool}with JSON tagsid,title,done.(Task) Validate() error— returnsErrEmptyTitlewhen the title is empty or only whitespace,ErrTitleTooLongwhen it exceeds 200 characters, elsenil.
internal/store— storage, safe for concurrent use.New() *Store(*Store) Create(title string) (task.Task, error)— trims the title, validates it, assigns the next ID starting at 1, stores and returns it. An invalid title returns the zero Task and the validation error.(*Store) Get(id int) (task.Task, bool)—falsewhen absent. Not an error: a miss is an ordinary outcome of a lookup.(*Store) All() []task.Task— every task, sorted by ID, as a fresh slice.(*Store) Delete(id int) bool—falsewhen there was nothing to delete.
internal/api— transport.New(s *store.Store) http.Handlerrouting:GET /tasks→200with a JSON array ([]when empty, nevernull)POST /tasks→201with the created task;400when the title is invalid or the body isn't valid JSONGET /tasks/{id}→200with the task;404when absent;400when the id isn't a numberDELETE /tasks/{id}→204;404when absent
Every response carries
Content-Type: application/json, except204, which has no body at all.The hidden tests live inside each package and drive your handler with
httptest.NewRecorder. They exercise the store from many goroutines at once, and check that it holds a lock of its own — an unsynchronized map either panics with "concurrent map writes" or silently loses tasks, so the mutex belongs inside the store.internal/api/api.go
// Package api is the transport layer: HTTP in, JSON out. // // It is the ONLY package that knows HTTP exists. store.Get returning false // becomes 404 here, because "not found" is a storage fact and "404" is what // HTTP calls it — keeping that translation in one place is what would let the // same store sit behind a CLI tomorrow. package api import ( "net/http" "submission/internal/store" ) // New returns the service's handler, routing: // // GET /tasks -> 200, JSON array ([] when empty, never null) // POST /tasks -> 201 created task; 400 on bad JSON or invalid title // GET /tasks/{id} -> 200 task; 404 absent; 400 non-numeric id // DELETE /tasks/{id} -> 204 no body; 404 absent // // Every response carries Content-Type: application/json, except 204. func New(s *store.Store) http.Handler { mux := http.NewServeMux() // TODO: register the four routes above on mux. // // http.ServeMux understands method and wildcard patterns directly: // mux.HandleFunc("GET /tasks", ...) // mux.HandleFunc("GET /tasks/{id}", ...) // and r.PathValue("id") reads the wildcard back out. return mux }internal/store/store.go
// Package store keeps Tasks in memory. It is safe for concurrent use: an // http.Server hands every request its own goroutine, so the store is touched // from many at once. package store import ( "sync" "submission/internal/task" ) // Store holds tasks by ID and hands out the next one. type Store struct { mu sync.Mutex tasks map[int]task.Task nextID int } // New returns an empty Store ready for use. func New() *Store { // TODO: implement (remember to make the map, and start IDs at 1) return &Store{} } // Create validates a trimmed title, assigns the next ID, and stores the task. // An invalid title returns the zero Task and the validation error. func (s *Store) Create(title string) (task.Task, error) { // TODO: implement return task.Task{}, nil } // Get returns the task with the given ID. The bool is false when there is no // such task — a miss is an ordinary outcome of a lookup, not an error. func (s *Store) Get(id int) (task.Task, bool) { // TODO: implement return task.Task{}, false } // All returns every task sorted by ID, as a fresh slice the caller may keep. func (s *Store) All() []task.Task { // TODO: implement return nil } // Delete removes a task, reporting whether there was one to remove. func (s *Store) Delete(id int) bool { // TODO: implement return false }internal/task/task.go
// Package task is the domain: what a Task is, and when it is valid. // // It imports nothing else of ours. A Task does not know it will be kept in a // map or served as JSON — that ignorance is what lets the layers above change // without touching this one. package task import "errors" // Validation failures, as sentinel errors so callers can test for them with // errors.Is rather than matching on message text. var ( ErrEmptyTitle = errors.New("title must not be empty") ErrTitleTooLong = errors.New("title must be 200 characters or fewer") ) // MaxTitleLen is the longest title a Task may carry. const MaxTitleLen = 200 // Task is a single to-do item. type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` } // Validate reports whether the task is well formed: a non-blank title of at // most MaxTitleLen characters. func (t Task) Validate() error { // TODO: implement return nil }main.go
// Package main wires the service together: construct the store, mount the API // on it, and serve. Written for you — the layers below are yours. package main import ( "log" "net/http" "submission/internal/api" "submission/internal/store" ) func main() { s := store.New() handler := api.New(s) log.Println("listening on :8080") if err := http.ListenAndServe(":8080", handler); err != nil { log.Fatal(err) } }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.