JavaRush /Courses /Go SELF /Handlers and http.ServeMux: basic patterns

Handlers and http.ServeMux: basic patterns

Go SELF
Level 59, Lesson 1
Available

1. Mental model: what happens on an HTTP request in Go

If you previously wrote console programs, everything there is linear: main() started, read something from stdin, computed something, printed something, and exited. A server is a different beast: it lives for a long time and runs your code many times when requests arrive. That’s why in this topic the most important thing is not “memorizing functions”, but building the right picture in your head: who calls whom, and where exactly you “plug in” with your code.

In Go, this picture is very simple: the server receives a request → passes it to a handler → the handler writes the response. If you configured routing, then between the server and the specific handler there is a router (in the standard library it’s http.ServeMux) that picks the right handler by the request path.

Here is the call chain on the “who calls whom” level:

flowchart LR
    C[Client: browser/curl] -->|HTTP request| S[net/http Server]
    S --> M[ServeMux]
    M --> H[Handler: your code]
    H -->|writes| W[ResponseWriter]
    W -->|HTTP response| C

Key takeaway: the entire server revolves around http.Handler. If you understand http.Handler and ServeMux, you already understand half of the server side of net/http.

2. http.Handler: the handler contract and the ServeHTTP method

When you’re just getting into web development, it can feel like a handler is “some function” that the server calls. In Go that’s almost true, but formally a handler is a value that implements the http.Handler interface. The interface has only one method, and that’s one of the reasons people love Go’s HTTP: it has a low entry barrier and is still very extensible.

Conceptually, the interface looks like this: “anything that can do ServeHTTP(w, r) is a handler.” The signature matters: the first parameter is where to write the response (http.ResponseWriter), and the second is what came in (*http.Request). And yes, the request is always passed as a pointer: copying the whole request struct would be odd and expensive.

Minimal example: “handler as a type”:

package main

import (
	"fmt"
	"net/http"
)

type healthHandler struct{}

func (healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok") // ok
}

In this code, the most important thing is not “ok”, but the fact that the type healthHandler became a handler because it implements the required method. This is the “Go way”: interfaces are implemented not by declaring implements, but simply by having methods with the right signatures.

Notice fmt.Fprintln(w, ...). This is a small but very useful trick: you can treat w as a writer because ResponseWriter supports writing bytes. So instead of “some special response function”, you just print into w like you would print to a file or a buffer.

3. http.HandlerFunc: how a regular function becomes a handler

If you have to write a separate type and a ServeHTTP method every time, you’ll quickly get sad and start suspecting there’s a conspiracy “against developer happiness”. Luckily, the standard library cancels the conspiracy: net/http provides the http.HandlerFunc adapter.

The idea is simple: if you have a function with the signature

func(w http.ResponseWriter, r *http.Request)

then you can “wrap” it into the http.HandlerFunc type, and it becomes an http.Handler. In other words, HandlerFunc is a bridge between “it’s convenient for me to write functions” and “the server needs an interface”.

Example:

package main

import (
	"fmt"
	"net/http"
)

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "hello") // hello
}

func main() {
	_ = http.HandlerFunc(hello) // now hello can be used as http.Handler
}

In practice, you rarely need to write http.HandlerFunc(hello) manually, because ServeMux has HandleFunc(...) methods that do this for you. But understanding that an adaptation happens under the hood is very useful: you stop seeing handlers as “magic functions” and start seeing ordinary values that satisfy an interface.

Separately interesting (and a bit gopher-ish funny): in Go, even a function can have methods, and therefore can implement http.Handler. This is one of those facts that first sounds like a prank, and then you use it and can’t stop. In the classic article about error handling via a custom handler type, they show how to declare type appHandler func(w, r) error and add a ServeHTTP method to it, where they call the function itself and handle the error centrally.

4. http.ServeMux: path-based routing without “magic”

When you have more than one handler, the next question is: “How does the server know which handler to call for "/health" and which for "/tasks"?” This is where http.ServeMux comes in.

ServeMux is the standard router from net/http. It stores a mapping table “path pattern → handler” and selects the appropriate handler for each request. At a basic level, you only need to remember two things: you create a mux, register routes, and then pass the mux to the server as the main handler. That’s it. No hidden reflection, no code generation, no “why does this even work”.

