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
HandleFuncwhen you have a function:
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "healthy")
})
- Use
Handlewhen you have a value that implementshttp.Handler:
handler := NewHealthHandler(service)
mux.Handle("GET /health", handler)
A useful shortcut is:
- function โ
HandleFunc - value with a
ServeHTTPmethod โ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:
- The HTTP server receives
GET /health. - The server passes the request to the mux.
- The mux finds the handler registered for
GET /health. - The mux calls
healthHandler.ServeHTTP(w, r). - The handler calls
healthService.Check(r.Context()). - The service runs its registered dependency checkers.
- The PostgreSQL checker calls
pool.Ping(ctx). - The result returns through the service and handler.
- The handler sends JSON and either
200 OKor503 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.
maincreates 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:
http.Serverreceives HTTP requests.http.ServeMuxchooses a handler based on the request path and method.- A type becomes an
http.Handlerby definingServeHTTP. - A handler can store a service in a struct field.
maincreates 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.