From http.ServeMux to Dependency Injection in Go: A Beginner-Friendly Guide
DEV Community

From http.ServeMux to Dependency Injection in Go: A Beginner-Friendly Guide

Step 1: The smallest useful mux and handler

Go provides an HTTP router called http.ServeMux. A router matches an incoming request with the code that should handle it. The method-and-path pattern used below, such as "GET /health", requires Go 1.22 or newer.

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "healthy")
	})

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

There are three important lines here. First, we create a mux:

mux := http.NewServeMux()

Second, we register a function for GET /health:

mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "healthy")
})

This tells the mux: When a GET /health request arrives, run this function.

Finally, we start the server and give it the mux:

http.ListenAndServe(":8080", mux)

The server receives requests, but the mux decides where they go. If we run the program and send this request:

curl http://localhost:8080/health

we receive:

healthy

Step 2: Give the handler function a name

An anonymous function is convenient, but it becomes harder to read as it grows. We can move it into a named function:

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", healthHandler)

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

Notice that we pass the function itself:

mux.HandleFunc("GET /health", healthHandler)

We do not call it:

// Wrong: this tries to call the function immediately.
mux.HandleFunc("GET /health", healthHandler())

The mux saves the function and calls it later when a matching request arrives.

Step 3: Introduce a service

Returning a fixed string does not require application logic. A real health endpoint may need to inspect PostgreSQL, Redis, or other dependencies. We can put that logic in a service:

type HealthService struct{}

func (s *HealthService) Status() string {
	return "healthy"
}

For now, this service still returns a fixed value. That is okay-we are focusing on how the pieces connect.

One easy way to let the handler use the service is with a closure:

func main() {
	service := &HealthService{}
	mux := http.NewServeMux()

	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		status := service.Status()
		fmt.Fprintln(w, status)
	})

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

The handler can use service because the function was created in the same surrounding scope. The request now follows this path:

GET /health โ†’ mux โ†’ handler function โ†’ service.Status()

This is a good solution for a small program.

Step 4: Let a handler struct remember the service

As an application grows, a handler may need several methods or dependencies. A struct gives us a clear place to store them:

type HealthHandler struct {
	service *HealthService
}

The service field means every HealthHandler value remembers which health service it should use. We create the handler with a constructor:

func NewHealthHandler(service *HealthService) *HealthHandler {
	return &HealthHandler{service: service}
}

Passing a dependency into an object from the outside is called dependency injection. Despite the intimidating name, the idea is simple: Create a value, then give it the things it needs. The handler does not create its own service. main creates the service and passes it in.

Step 5: Understand the http.Handler interface

The standard library defines an interface that is approximately this:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

Any type with a matching ServeHTTP method can act as an HTTP handler. We can make our HealthHandler satisfy that interface:

func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	status := h.service.Status()
	fmt.Fprintln(w, status)
}

We do not need to write implements http.Handler. Go recognizes automatically that *HealthHandler has the required method.

Now we can register it with mux.Handle:

func main() {
	service := &HealthService{}
	handler := NewHealthHandler(service)

	mux := http.NewServeMux()
	mux.Handle("GET /health", handler)

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

When a matching request arrives, the mux effectively calls:

handler.ServeHTTP(w, r)

We do not call ServeHTTP ourselves. The standard library does it for us.

HandleFunc versus Handle

These two methods are similar, so they are easy to confuse.

  • Use HandleFunc when you have a function:
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "healthy")
})
  • Use Handle when you have a value that implements http.Handler:
handler := NewHealthHandler(service)
mux.Handle("GET /health", handler)

A useful shortcut is:

  • function โ†’ HandleFunc
  • value with a ServeHTTP method โ†’ Handle

Applying this to a real health-check package

Now let us return to the original line:

mux.Handle("GET /health", health.NewHandler(healthService))

It becomes easier to understand if we expand it:

healthHandler := health.NewHandler(healthService)
mux := http.NewServeMux()
mux.Handle("GET /health", healthHandler)

The constructor stores the service inside the handler:

type Handler struct {
	service *Service
}

func NewHandler(service *Service) *Handler {
	return &Handler{service: service}
}

