DEV Community

Cover image for From http.ServeMux to Dependency Injection in Go: A Beginner-Friendly Guide
Harsh Mangalam
Harsh Mangalam

Posted on

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

When I first saw code like this, it felt as if too many things were happening in one line:

mux.Handle("GET /health", health.NewHandler(healthService))
Enter fullscreen mode Exit fullscreen mode

What is a mux? Why are we giving it a handler? How does the handler get the service? Who calls ServeHTTP?

This article answers those questions by starting with the smallest possible Go HTTP server and building toward a clean handler-and-service design one step at a time.

The big idea

An HTTP request moves through the application like this:

GET /health
     │
     ▼
HTTP server
     │
     ▼
ServeMux
     │
     ▼
Health handler
     │
     ▼
Health service
     │
     ▼
Dependency checker
Enter fullscreen mode Exit fullscreen mode

Each part has one job:

  • The HTTP server receives requests.
  • The mux decides which handler should receive each request.
  • The handler translates between HTTP and our application.
  • The service performs the application logic.
  • A checker knows how to inspect one dependency, such as PostgreSQL.

Let us build this structure gradually.

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

There are three important lines here.

First, we create a mux:

mux := http.NewServeMux()
Enter fullscreen mode Exit fullscreen mode

Second, we register a function for GET /health:

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

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

we receive:

healthy
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we pass the function itself:

mux.HandleFunc("GET /health", healthHandler)
Enter fullscreen mode Exit fullscreen mode

We do not call it:

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

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"
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

When a matching request arrives, the mux effectively calls:

handler.ServeHTTP(w, r)
Enter fullscreen mode Exit fullscreen mode

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")
})
Enter fullscreen mode Exit fullscreen mode

Use Handle when you have a value that implements http.Handler:

handler := NewHealthHandler(service)
mux.Handle("GET /health", handler)
Enter fullscreen mode Exit fullscreen mode

A useful shortcut is:

function                         → HandleFunc
value with a ServeHTTP method    → Handle
Enter fullscreen mode Exit fullscreen mode

Applying this to a real health-check package

Now let us return to the original line:

mux.Handle("GET /health", health.NewHandler(healthService))
Enter fullscreen mode Exit fullscreen mode

It becomes easier to understand if we expand it:

healthHandler := health.NewHandler(healthService)

mux := http.NewServeMux()
mux.Handle("GET /health", healthHandler)
Enter fullscreen mode Exit fullscreen mode

The constructor stores the service inside the handler:

type Handler struct {
    service *Service
}

func NewHandler(service *Service) *Handler {
    return &Handler{
        service: service,
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

The handler has a narrow responsibility:

  1. Ask the service for the current health result.
  2. Choose the correct HTTP status code.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

Each line gives one object to the next:

PostgreSQL pool
      │
      ▼
PostgreSQL checker
      │
      ▼
Health service
      │
      ▼
Health handler
      │
      ▼
ServeMux
Enter fullscreen mode Exit fullscreen mode

The shorter version is equivalent:

mux.Handle("GET /health", health.NewHandler(healthService))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The request travels from top to bottom at runtime:

mux → handler → service → checker → pool
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

“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))
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)