Generally, context.Context
is used to propagate cancellation signals, deadlines, and request-scoped values.
The definition of request-scoped is often stretched to anything needed to process this request. My personal pet peeve is seeing it used for Dependency Injection.
I have learned the hard way to avoid using context.Context
for Dependency Injection and shared state that alters behaviour.
Using context.Context
as a Dependency Injection and/or shared state object is confusing for maintainers and consumers of a package. In my experience this results in the following side effects:
Say we have a service method which requires a *sql.DB
as input.
// package user
func (s Service) GetUserByID(ctx context.Context, db *sql.DB, id int64) (*User, error) {
row := db.QueryRowContext(ctx, "select * from users where id = $1", id)
// stuff with row
}
// package main
func main() {
db, err := sql.Open("driver-name", *dsn)
if err != nil {
// ...
}
defer db.Close()
svc := user.NewService()
u, err := svc.GetUserByID(context.Background(), db, 1)
//...
}
It is repetitive to have to pass in sql.DB
every time we want to fetch a user.
Below is an example of how I have seen context.Context
used to solve this problem. The intent is to design service.GetUserByID
so that it is easier to consume and reduce repitition. By propagating a frequently used object, sql.DB
, through shared state, context.Context
, we don't have to force all of our method signatures to define sql.DB
as a parameter. This is a form of Dependency Injection.
// package user
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error) {
db := context.Value("db").(*sql.DB)
row := db.QueryRowContext(ctx, "select * from users where id = $1", id)
}
// package main
func main() {
db, err := sql.Open("driver-name", *dsn)
if err != nil {
// ...
}
defer db.Close()
ctx := context.WithValue(context.Background(), "sql.DB" /*context key*/, db)
svc := user.NewService()
u, err := svc.GetUserByID(ctx, 1)
//...
}
Looks great! Now callers don't have to pass in the sql.DB
as a separate input. What's the catch?
The complexity has shifted from the method signature. It is now hidden it behind an obscure object context.Context
.
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error)
The compiler will tell us this method only requires 2 input parameters. We know this is not true. It only appears as though it requires 2 inputs. This method clearly requires 3 inputs, or it will not run as expected. One of the inputs is obfuscated behind context.Context
but it is not communicated in the method signature. This will have more consequences.
Using our example, there is nothing to communicate to the compiler that the method expects sql.DB
to be passed in through context.Context
.
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error)
In reality, first we have to run some logic to instantiate sql.DB
, then more logic to inject the sql.DB
into context.Context
. Only then can we safely invoke the service method. This is a coupling in the temporal dimension. A
must invoke B
before invoking C
.
The coupling itself is not the issue. The problem is it is not communicated clearly in the method signature.
This can become a hurdle when refactoring. Typically context.Context
is propagated through the entire stack, but not all methods/functions use all the values in context.Context
. This method of Dependency Injection leads to more implicit couplings deep in a stack.
Function A
injects sql.DB
into context, then calls B
which calls C
and so on until Z
extracts the dependency it needs from context.Context
. Functions B
all the way to Y
don't depend on sql.DB
directly. Remove A
and now any caller to Z
breaks with a nil pointer exception.
Looking at our original example with obfuscated inputs (listed again below), it has been established that this implementation has obfuscated the inputs to the method and fails to communicate a temporal coupling. As mentioned the consequences of this are a nil pointer exception.
// package user
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error) {
db := context.Value("db").(*sql.DB)
row := db.QueryRowContext(ctx, "select * from users where id = $1", id)
}
// package main
func main() {
svc := user.NewService()
u, err := svc.GetUserByID(ctx, 1)
//...
}
The above implementation will compile even though we failed to set our sql.DB
in context.Context
. How is a reader supposed to know that there was a specific value, sql.DB
, expected in context.Context
? The only way to know would be to read the implementation or wait to run into an nil pointer exception to uncover the truth.
On top of the issues around readability and maintainability, if a nil pointer exception like this slips into a live environment it will cause disruptions for you, your colleagues and worst of all your customers.
In a small codebase, it may be easy to read the implementation. When you have a very large codebase or a deep call stack it is not as productive to ask that every developer read through all implementations to make sure they didn't miss a hidden input.
The solution I prefer is explicit Dependency Injection for service-wide dependencies like logger
and sql.DB
using factory functions.
Let's walk through options
We could catch these issues in code review, but in practice people make mistakes. If you're adding a new dependency via context.Context
a reviewer could miss one critical path. In practice, that critical path may not even appear in a diff since the changes would not affect any function/method signatures.
We could catch these exceptions through tests. In practice this has a few issues:
In my experience, trying to catch errors in this scenario (using context.Context
for dependency injection) just doesn't always work.
We could avoid the exception using defensive programming with a type assertion and return an error early.
This avoids the nil pointer exception but the method signature is still misleading readers to believe there are only 2 required inputs. In practice, the error will bubble up and likely result in the same error for our users and still cause service disruptions.
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error) {
db, ok := context.Value("db").(*sql.DB)
if db == nil || !ok {
return nil, fmt.Errorf("db must be set in context")
}
// ...
}
Instead of returning an error, we could try to fallback to a default value. In practice, I don't see how this could work in a clean way for sql.DB
unless the connection string is propagated as well (which brings us back to our original problem). This opens up more risks in the case of sql.DB
to issues like leaking connections.
Below is an example if we had another dependency, Logger
propagated through context.Context
and wanted to make sure there was always a Logger
available
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error) {
logger := extractLogger(ctx)
logger.Info("I will never fail now")
}
func extractLogger(ctx context.Context) log.Logger {
logger, ok := context.Value("logger").(log.Logger)
if logger == nil || !ok {
return log.New()
}
}
What would be better is if the references (to logger
and sql.DB
) were injected directly into the Service
through a factory function. sql.DB
and logger
are service dependencies. It is likely that they will be used across many or all methods of our service.
In the example below, the NewService
factory function signature clearly communicates the expected service dependencies. The factory function should validate the inputs explicitly on initialization as opposed to at run-time. This way we can rollback a deployment before serving requests, reducing impact to users.
This also solves the issue around making our service method easier to use, since we no longer have to provide logger
or sql.dB
as an input to every method. It also better communicates temporal coupling as our factory explicitly states its inputs.
type Service struct {
logger log.Logger
db *sql.DB
}
func NewService(db *sql.DB, logger log.Logger, opts ...func(*service)) (*Service, error) {
if db == nil {
return nil, fmt.Errorf("db must not be nil")
}
// We could check if logger is nil, and return an error
if logger == nil {
return nil, fmt.Errorf("logger must not be nil")
}
// We could fallback to a default logger
if logger == nil {
logger = log.NewLogger()
}
// We could omit `logger` from the signature entirely and set a default logger
// We could use functional options to allow injecting an override
logger = log.NoopLogger()
for _, opt := range opts {
opt(service)
}
return &Service{
logger: logger,
db: db,
}, nil
}
func (s Service) GetUserByID(ctx context.Context, id int64) (*User, error) {
s.db.Query(/*...*/)
s.logger.Info("I will never fail now")
}
// package main
func main() {
db, err := sql.Open("driver-name", *dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
svc := user.NewService(db)
u, err := svc.GetUserByID(context.Background(), 1)
//...
}
context.Context
correctlyMy advice at time of writing would be to limit usage to:
My preference is to leverage the compiler as much as possible and be explicit about expected inputs.
Any values required as inputs to functions and methods should be defined explicitly as input parameters. When methods have many required inputs, lean on composing related inputs with the factory and builder pattern.
For service-wide dependencies like sql.DB
or loggers, use factory functions. Avoid hiding dependencies in context.Context
to minimize run-time exceptions and make your code easier to read.
Hold on, isn't a logger request-scoped?
From my perspective, no. Frequently I have come across the pattern where a logger is instantiated with request-scoped fields and propagated through
context.Context
. All logs written using this request-scoped logger report with the request-scoped metadata.While the pattern is well established, it is not necessary. A logger can be implemented to look for specific keys in
context.Context
and extract these values every time a message is logged.I have a work-in-progress logger implementation that works like this here.