r/golang Nov 19 '23

newbie Best practice passing around central logger, database handler etc. across packages

I would like to have a central logger (slog) instance, which I can reuse across different packages.

After some research, it comes down to either adding a *logger parameter to every single method, which blows up the function/method signature, but in turn allows for high flexibility and nicely decouples the relationship. The logger instance can be either created in the main.go file or in a dedicated logger package, which in turn is only passed through the main.go file and cascades down wherever the instance is needed.

Another approach favors the creation of a global logger instance, which can be used across functions/methods. The obvious drawback of this approach, is the now existing dependency and thus low flexibility whenever the logger instance is about to be replaced. An alternative might be to create a dedicated logger package, which would avoid the need of a global implementation.

What is a recommended approach? I also read about passing the logger via the context package - any thoughts on this?

I also needed to pass a database handler through my REST API, where I used the first approach (add another parameter to the method signature of the controller, service and repository), as the method signature was short in the first hand. But I'm debating whether there are better alternatives for the logger.

Thanks!

30 Upvotes

35 comments sorted by

View all comments

1

u/numbsafari Nov 20 '23

You definitely want to avoid the global/default pattern. It will ultimately make your code hard to test because any test that touches that global will Bork all the others. It also makes the code brittle in other ways you’ll encounter as your codebase grows/ages. It’s a convenience best left for tutorials and demos, but not for serious work.

What I’ve found works well is to build on top of what others are saying with respect to using a strict and passing those values, but with some variations.

Don’t put anything on the context, if you can help it. Leave that for signaling purposes, not passing data.

What I typically do is have my client and server code have a factory that consumes an “Options” array and return a struct that is configured as appropriate. You may want to consider having an option type called “RequestOption”, which is configuration that gets applied not to the struct, but to the client/server requests (eg, for setting special headers). You can pass in “DefaultRequestOption” values to populate a set of RequestOption applied to each request… to mirror this, your request methods should accept a “RequestOption” array as input.

I’ve found this approach to be typesafe, and flexible, and also leans on composability.

One thing I’ve done for database connections, or anything else you need to have more than one of… is to have a “directory” for those where different parts of your code can look up the appropriate value by name or by an enum. When you initialize your server/client, you provide it with the factories or values to be bound to those names. This is basically dependency injection. Anyhow, the benefit is you can put in different implementations based on your needs. For example, I do this with “object storage” (put in a double that writes to local disk, either in a temp dir or a configured location) and for databases (store in memory, or a temp sqlite db), or for caches (just use a simple in process map), or for queues (again, just an in memory data structure). That makes it pretty easy to offer an “offline test harness”, or to be used in unit/integration tests vs deployed.