Go's standard library ships everything needed to stand up a REST backend, so "which framework should I use?" is a less clear-cut question in Go than in most languages. This series builds the same small server several ways to compare the trade-offs; this installment stays entirely within net/http. It has been updated for the routing enhancements that landed in the Go 1.22 standard http.ServeMux.
The API being built
The server backs a task management application. Path segments in angle brackets are client-supplied parameters, tasks carry unique IDs, and bodies are JSON: a POST carries the task to create, and every response payload is JSON.
POST /task/ : create a task, returns ID GET /task/<taskid> : returns a single task by ID GET /task/ : returns all tasks DELETE /task/<taskid> : delete a task by ID GET /tag/<tagname> : returns list of tasks with this tag GET /due/<yy>/<mm>/<dd> : returns list of tasks due by this date
Just GET, POST and DELETE are needed; some operations accept several paths.
Data layer
The server is self-contained — a dependency-free Go module — and the README SERVERPORT value in the sample's environment settings can be any TCP port.
$ SERVERPORT=4112 go run .
With the process up, curl from a second terminal is enough to exercise it; the linked manual script and the bundled test harness in the same directory show one way to do that.
Storage lives behind a small abstraction, the taskstore package, with a map-backed implementation, and its API is shown below alongside its Task type.
func New() *TaskStore // CreateTask creates a new task in the store. func (ts *TaskStore) CreateTask(text string, tags []string, due time.Time) int // GetTask retrieves a task from the store, by id. If no such id exists, an // error is returned. func (ts *TaskStore) GetTask(id int) (Task, error) // DeleteTask deletes the task with the given id. If no such id exists, an error // is returned. func (ts *TaskStore) DeleteTask(id int) error // DeleteAllTasks deletes all tasks in the store. func (ts *TaskStore) DeleteAllTasks() error // GetAllTasks returns all the tasks in the store, in arbitrary order. func (ts *TaskStore) GetAllTasks() []Task // GetTasksByTag returns all the tasks that have the given tag, in arbitrary // order. func (ts *TaskStore) GetTasksByTag(tag string) []Task // GetTasksByDueDate returns all the tasks that have the given due date, in // arbitrary order. func (ts *TaskStore) GetTasksByDueDate(year int, month time.Month, day int) []Task
type Task struct {
Id int `json:"id"`
Text string `json:"text"`
Tags []string `json:"tags"`
Due time.Time `json:"due"`
}
Swapping in a real database would only mean implementing the same API; in production TaskStore would likely be an interface with multiple backends, while the present API suffices for the example.
Wiring up the server
The entry point is short, and the interesting construction is done by NewTaskServer, which builds the taskServer value around a TaskStore — a store safe for concurrent use. Routing itself is delegated to the standard multiplexer from net/http.
func main() {
mux := http.NewServeMux()
server := NewTaskServer()
mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))
}
type taskServer struct {
store *taskstore.TaskStore
}
func NewTaskServer() *taskServer {
store := taskstore.New()
return &taskServer{store: store}
}
mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)
That multiplexer is intentionally minimal, yet handles most routing needs.
Inside a handler
Because the registered route already encodes the pattern, the multiplexer performs the method check (only GET reaches the handler) and extracts the {id} variable, which the handler reads with PathValue.
func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
log.Printf("handling get task at %s\n", req.URL.Path)
id, err := strconv.Atoi(req.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
task, err := ts.store.GetTask(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
js, err := json.Marshal(task)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
From there the job splits in two: pull the record out of the model, then write the response. Both halves are simple, but repeating the JSON marshalling and header setup across the remaining handlers gets tedious. The one handler requiring real care is createTaskHandler, which must decode client-sent JSON from the request body; request-body JSON parsing has subtleties beyond the scope of this example.
Factoring out the JSON response
To remove the duplication, a second version of the server — stdlib-factorjson — sits beside the first so the two can be diffed, built around one helper.
// renderJSON renders 'v' as JSON and writes it as a response into w.
func renderJSON(w http.ResponseWriter, v interface{}) {
js, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
Handlers then shrink considerably; the handler that fetches a single task, for instance, reduces to the following.
func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
log.Printf("handling get task at %s\n", req.URL.Path)
id, err := strconv.Atoi(req.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
task, err := ts.store.GetTask(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
renderJSON(w, task)
}
Third-party routers and how they stack up against net/http come next.
| [1] | Note the ad-hoc nature of specifying the REST API for the server. We'll discuss more structured/standard ways in future parts of this series. |



