Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Spectral Contexts in Go (hypirion.com)
58 points by todsacerdoti on June 18, 2023 | hide | past | favorite | 36 comments


There's no need to put any dependencies in the context, ever. For HTTP servers, the easiest way is just to use a struct to hold the dependencies:

    type Server struct {
      logger *Logger
      users *UserService
      // etc.
    }
Assuming we use a router like Chi or similar:

    router.Get("/users", srv.HandleUsers)
...where HandleUsers is a method on Server.

Some people like to spread handlers in different packages. You can of course declare handlers as functions with no receiver struct, and pass the data as an argument:

    router.Get("/users", func(w io.ResponseWriter, r *http.Request) {
      userroutes.HandleUsers(srv, w, r)
    })
For middlewares, e.g. something that parses a session cookie and fetches the current user, do exactly the same thing.


Exactly this. Your dependencies are not request scoped, so there is no reason for them to be included in a per-request context. Instead, dependencies should be passed in to and stored in the struct that uses them.

The data you store in the context should be specific to the current request. Think: request ID, user ID, auth info, etc.


Thanks for this perspective - "request scoped" seems like a really good guideline for context data


Wow, thanks for this. This nails a nagging problem I was having at my last job. I’d intuitively figured out what was wrong and what a solution might look like, but the way you’ve articulated it here helped crystallize that understanding much better.

It’s weird. It isn’t a complex problem or solution, but almost every team I’ve worked on hasn’t done this. You get to this point where you’ve seen so much context-stuffing that you half expect to see tools to help you do it in the standard library. You almost wonder if your aversion to it is because you’re the crazy one, haha.


are one-character variables common in Go? every time that i think "maybe i should pick up Go," and then i see the syntax... it gives me pause. seems really ugly!


Yes but every time I've seen it, it's in a small scope, it's very obvious the type of the variable and what it's doing. It's really not as bad as it seems once you spend some time in go.


And there are a lot of conventions so you know, eg, w is an http.ResponseWriter, r is an http.Request pointer, etc.


I felt like you did and I totally get it it. It isn’t what it seems like, though.

It’s actually very pragmatic and once you embrace it, it makes writing and reasoning about Go much easier than you’d expect.

The way you write Go isn’t like most languages I’ve encountered. You write a lot of local variables which typically come from well-named structs and functions, so keeping track of what’s what is extremely low effort. Tracing what something is happens very naturally, and arguably easier because there’s less data to parse.

It’s counter intuitive, but I was extremely resistant to it and now I like it (when writing Go) quite a bit. I don’t do this in other languages I write (like TypeScript or Rust); it’s definitely a Go-ism.


There's a convention to prefer short (1-3 characters) abbreviated identifiers for trivial things. For example, a loop variable:

    i := 0
But a more complex thing might get a fuller name:

    producer := NewProducer()
In the earlier case with the HTTP handlers, "r" and "w" are conventions. They appear so often that it's counterproductive to give them full names. People who read the code would be confused.

Before Go, I did a lot of Java and Ruby, and felt like you did, and went against the conventions. But the code I wrote back when I started with Go now looks foreign to me.


Variable naming conventions are different from syntax, right? The variables in your own code will be the ones you named.

I think this is more prevalent in Go though because go forces you to make many more local variables (since error handling prevents you from composing expressions), and when you need lots of useless local variables for intermediate values with no externally imposed meaning people tend to fall back to things that are easy to write.


Choosing to name variables with a single character has nothing to do with syntax. At most it could be considered idiomatic, but there's no reason to slavishly follow every idiomatic choice, just as there isn't in natural language.


The author demonstrates attaching services to a context, which afaict goes against the Go authors' usage recommendations in https://pkg.go.dev/context:

> Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.


Maintaining Go Code at scale, I can tell you decisively that using the context as opaque bag for all-the-values is a bad idea in terms of maintainability and code comprehensibility. And I write this agnostic to Go as well; I’d feel exactly the same about using thread local storage for dependency injection purposes.

The advice to use the context as the article proposes is a recipe for a regretted design. I guarantee it. It’s a fun, clever thought experiment but should not be applied to production code.


Don't do it. Don't past non-request-scoped data through contexts, and none of these are request-scoped. You can trivially pass such values through middlewares using methods or closures.

Also, while I think I know what "unlike Rust, these phantom types appear at runtime in Go" is saying, it's saying it in a really confusing way. These types do not "appear at runtime"; they are exactly like all other types, constructed at compile time and visible when interface-boxed at runtime. They're in no way "spectral".


"phantom" and "phantom type" are well-understood terms that describe a type with a type parameter that isn't used in the type definition. "spectral" is a pun (phantom -> spectral). It has nothing to do with runtime representation of types.


I'm not a big fan of context variables: they're basically a super clunky kind of goroutine-local global variable, with all the problems you might get with these kinds of mutable global variables. I would argue that if you're having to pass dependencies down, you should be explicitly dependency injecting them instead rather than implicitly via context.


