Skip to Content
Course content

58: Practice Exercise: Building a REST API with net/http

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you know how to spin up a basic server in Go. But there is a massive gulf between "making a port listen" and building a REST API that doesn't become a maintenance nightmare the moment you add a second endpoint. I've seen a lot of developers start their first Go API by leaning too hard on the net/http package's global functions, and while it feels productive for the first twenty minutes, it usually ends in tears during the first integration test.

The "Quick and Dirty" Global State

When people first start, they usually do something like this. They define a global slice to hold their data—let's say we're building a simple Book Inventory system—and they use http.HandleFunc to map routes. It looks clean because it's concise, but it's a trap.

var books = []Book{{ID: "1", Title: "The Go Programming Language"}}

func bookHandler(w http.ResponseWriter, r *http.Request) {
    // Logic to return books...
    json.NewEncoder(w).Encode(books)
}

func main() {
    http.HandleFunc("/books", bookHandler)
    http.ListenAndServe(":8080", nil)
}

Here is where this breaks. Because books is a global variable, your handler is tightly coupled to that specific instance of data. If you want to write a unit test for bookHandler, you can't easily swap out that slice for a mock database or a controlled test set without affecting every other test running in parallel. Even worse, using the default http.DefaultServeMux (which is what happens when you pass nil to ListenAndServe) means any package in your entire dependency tree can register routes to your server. It's a security risk and a debugging headache.

Structuring for Testability and State

The better way—the way we actually do this in production—is to encapsulate your dependencies in a struct. Think of this struct as your "Application Context." Instead of global variables, you put your database connection, logger, or configuration inside this struct, and then you define your handlers as methods on that struct.

I also strongly recommend creating your own http.ServeMux rather than using the global one. It gives you total control over your routing table and prevents external packages from messing with your endpoints.

type BookServer struct {
    db []Book // In a real app, this would be *sql.DB
}

func (s *BookServer) handleBooks(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(s.db)
}

func main() {
    server := &BookServer{
        db: []Book{{ID: "1", Title: "The Go Programming Language"}},
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/books", server.handleBooks)

    http.ListenAndServe(":8080", mux)
}

Now, the trade-off is a bit more boilerplate. You have to define a struct and initialize it. But look at what you gained: you can now instantiate BookServer in a test file with a specific set of dummy books, call handleBooks directly, and verify the output without ever actually opening a network port. You've decoupled your business logic from the global state of the running process.

Handling the JSON Plumbing

One last thing that trips people up is how they handle responses. I see a lot of developers manually writing strings into the ResponseWriter or forgetting to set the Content-Type header. In a REST API, consistency is everything. If one endpoint returns JSON and another returns plain text because of a missing header, your frontend developers will hate you.

Instead of scattering json.NewEncoder calls everywhere, I usually suggest creating a small helper method on your server struct. It keeps your handlers focused on the "what" rather than the "how." It turns a three-line ritual of setting headers and encoding into a single, readable function call. It's a small win, but across fifty endpoints, it's the difference between a codebase that feels professional and one that feels like a collection of scripts.




📋 Practical Task

Exercise: Refactoring the Library Catalog API

You have been handed a "prototype" for a Library Catalog API that uses global state and the default serve mux. Your task is to refactor this into a professional structure using a server struct and a custom mux.

Requirements:

  • Create a CatalogServer struct that holds a slice of Book objects (each book should have an ID and a Title).
  • Move the logic for the /books endpoint into a method of CatalogServer.
  • Replace http.HandleFunc with a custom http.NewServeMux().
  • Ensure the Content-Type: application/json header is set for the response.
  • Initialize the server in main() with at least two books before starting the listener.

Starter Code:

package main

import (
    "encoding/json"
    "net/http"
)

type Book struct {
    ID    string `json:"id"`
    Title string `json:"title"`
}

var library = []Book{{ID: "1", Title: "Clean Code"}}

func getBooks(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(library)
}

func main() {
    http.HandleFunc("/books", getBooks)
    http.ListenAndServe(":8080", nil)
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.