The handler uses that stored service when a request arrives:

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	response := h.service.Check(r.Context())
	statusCode := http.StatusOK

	if !response.Healthy() {
		statusCode = http.StatusServiceUnavailable
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)
	_ = json.NewEncoder(w).Encode(response)
}

The handler has a narrow responsibility:

  • Ask the service for the current health result.
  • Choose the correct HTTP status code.
  • Encode the result as JSON.

It does not know how PostgreSQL is checked. That responsibility belongs elsewhere.

Wiring the complete application

The clearest way to read the application wiring is one layer at a time:

pool, err := db.NewPostgres(ctx)
if err != nil {
	return fmt.Errorf("initialize postgres: %w", err)
}
defer pool.Close()

postgresChecker := health.NewPostgresChecker(pool)
healthService := health.NewService(postgresChecker)
healthHandler := health.NewHandler(healthService)

mux := http.NewServeMux()
mux.Handle("GET /health", healthHandler)

Each line gives one object to the next:

PostgreSQL pool
       โ”‚
       โ–ผ
PostgreSQL checker
       โ”‚
       โ–ผ
Health service
       โ”‚
       โ–ผ
Health handler
       โ”‚
       โ–ผ
ServeMux

The shorter version is equivalent:

mux.Handle("GET /health", health.NewHandler(healthService))

The expanded version is often easier to learn and debug. The shorter version is only a convenience; it is not a different design.

Follow one request from beginning to end

Suppose we run:

curl http://localhost:8080/health

Here is what happens:

  1. The HTTP server receives GET /health.
  2. The server passes the request to the mux.
  3. The mux finds the handler registered for GET /health.
  4. The mux calls healthHandler.ServeHTTP(w, r).
  5. The handler calls healthService.Check(r.Context()).
  6. The service runs its registered dependency checkers.
  7. The PostgreSQL checker calls pool.Ping(ctx).
  8. The result returns through the service and handler.
  9. The handler sends JSON and either 200 OK or 503 Service Unavailable.

The dependencies are created from bottom to top during startup:

pool โ†’ checker โ†’ service โ†’ handler โ†’ mux

The request travels from top to bottom at runtime:

mux โ†’ handler โ†’ service โ†’ checker โ†’ pool

Seeing those as two separate moments-application startup and request handling-usually removes much of the confusion.

Why keep the service and handler separate?

We could put everything inside one handler function. That might be fine for a tiny experiment, but separating them gives us useful boundaries:

  • The handler deals with HTTP concepts such as request contexts, JSON, and status codes.
  • The service coordinates health checks and decides whether the application is healthy.
  • Each checker understands one external dependency.
  • main creates and connects the components.

This also makes future changes local. Adding Redis does not require changing the handler:

healthService := health.NewService(postgresChecker, redisChecker)

The handler still asks the same service the same question.

Common beginner misunderstandings

"Does the mux execute the handler during startup?"

No. Registering a handler only saves it for later:

mux.Handle("GET /health", healthHandler)

It executes after a matching request arrives.

"Why do we not call ServeHTTP?"

The HTTP server and mux call it. Our responsibility is to provide a value with the correct method.

"Is NewHandler special Go syntax?"

No. It is an ordinary function following a common naming convention for constructors:

func NewHandler(service *Service) *Handler

"Does the mux know about the service?"

No. The mux knows only about the handler. The handler knows about the service because the service was injected into it.

"Why use a pointer such as *Handler?"

The pointer lets the handler methods work with the same handler value and its stored dependencies without copying the struct. This is the usual pattern for stateful handlers.

Final mental model

If you remember only five ideas, remember these:

  1. http.Server receives HTTP requests.
  2. http.ServeMux chooses a handler based on the request path and method.
  3. A type becomes an http.Handler by defining ServeHTTP.
  4. A handler can store a service in a struct field.
  5. main creates the dependencies and connects them together.

The line that once looked complicated:

mux.Handle("GET /health", health.NewHandler(healthService))

now reads naturally: Create a health handler that uses this health service, then register it for GET /health.

That is the core of mux, handlers, and dependency injection in Go.

Comments

No comments yet. Start the discussion.