They are not "goroutine local", they are essentially (at least the way they are used) a request-local variable. Which in itself is useful tool.

My only real issue with them is lack of type safety so you have to remember which contest var is what.

For example I'd like to have User context variable that is set somewhere early if user is authenticated but now at every subsequent call there needs to be a bit of fluff to check whether it is right type, else you risk runtime safety.


Not sure I understand the typing issue. The standard pattern is to provide an x.Context(ctx, ...) and an x.FromContext(ctx), and those are the only two functions that know about the context key, and they do the type-casting. Since they are the only functions knowing the context key (the key is often a pointer to an unexported variable), you are guaranteed type-safety as long as there is no unsafe code that can pretend-inject the key.

https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/...


When people mention type safety in this context, they are talking about compiler guarantees. Application code that uses type assertions will never be "type safe" in the way that a compiler can ensure. So while you're correct that if the application code that does the type checking is bug free then the type safety is implied, but is not certain in all cases.


Sure but the interface is typesafe and usually that’s good enough. The two methods of the API (NewContext and FromContext) are typesafe from the perspective of the user.


The guarantee you don't get is that foo.FromContext always succeeds. If *Foo was a struct field of your receiver or an argument to the function, the function would be guaranteed to have access to a *Foo.


We're talking about context variables which are of type any so you need to check them runtime.


I think it always a mistake to start a goroutine without passing a context to it, since you are no longer able to cancel the goroutine unless you build your own mechanism to do so. Given that, I think you can handwave it a bit and regard context variables as goroutine local.


>I think it always a mistake to start a goroutine without passing a context to it, since you are no longer able to cancel the goroutine unless you build your own mechanism to do so

Not really a problem. If you pass a channel you can close the channel. And cancel isn't magical, still need to handle it from within a goroutine and adding a timeout isn't any more or less complex than handling context cancellation

> Given that, I think you can handwave it a bit and regard context variables as goroutine local.

That's extremely wrong way to handle it, especially if it is in a bigger app. There is nothing stopping from changing the values in it in other goroutines for example, and one of designed uses of ctx is scatter-gather pattern of spawning multiple parallel tasks that need to be done to respond to request, and other is using ctx to pass data between middlewares (as is often used in web frameworks in Go at least). So you have to at least take care about variable naming across the project and parallel access to them might happen so you need to take care about not running 2 goroutines wanting to write into same variable.


Passing some state into it from outside makes it the opposite of "local".


How would you do that with standalone functions? You'll need at least one parameter for cancelation, and one parameter for the logging context, and perhaps one more parameter for tracing.

Even passing the single context variable increases clutter a lot.


I tend to put the logger in the context as it becomes request-scoped when you bind the request ID to it in middleware. Zerolog has a useful method to get the logger from the context, which is really handy.


We use strongly typed contexts for:

- Request-scoped parameters like user ID, request ID, etc.

- Dependency injection (database connection and other IO)

- Cancellation

It works great. The one hairy part is when you need to change the context, for example when logging in you take an anonymous context and end up with a logged-in context. We use linters to ensure that stuff is right, but it still takes some thought in the edge cases.

Still, I think those issues are exposing problems in our codebase rather than problems with typed contexts.


Why do you use it for dependency injection? Why can you not pass those parameters directly when instantiating your service?


Because there’s a lot of them. Logging, MQ messages, database connection, emails, S3. If we want to limit which of these are available to the code, we do so using a strongly typed context. (For example a ReadOnlyContext)

The only use of dependency injection for us is testing. Our test fixtures have to provide both request-scoped info (user ID etc.) and mocks for IO. It makes sense to do that all in one object.


Do you use a single context key with a struct or iota keys for each value?


I experimented with the struct because I was too lazy to do the iota keys. It works but it can be a foot gun. We have multiple strongly typed contexts (again one example would be anonymous vs logged in context) and with the struct approach, it’s easy to get in a situation where you lose one of your values by passing the context through a layer that doesn’t know about your types.

I still think these gotchas are indicative of issues in the codebase rather than the struct or the typed context. They only come up in the hairiest parts of the code.


https://blog.khanacademy.org/statically-typed-context-in-go/

i found this to be a good approach for injecting dependencies, especially when the methods implement an interface


Don't create a custom context type. This will make it more difficult to interop with any other library that uses the normal context.Context.[0]

Furthermore, why is that code using string keys for the context? It's recommended in the context package documentation that context keys should always be an unexported type, so that the key can never collide with a different key.[1] It's common to define accessor functions which are type safe

[0] https://google.github.io/styleguide/go/decisions.html#custom...

[1] https://pkg.go.dev/context#WithValue


Agreed, custom context types don’t work for libraries.

But, i am finding them useful in building a pipeline composer similar to https://www.reddit.com/r/RedditEng/comments/z137m3/from_serv...


D has phantom types too but I've never understood why you would intentionally want a type to be unnameable




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: