I’ve been programing for over 20 years in varying professional roles at this point, and there are a few things I’ve learned along the way that helps me keep things straight, especially when working with other developers, or in code bases where I tend to come back after a while away to pick it back up again.
I’m a firm believer that when you write code, you should make everything as obvious as possible. This means no magic global variables, clever tricks, or hidden dependencies. I also think that you should rely on the compiler rather than tests to verify as much as possible, so in a strictly typed language like GoLang you would not rely on passing dependencies through as non-typed to be cast to the correct type further down the callstack. It often leads to runtime errors, and can make testing a lot more difficult than you would like as you need to know all the hidden dependencies.
Mat Ryer wrote an excellent blog posts called How I write HTTP services in Go after 13 years covering a whole list of things he does and has learned through his career. If you haven’t read it yet, it’s worth a look. One of the things he listed was making his handler functions return a http.HandlerFunc rather than implementing it directly. This is also an excellent way to pass global dependencies like a database connection to the handler.
1 2 3 4 5 6 7 8 9 10 11 | func handleSomething(dbConn *sql.DB) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { // use dbConn to load data from db and respond to request } ) } func addRoutes(mux *http.ServeMux, dbConn *sql.DB) { mux.Handle("/$", handleSomething(dbConn)) } |
You can also do this using a struct that takes the dependencies, or even a struct that implements the Handler interface:
1 2 3 4 5 6 7 8 9 10 11 | type IndexHandler struct { DbConn *sql.DB } func (h *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // use h.DbConn to load data from db and respond to requests } func addRoutes(mux *http.ServeMux, dbConn *sql.DB) { mux.Handle"/$", IndexHandler{DbConn: dbConn}) } |
I tend to go with the first option, as I like to keep internal dependencies internal to the struct as much as possible and use a New<Struct> method to set up the struct. This means they can’t be access outside the struct and changed, making them immutable. e.g. I can’t change the dbConn reference later without creating a new struct. And since I would be calling a function to set up the handler, I might as well just call a function directly. This also makes it much easier to navigate directly to code being executed in my IDE, rather than having to know that mux.Handle() calls ServeHttp() on the struct and then find that implementation.
The approach above works well for global dependencies, but what about dependencies that are scoped to a request? I often try to add a request id to log entries for a request, so I can easily group them together. I use the With() method on slog.Logger to add a parameter to all subsequent log write calls. It creates a new instance of the logger that can be passed through to any further methods that needs to write log entries.
I use the following method to set up a new logger with a parameter
1 2 3 4 | func NewRequestLogger(logger *slog.Logger, r *http.Request) *slog.Logger { requestId := uuid.New().String() return logger.With("request_id", requestId) } |
This can be expanded to check a header to pull a request ID from an API Manager, a load balancer, or an upstream system that you would like to be able to trace logs across.
I could call this method on the beginning of every http.HandlerFunc method, but I know I will forget and there is also no simple way to test this. So how can we pass this request scoped dependency through to the handler logic?
A common approach is adding middleware that injects this into the request context with the WithValue() method and makes it the new context for the request. Then in the handler method, we can grab it, cast it to the correct value, and use it as expected. This is both quick and simple, but has a few obvious drawbacks. Main one in my opinion is that the validation of the type happens at runtime rather than during compile time.
I prefer an alternative approach that overrides the function signature for http.HandlerFunc by creating a new function type with *slog.Logger as a parameter. And then wrap all the handlers in a function that sets up and injects the request scoped dependencies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | type HandlerWithRSD func(w http.ResponseWriter, r *http.Request, logger *slog.Logger) type RequestScopedDependencyInjector struct { logger *slog.Logger } func NewRequestScopedDependencyInjector(logger *slog.Logger) *RequestScopedDependencyInjector { return &RequestScopedDependencyInjector{logger: logger} } func (rsd *RequestScopedDependencyInjector) WithRSD(h HandlerWithRSD) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { log := logs.NewRequestLogger(rsd.logger, r) h(w, r, log) } ) } |
This gives us a clean implementation that can be verified by the compiler, and it can easily be expanded by modifying the signature of HandlerWithRSD with something like a session object, and then updating theRequestScopedDepdendencyInjector struct and methods to account for the new features.
My favourite part of this approach is that the compiler will tell you if you missed any handler methods that needs to be updated with the new parameter.
To use this with your routes, you simply have to wrap your routes with the WithRSD() method like below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | func main() { logger := logs.NewLogger("info") in := NewRequestScopedDependencyInjector(logger) dbConn := db.SimpleDB{} http.Handle("/", in.WithRSD(HandleIndex(dbConn))) _ = http.ListenAndServe(":8080", nil) } func HandleIndex(dbConn db.SimpleDB) HandlerWithRSD { return func(w http.ResponseWriter, r *http.Request, logger *slog.Logger) { logger.Info("Index requested") name := dbConn.GetName(logger) _, _ = fmt.Fprintf(w, "Hello %s!", name) } } |
With a bit more setup with log output format and a database connection, it will print the following to stdout. You can easily see which log entries belong together based on the request_id value. If you use a log aggregation system, you can group log entries based on this across multiple services by passing the request id to downstream systems.
1 2 3 4 | {"time":"2025-07-20T16:32:14.834856376+10:00","level":"INFO","msg":"Index requested","request_id":"3c014ea4-4aa6-47a2-bfb6-5a495211ae50"} {"time":"2025-07-20T16:32:14.834883596+10:00","level":"INFO","msg":"Loading name from db","request_id":"3c014ea4-4aa6-47a2-bfb6-5a495211ae50"} {"time":"2025-07-20T16:32:32.426801776+10:00","level":"INFO","msg":"Index requested","request_id":"659cedd4-8359-4165-9b61-857029f960ab"} {"time":"2025-07-20T16:32:32.426814996+10:00","level":"INFO","msg":"Loading name from db","request_id":"659cedd4-8359-4165-9b61-857029f960ab"} |
I’ve created a full working example implementation of this on my GitHub. Any suggestions and improvements are more than welcome.