Skip to Content
Course content

82: Building a REST API with Go

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

When you first start building APIs in Go, the temptation is to lean heavily on the net/http package's simplicity. It's a great package, but there's a very common trap I see developers fall into: they treat the handler function as the place where everything happens. I call this the "God Handler" pattern, and while it feels fast at first, it becomes a maintenance nightmare the moment your project grows beyond a single endpoint.

The Trap of the All-in-One Handler

Imagine we're building a simple Book Inventory API. In a naive implementation, you'd probably write a handler that parses the request, connects to the database, performs the logic, and writes the JSON response all in one block of code. It looks something like this:

func handleCreateBook(w http.ResponseWriter, r *http.Request) {
    var b Book
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // Directly calling a global DB variable
    if err := db.SaveBook(b); err != nil {
        http.Error(w, "Database error", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(b)
}

On the surface, this works. But look closer. This function is doing three different jobs: it's handling HTTP transport (parsing JSON/writing headers), it's managing business logic, and it's talking to the data layer. If you want to change your database to something else, or if you want to write a unit test for the "save book" logic without starting a real HTTP server, you're stuck. You can't test the logic without triggering the whole HTTP cycle.

Decoupling with a Server Struct

The better way—the way we actually do this in production—is to separate the transport (HTTP) from the logic (the Service). Instead of using global variables and standalone functions, I prefer to wrap my dependencies in a Server struct. This allows us to "inject" our database or logger, making the code modular and testable.

By moving the data logic into a separate BookStore interface, the handler becomes a thin wrapper. Its only job is to translate an HTTP request into a Go object and translate the result back into an HTTP response. I've found that when you treat your handler as a "translator," your code becomes significantly cleaner.

type BookStore interface {
    Save(Book) error
}

type Server struct {
    Store BookStore
}

func (s *Server) handleCreateBook(w http.ResponseWriter, r *http.Request) {
    var b Book
    if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
        s.respondWithError(w, http.StatusBadRequest, "Invalid request payload")
        return
    }

    if err := s.Store.Save(b); err != nil {
        s.respondWithError(w, http.StatusInternalServerError, "Failed to save book")
        return
    }

    s.respondWithJSON(w, http.StatusCreated, b)
}

The Cost of Abstraction vs. The Cost of Rigidity

Now, you might look at this and think, "I've just added more code to do the same thing." You're not wrong. We've added a struct, an interface, and a couple of helper methods like respondWithJSON. In a tiny script, this is overkill.

But here is the trade-off: the naive way is cheap to start but expensive to change. The structured way is slightly more expensive to start but nearly free to change. If we decide to switch from a PostgreSQL database to a MongoDB instance, we only change the implementation of the BookStore. We don't touch a single line of the HTTP handler code. More importantly, we can now write a "MockStore" for our tests, allowing us to test our API logic in milliseconds without needing a database running in the background.

I usually recommend sticking to this pattern from day one. It forces you to think about the boundaries of your application. When you separate how a request arrives (HTTP) from what the application does (Save Book), you're building a system that can actually survive a year of feature requests.




📋 Practical Task

Exercise: Refactoring the Library Management API

You have been handed a legacy piece of code for a "Library Management" API. Currently, it uses a global slice for storage and a monolithic handler that manages both the data and the HTTP response. Your goal is to refactor this into a professional structure.

Requirements:

  • Create a LibraryStore interface with a GetBook(id string) (Book, error) method.
  • Implement a Server struct that holds a LibraryStore.
  • Refactor the GetBookHandler so it no longer accesses a global slice directly, but instead calls the LibraryStore interface via the Server struct.
  • Implement a helper method on the Server struct called sendJSON to handle the repetitive task of setting the Content-Type header and encoding the JSON response.

Success Criteria: The API should still return the correct book when requested via GET /books/{id}, but the handler function should contain no direct database/slice manipulation logic.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.