Creation and registration:

package main

import "net/http"

func buildMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", helloHealth)
	return mux
}

func helloHealth(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusNoContent)
}

And then the server (we won’t dive into startup here; that’s a neighboring topic), to which you give the mux:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := buildMux()

	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Println("server error:", err)
	}
}

If a route isn’t found, the mux will return 404 (Not Found) by itself. This matters: you don’t need to write “a handler for everything that checks paths”. The router already does that.

Handle vs HandleFunc: the difference in plain terms

It’s easy to get confused here, so it’s useful to lock in a short, clear explanation. Handle takes an http.Handler (that is, something with ServeHTTP), while HandleFunc takes a “regular function” and converts it into a handler via HandlerFunc.

What you register How you register What you pass
A ready handler (a type with ServeHTTP) mux.Handle(...) http.Handler
A handler function mux.HandleFunc(...) func(http.ResponseWriter, *http.Request)

Almost always, beginners will start with HandleFunc, and when dependencies appear (storage, services), you’ll naturally move to “handlers as methods on a struct” and start using Handle more often.

Where to keep handlers: three basic styles

Once you understand what a handler and a mux are, an “architecture” question comes up: “Okay, how do I organize the code so it doesn’t turn into spaghetti routing?” The good news: even within a single file you can build an order that you can later move into a project with packages.

The simplest style is package-level functions: func handleHealth(w, r). It’s fast and works great while there’s little logic and no dependencies. But as soon as you need a task store or config, you either start making global variables (not the best path), or pass dependencies more neatly.

The second style, very friendly to growth, is “handlers as methods on a struct”. You create a struct, for example type server struct { store *TaskStore }, and write func (s *server) handleHealth(...). This approach is good because dependencies are explicitly stored in struct fields instead of being hidden in globals.

The third style is functional adapters (when a handler returns an error and a wrapper writes the response). It’s powerful and elegant, but as a first “entry point” it’s better not to start there: it’s easy to get carried away and fly off into abstractions. Still, it’s important to know this style exists, and that it’s possible precisely because in Go you can give functions method receivers and turn them into handlers.

Useful matching rules in ServeMux

At the beginning, people often think ServeMux “looks at the path string and searches for an exact match.” In practice, it’s important to remember it has pattern-matching rules. And while we won’t turn the lecture into a retelling of the docs, a couple of things are worth knowing up front so you don’t later sit there thinking, “Why isn’t my handler being called? I registered it!”

First, ServeMux picks the most appropriate (most “specific”) pattern. That means if you registered both "/" and "/tasks", then a request to "/tasks" will go to "/tasks", not to "/". "/" is often used as a “root” handler or as a catch-all for the whole site, but it’s important not to overuse it: if you take "/" and start manually parsing r.URL.Path inside, you’ve effectively thrown away ServeMux and built yourself a homemade router for no reason.

Second, it’s convenient to agree on a path style early. For example, either you do “simple” paths /tasks, /health, or you adopt a namespace right away like /api/.... Mixing /tasks and /api/tasks in a small service is usually unnecessary: it complicates tests, documentation, and your future self, who will open this code a month later and ask: “Who came up with this, and why didn’t they warn me?”

5. Mini example: a task server with ServeMux

So this doesn’t remain theory, let’s assemble a small application scaffold that will gradually evolve. Imagine we’re writing a tiny “task tracker”: no database yet and no complex formats—just to get hands-on with ServeMux, several routes, and different handlers.

Let’s start with the task model and a simple in-memory store:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

type TaskStore struct {
	nextID int
	tasks  []Task
}

func NewTaskStore() *TaskStore {
	return &TaskStore{nextID: 1}
}

Now let’s add a method that creates a task. Note: short, without extra “engineering”, but readable.

package main

func (s *TaskStore) Add(title string) Task {
	t := Task{ID: s.nextID, Title: title}
	s.nextID++
	s.tasks = append(s.tasks, t)
	return t
}

And a method to read the task list (we’ll return a copy of the slice—safer, even though we’re not discussing concurrency yet):

package main

func (s *TaskStore) List() []Task {
	out := make([]Task, len(s.tasks))
	copy(out, s.tasks)
	return out
}

