https://hypirion.com/musings/spectral-contexts-in-go Polymatheia * Home * Thinkies * Reflections * About Spectral Contexts in Go posted 18 Jun 2023 If you're attaching values to Go contexts, you probably do that in a single location like so: type contextKey string const ( userIDKey contextKey = "userID" // etc... ) func GetUserID(ctx context.Context) UserID { return ctx.Value(userIDKey).(UserID) } // etc... func (s *Server) WithUserData(ctx context.Context, req http.Request) (context.Context, error) { ctx = s.withRequestDataLoaders(ctx) ctx, err := s.withUserAndTeamID(ctx, req) if err != nil { return nil, err } ctx, err = s.withIPAddress(ctx, req) if err != nil { return nil, err } ctx = s.withLogger(ctx, req) return ctx, nil } It's fine, but a bit boilerplatey: You have to define XXXRequestValue and a GetXXX utility function per element. It's not very boilerplatey, but enough that I get annoyed by it. If you're like me and usually attach a type once, then it's possible to use phantom types to attach singletons to a context: package ctxx import "context" type ctxKey[T any] struct{} // WithSingleton attaches val to the context as a singleton. func WithSingleton[T any](ctx context.Context, val T) context.Context { return context.WithValue(ctx, ctxKey[T]{}, val) } // Singleton returns the single value T attached to the context. // If there is no value attached, the zero value is returned. func Singleton[T any](ctx context.Context) T { val, _ := ctx.Value(ctxKey[T]{}).(T) return val } A phantom type is a type with a type parameter that isn't used in its type definition. In this case, ctxKey is a phantom type because T isn't used. While the binary representation of ctxKey[UserID]{} and ctxKey [TeamID]{} is the same when we know their concrete type, they differ when converting them to interface values. Interface values are a tuple of type and value under the covers, which is why any(ctxKey[UserID]{}) == any(ctxKey[TeamID]{}) is always false, and that's what we can take advantage of. With this new ctxx package, we can attach request values like this func (s *Server) WithUserData(ctx context.Context, req http.Request) (context.Context, error) { ctx = ctxx.WithSingleton(ctx, s.newDataLoaders()) userID, teamID, err := s.userAndTeamID(req) if err != nil { return nil, err } ctx = ctxx.WithSingleton(ctx, userID) ctx = ctxx.WithSingleton(ctx, teamID) ipAddr, err := s.findIPAddress(req) if err != nil { return nil, err } ctx = ctxx.WithSingleton(ctx, idAddr) // net.IP seems too generic, so I wrap it in a UserIP type ctx = ctxx.WithSingleton(ctx, s.newLogger(req, userID, teamID)) return ctx, nil } and retrieve them like this userSvc := ctxx.Singleton[UserID](ctx) which is a bit more handy in my experience. Context Values are Alright People have opinions on whether you should add values to your context or not. If I could, I would prefer a typed context like this: type MyContext struct { Context context.Context UserID UserID TeamID TeamID UserIP UserIP // (wrapper around net.IP) // etc } This one usually won't survive through middlewares or external libraries though, as those will wrap the context with cancellations and whatnot, causing the typed context to be lost. As I'd prefer a typed alternative if possible, I played around with typed contexts in a previous blog post. However, using one gets too unwieldy and heavy to use in practice, and the type won't survive through external third-party middlewares either. Attaching context values in a type-unsafe manner is the only way you can really use middleware and third-party libraries effectively, so the question is whether you should or not. Personally, I have never experienced any issues with attaching and fetching context values, but I think that's heavily related to how I use them. I never conditionally set context values, and they are mostly used to pass data down through middleware for GRPC/HTTP/ GraphQL handlers, not further down the chain. I think context values are a necessary evil for passing data through parts you don't control, but as long as you have control you should pass stuff down as parameters. --------------------------------------------------------------------- A previous version of this blog post had services as an example of what you propagate down via contexts. That was silly: There are cases you can't get around it, but you can typically use struct methods as handlers and fetch the services from struct fields or methods instead. Copyright 2023 Jean Niklas L'orange