55 ms·
Passing the current user ID/tenant ID inside ctx has been super useful for us. We’re already using contexts for cancellation and graceful termination, so our ap
by kgeist 2y ago
Passing the current user ID/tenant ID inside ctx has been super useful for us. We’re already using contexts for cancellation and graceful termination, so our application-layer functions already have them. Makes sense to just reuse them to store user and tenant IDs too (which we pull from access tokens in the transport layer).
We have DB sharding, so the DB layer needs to figure out which shard to choose. It does that by grabbing the user/tenant ID from the context and picking the right shard. Without contexts, this would be way harder—unless we wanted to break architecture rules, like exposing domain logic to DB details, and it would generally just clutter the code (passing tenant ID and shard IDs everywhere). Instead, we just use the "current request context" from the standard lib that can be passed around freely between modules, with various bits extracted from it as needed.
What’s the alternatives, though? Syntax sugar for retrieving variables from some sort of goroutine-local storage? Not good, we want things to be explicit. Force everyone to roll their own context-like interfaces, since a standard lib's implementation can't generalize well for all sitiations? That’s exactly why contexts we introduced—because nobody wanted to deal with mismatched custom implementations from different libs. Split it into separate "data context" and "cancellation context"? Okay, now we’re passing around two variables instead of one in every function call. DI to the rescue? You can hide userID/tenantID with clever dependency injection, and that's what we did before we introduced contexts to our codebase, but that resulted in allocations of individual dependency trees for each request (i.e. we embedded userID/tenantID inside request-specific service instances, to hide the current userID/tenantID, and other request details, from the domain layer to simplify domain logic), and it stressed the GC.
- vbezhenar 2y agoAn alternative is to add all dependencies explicitly into function argument list or object fields, instead of using them implicitly from the context, without documentation and static typing. Including logger.
- kgeist 2y agoI already talked about it above. Main problems with passing dependencies in function argument lists: 1) it pollutes the code and makes refactoring harder (a small change in one place must be propagated to all call sites in the dependency tree which recursively accept user ID/tenant ID and similar info) 2) it violates various architectural principles, for example, from the point of view of our business logic, there's no such thing as "tenant ID", it's an implementation detail to more efficiently store data, and if we just rely on function argument lists, then we'd have to litter actual business logic with various infrastructure-specific references to tenant IDs and the like so that the underlying DB layer could figure out what to do. Sure, it can be solved with constructor-based dependency injection (i.e. request-specific service instances are generated for each request, and we store user ID/tenant ID & friends as object fields of such request-scoped instances), and that's what we had before switching to contexts, but it resulted in excessive allocations and unnecessary memory pressure for our highload services. In complex enterprise code, those dependency trees can be quite large -- and we ended up allocating huge dependency trees for each request. With contexts, we now have a single application-scoped service dependency tree, and request-specific stuff just comes inside contexts. Both problems can be solved by trying to group and reuse data cleverly, and eventually you'll get back to square one with an implementation which looks similar to ctx.Context but which is not reusable/composable. >Including logger. We don't store loggers in ctx, they aren't request-specific, so we just use constructor-based DI.
- TeMPOraL 2y agoI believe this problem isn't solvable under our current paradigm of programming, which I call "working directly on plaintext, single-source-of-truth codebase". Tenant ID, cancellations, loggers, error handling are all examples of cross-cutting concerns. Depending on what any given function does, and what you (the programmer) are interested in at a given moment, any of them could be critical information or pure noise. Ideally, you should not be seeing the things you don't care about, but our current paradigm forces us to spell out all of them, at all times, hurting readability and increasing complexity. On the readability/"clean code", our most advanced languages are operating on a Pareto frontier. We have whole math fields being employed in service of packaging up common cross-cutting concerns, as to minimize the noise they generate. This is where all the magic monads come from, this is why you have to pay attention to infectious colors of your functions, etc. Different languages make slightly different trade-offs here, to make some concerns more readable, but since it's a Pareto frontier, it always makes some other aspects of code less comprehensible. In my not so humble opinion, we won't progress beyond this point until we give up on the paradigm itself. We need to accept that, at any given moment, a programmer may need a different perspective on the code, and we need to build tools to allow writing code from those perspectives. What we now call source code should be relegated to the role of intermediary/object code - a single source of truth for the bowels of the compiler, but otherwise something we never touch directly. Ultimately, the problem of "context" is a problem of perspective, and should be solved by tooling. That is, when reading or modifying code, I should be able to ignore any and all context I don't care about. One moment, I might care about the happy path, so I should be able to view and edit code with all error propagation removed; at another moment, I might care about how all the data travels through the module, in which case I want to see the same code with every single goddamn thing spelled out explicitly, in the fashion GP is arguing to be the default. Etc. Plaintext is fine. Single source of truth is fine. A single all-encompassing view of everything in a source file is fine. But they're not fine all together, all the time.
- xyz-x 2y agoMonads but more importantly MonadTransformers so you can program in a legible fashion. However, there's a lot of manual labour to stuff everything into a monad, and then extract it and pattern match when your libraries don't match your choice of control flow monad(s)! This is where I'd prefer if compilers could come in. Imagine being in the bowels of a DB lib, and realising that the function you just write might be well positioned to terminate the TCP connection that it's using to talk to the database with. Oh no: now you have to update the signature and every single call-site for its parent, and its parent, and... Instead, it would be neat if the compiler could treat things you deem cross-cutting as a graph traversal problem instead; call a cancelable method and all callers are automatically cancelable. Decisions about whether to spawn a cancelable subtree, to 'protect' some execution or set a deadline is then written on an opt-in basis per function; all functions compose. The compiler can visualise the tree of cancellation (or hierachical loggers, or OT spans, or actors, or green fibers, or ...) and it can enforce the global invariant that the entry-point captures SIGINT (or sets up logging, or sets up a tracer, or ...). So imagine the infrastructure of a monad transformer, but available per-function on an opt-in basis. If you write your function to have a cleanup on cancellation, or write logs around any asynchronous barrier, the fiddly details of stuffing the monad is done by the compiler and optionally visualised and explained in the IDE. Your code doesn't have to opt-in, so you can make each function very clean.
- cle 2y ago> instead of using them implicitly from the context, without documentation and static typing This is exactly what context is trying to avoid, and makes a tradeoff to that end. There's often intermediate business logic that shouldn't need to know anything about logging or metrics collection or the authn session. So we stuff things into an opaque object, whether it's a map, a dict, a magic DI container, "thread local storage", or whatever. It's a technique as old as programming. There's nothing preventing you from providing well-typed and documented accessors for the things you put into a context. The context docs themselves recommend it and provide examples. If you disagree that this is even a tradeoff worth making, then there's not really a discussion to be had about how to make it.
- vbezhenar 2y agoI disagree that it's a good approach. I think that parameters must be passed down always, as parameters. It allows compiler to detect unused parameters and it removes all implicitness. It is verbose indeed and may be there should be programming language support to reduce that verbosity. Some languages support implicit parameters which proved to be problematic but may be there should be more iterations on that manner. I consider context for passing down values to do more harm than good.
- cle 2y agoIt's nothing to do with verbosity, which is why I didn't mention it.
- danudey 2y agoOther responses cover this well, but: the idea of having to change 20 functions to accept and propagate a `user` field just so that my database layer can shard based on userid is gross/awful. ...but doing the same with a context object is also gross/awful.
- bvrmn 2y agoYou can't add arguments to vendor library functions. It's super convenient to have contexted logging work for any logging calls.