The “server” struct and handlers as methods

Now let’s create a struct that holds dependencies (for now it’s only store), and write two handlers: /health and /tasks.

package main

import (
	"fmt"
	"net/http"
)

type server struct {
	store *TaskStore
}

func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusNoContent)
	fmt.Fprintln(w, "healthy") // healthy (a body is not required here, but it’s useful for debugging)
}

For /tasks, let’s use a simple pattern: one path, but different behavior by method. It’s very important not to mix this up: ServeMux routes by path, not by HTTP method. So the method is usually checked inside the handler (as long as we’re not using more advanced routing).

package main

import (
	"fmt"
	"net/http"
)

func (s *server) handleTasks(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodGet {
		for _, t := range s.store.List() {
			fmt.Fprintf(w, "#%d %s\n", t.ID, t.Title)
		}
		return
	}

	w.WriteHeader(http.StatusMethodNotAllowed)
	fmt.Fprintln(w, "method not allowed") // method not allowed
}

Already you can see a useful style: the handler is “linear”, without unnecessary nesting. First you check the most common and straightforward case (GET), do the work, and return. If it’s not GET—return 405.

Building the mux in one place: buildMux()

Now the central point of the lecture: route registration should be in one place, so you don’t have to search the project for “where did we hook up /tasks?”

package main

import "net/http"

func buildMux(srv *server) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", srv.handleHealth)
	mux.HandleFunc("/tasks", srv.handleTasks)
	return mux
}

Notice a nice detail: srv.handleTasks is a “method value”, and its signature matches HandleFunc. So struct methods work great as handlers without extra wrappers.

main(): putting it all together

The final piece is how to assemble the whole server. Here we just connect the parts: store → server → mux → start.

package main

import (
	"log"
	"net/http"
)

func main() {
	store := NewTaskStore()
	store.Add("buy milk")
	store.Add("do not buy milk (for dramatic effect)")

	srv := &server{store: store}
	mux := buildMux(srv)

	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Println("server error:", err)
	}
}

Now, if you open /tasks, you’ll get a plain-text list of tasks. Yes, this is not a JSON API yet, but for understanding handlers and mux that’s even a plus: fewer details, more clarity about where routing lives and where request-handling logic lives.

6. Common mistakes

Mistake #1: mixing up “routing” and “handling”.
Beginners often write one huge handler for "/" and do if r.URL.Path == "/tasks" { ... } else if ... inside. Formally it works, but you lose the main advantage of ServeMux: a centralized, readable handler selection. The result is code that’s harder to read and harder to extend, because the “route table” is smeared across conditionals.

Mistake #2: thinking ServeMux routes by HTTP method.
At a basic level, ServeMux selects a handler by path. If you registered "/tasks", then both GET /tasks and POST /tasks will hit the same handler. That’s why checking r.Method inside the handler is normal and expected at this level. If you forget this, you can accidentally start handling POST as GET and wonder why the client gets a “task list” instead of “created a task”.

Mistake #3: writing the response “to the console” instead of to the client.
Sometimes, out of habit, people do fmt.Println("ok") inside a handler and expect the client to see “ok”. The client won’t see it: fmt.Println writes to the server process stdout. The client will only see what you wrote to http.ResponseWriter (via fmt.Fprintln(w, ...), w.Write(...), json.NewEncoder(w).Encode(...), etc.). These bugs are sneaky because “everything looks fine” in logs, while the client sees “somehow it’s empty”.

Mistake #4: scattering route registration across the codebase.
When mux.HandleFunc("/tasks", ...) appears in three places, you’ve created a detective story for yourself: where exactly is the route registered, which handler is the last one, and why one of them is never called. It’s much easier to keep registration in one function like buildMux() and call it from main(). It’s a small discipline that saves hours later.

1
Task
Go SELF, level 59, lesson 1
Locked
Application health
Application health
1
Task
Go SELF, level 59, lesson 1
Locked
Two greetings
Two greetings
1
Task
Go SELF, level 59, lesson 1
Locked
Request Counter
Request Counter
1
Task
Go SELF, level 59, lesson 1
Locked
Tasks menu
Tasks menu
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION