Section 3 ended with a single main.go handling routing, JSON, and context all by itself. Before Section 4 grows that into a real API, we need to organize the code.
Before adding new routes, we need to settle two things: what the Task API actually looks like, and where its code lives. Chapter 3 sketched out the resource and endpoints. This chapter locks those details down and gives them a proper home.

Here is what changes in this chapter:
main.go is deleted. It gets replaced by cmd/server/main.go and a new internal/task package.Task struct gets its final shape. The /tasks routes get stub handlers backed by an in-memory store.Check out the finish branch to follow along:
bashgit checkout 19-designing-the-api-finish
internal/. Go treats the internal/ directory name specially. Any package inside an internal/ folder can only be imported by code within the same module. Another Go module physically cannot run . The compiler enforces this rule, so you don't have to rely on conventions or linters.
import "github.com/mt26691/go-for-beginners/internal/task"Module path. This is the import path other code uses to reach your packages. It is declared once at the top of go.mod (module github.com/mt26691/go-for-beginners). Every package inside your project is addressed relative to this path. For example, internal/task becomes github.com/mt26691/go-for-beginners/internal/task.
Stub handler. A stub handler is wired up and reachable at its route, but it doesn't do the real work yet. Our stubs will call http.Error(w, "not implemented", http.StatusNotImplemented). This is a valid response, just not the final one. Stubs let us see the shape of the API before we write the logic behind it.
If you skip them now, two decisions are easy to get wrong later: what the API returns, and where the code lives.
We sketched the resource shape back in Chapter 3. POST /tasks creates a task, GET /tasks lists them, and GET /tasks/{id} fetches just one. (PUT and DELETE will follow in Chapter 21.) A task has an id, title, description, done status, and createdAt timestamp. The server always assigns the id and createdAt fields, never the client. Letting a client pick its own ID invites collisions, and letting it set createdAt invites inaccurate data. Locking these rules down now means Chapter 20 has no design decisions left to make. We can just write code.
The layout question matters because everything from Chapter 20 onward builds on the structure we pick here. A single growing main.go stops being readable fast. However, the opposite mistake is jumping straight into a complex folder structure like handler/, service/, repository/, and entity/. That is worse for a project this size. Extra layers you don't need yet just give you more places to get lost. Two packages is the right number for now: main in cmd/server, and the task domain in internal/task. We will introduce a storage interface in Chapter 23 once we actually have two implementations that need one. Introducing it today would just be an abstraction for the sake of abstraction.
We are also deliberately staying on an in-memory store. The database arrives in Section 6. Keeping storage in a plain Go map for now means every choice in this chapter is about HTTP and JSON, not connection pools or SQL.
The finish branch replaces the flat file with this tree:
cmd/
server/
main.go
internal/
task/
task.go
store.go
handler.go
cmd/server/main.go is the entry point. It is the only file with package main. The internal/task directory holds the domain: the Task model, the store, and the handlers that serve it. The go.mod module path stays exactly the same as before:
go// go.modmodule github.com/mt26691/go-for-beginners
That single line is what makes internal/task importable. The main.go file reaches it via github.com/mt26691/go-for-beginners/internal/task, which is the module path plus the folder path.
Notice that the Section 3 context demos (/slow, /slow-store, /trace) and their supporting code were deliberately removed in this restructure. They did their job teaching context.Context in Chapter 18. Keeping them around would just be dead weight in the Task API going forward.
The internal/task/task.go file defines the resource we committed to in Chapter 3. It now lives in its own file:
go// internal/task/task.gopackage taskimport "time"type Task struct {ID int `json:"id"`Title string `json:"title"`Description string `json:"description,omitempty"`Done bool `json:"done"`CreatedAt time.Time `json:"createdAt"`}
It has five fields. All of them are exported (capitalized) so the encoding/json package can see them, as we learned in Chapter 17. The Description field carries the omitempty tag because a task without a description shouldn't serialize as "description":"". Finally, CreatedAt is a time.Time. Nothing sets it yet, because nothing creates a task yet.
The internal/task/store.go file holds the exact store this chapter needs, and nothing more:
go// internal/task/store.gopackage taskimport "sync"type Store struct {mu sync.Mutextasks map[int]Task}func NewStore() *Store {return &Store{tasks: make(map[int]Task)}}func (s *Store) List() []Task {s.mu.Lock()defer s.mu.Unlock()tasks := make([]Task, 0, len(s.tasks))for _, t := range s.tasks {tasks = append(tasks, t)}return tasks}
The map[int]Task and sync.Mutex pairing is the same pattern from Chapter 13. Every incoming HTTP request runs in its own goroutine, so any shared map needs a lock around it to prevent data races. The List() method builds the result using make([]Task, 0, len(s.tasks)) rather than a nil slice. This ensures an empty store serializes to [] in JSON instead of null.
There is no nextID field yet, and no Create, Get, Update, or Delete methods. Chapter 20 adds Create and the ID counter. Chapter 21 adds the rest. In Chapter 23, Store will get pulled behind an interface so a Postgres-backed store can stand in for it later. For now, it is just a concrete type that the handler holds directly.
The internal/task/handler.go file wires the store to HTTP:
go// internal/task/handler.gopackage taskimport ("encoding/json""log""net/http")type Handler struct {store *Store}func NewHandler(store *Store) *Handler {return &Handler{store: store}}func (h *Handler) Routes(mux *http.ServeMux) {mux.HandleFunc("GET /tasks", h.list)mux.HandleFunc("POST /tasks", h.create)mux.HandleFunc("GET /tasks/{id}", h.get)}func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {writeJSON(w, http.StatusOK, h.store.List())}func (h *Handler) create(w http.ResponseWriter, _ *http.Request) {http.Error(w, "not implemented", http.StatusNotImplemented)}func (h *Handler) get(w http.ResponseWriter, _ *http.Request) {http.Error(w, "not implemented", http.StatusNotImplemented)}func writeJSON(w http.ResponseWriter, status int, v any) {w.Header().Set("Content-Type", "application/json")w.WriteHeader(status)if err := json.NewEncoder(w).Encode(v); err != nil {log.Printf("writeJSON: %v", err)}}
The Handler struct holds a *Store. It receives this store through NewHandler rather than reaching for a global variable. This is a dependency injection habit you will see again when a service layer shows up in Chapter 23. The Routes method registers the three endpoints using the Go 1.22 method and path patterns from Chapter 16.
The list handler is real. It calls h.store.List() and returns whatever is there with a 200 OK status. Since nothing has been created yet, it returns []. The create and get handlers are stubs. They each return 501 Not Implemented. That status code exists precisely for this scenario: the route is registered and reachable, but the behavior behind it isn't built yet. Finally, writeJSON is the same helper function from Chapter 17. It now lives next to the handlers that use it instead of sitting at the top of a single main.go file.
We are using light REST framing for the status codes here: 200 for a successful list, 404 for a missing resource (coming in Chapter 21), and 501 for a route that exists but isn't implemented. Deeper status code decisions, like choosing between 200 and 204, will wait until Chapters 20 and 21 when we actually build the behavior.
The cmd/server/main.go file is the composition root. It is the one place in the application that knows about both net/http and the task package:
go// cmd/server/main.gopackage mainimport ("fmt""log""net/http""github.com/mt26691/go-for-beginners/internal/task")func ping(w http.ResponseWriter, _ *http.Request) {if _, err := fmt.Fprintln(w, "pong"); err != nil {log.Printf("ping: %v", err)}}func main() {mux := http.NewServeMux()mux.HandleFunc("GET /ping", ping)store := task.NewStore()handler := task.NewHandler(store)handler.Routes(mux)log.Println("listening on :8080")log.Fatal(http.ListenAndServe(":8080", mux))}
Four lines tell the whole story: build the mux, create the store, hand the store to a new handler, and register that handler's routes on the mux. Nothing in main knows how Store stores tasks or how Handler decodes JSON. It just wires the pieces together and starts listening.
Because the entry point moved out of the module root, running go build . in the repository root no longer works. There is no package main there anymore. The Makefile already accounts for this change:
makefile# Makefilerun: ## Run the applicationgo run ./cmd/serverbuild: ## Compile the applicationgo build ./...
The whole point of this chapter is to ensure the application compiles and boots, even though two of the three task routes aren't implemented yet.
bashgo build ./...make run

The /ping endpoint still works exactly as before. GET /tasks is wired to the real List() method and returns [] because the store is empty. POST /tasks and GET /tasks/{id} both return 501 Not Implemented. They are reachable and routed correctly, just not built yet. Running go build ./..., go vet ./..., and golangci-lint run will all come back clean on this branch.
In Chapter 20, we replace the create stub with a working POST /tasks handler that decodes the body, assigns an ID and createdAt, and returns 201 Created. Once tasks are stored, GET /tasks starts returning real data.