Design: Context

It has always been there, but you might have never noticed it

Jérôme Beau
8 min readJun 25, 2024

What is it?

Generally speaking, context is something that may influences the way you perform a given operation. This definition can apply to:

  • data at various scopes: function arguments, current object’s state (this.field), static/global variables, system/OS environment.
  • code at the same various scopes: local functions, current object’s methods (this.method()), static/global functions, system/OS API.

Nothing new under the sun. Contextualization is about how you use them.

How to contextualize?

In two words, contextualization is a bound shortcut, which allows you to use information implicitly instead of explicitly.

Let’s illustrate that with a simple example.

Non-contextual way

Imagine you have a service that allows to save user data:

class UserService {

update(user) {
// Update the new user state in database
}
}

to use it, you need to provide explicit data (user) as an argument to the update() method:

user = new User()
userService = new UserService()
// Change user state here
userService.update(user)

The contextual way

Using a context, the service we provide will be a contextualized one, which already knows about contextual data. You can view this as an adaptation of the original UserService, to remove the need for a parameter:

class UserServiceAdapter {

constructor(private context: Context, private userService: UserService) { }

save() {
this.userService.update(this.context.user);
}
}

Once you have a context-adapted service, you can declare it as part of your whole Context API:

class Context {

readonly userService: UserServiceAdapter

constructor(readonly user: User, userService: UserService) {
this.user = new UserServiceAdapter(userService)
}
}

So now you can setup a context, then use the contextualized service:

user = new User()
context = new Context(user, new UserService())
// Change user state here
context.user.save()

Hierarchies

From that pattern, you can imagine further sophistications using:

  • more fine-grained APIs: context.userService could have more specialized sub-services itself, and so on. On context change, this would allow to replace either a whole set of services or just a branch/left of it.
  • more coarse-grained APIs: conversely, a context-dependent (sub-)service can be leveraged or impacted by a change at a higher facade. For instance, a simple context.save() method could trigger context.userService.save() internally as well as other consistent calls to other sub services. You could also devise some way to access “parent” context (if allowed), either implicitly (context.getData(“parentKey")) or explicitly (context.parentContext.data).

Conditionality

Up to now, we only used contextualization to read/write data. What if want to behave in different ways, depending on context? Handling control flows through it is a good use case to grasp the potentialities of this pattern.

Level 1 : The ad hoc if

When you have a condition to check, the most intuitive way to do it is to add “if” statement:

myFunction (param) { 
if (param == 'someValue') {
doSomethingSpecial(param)
}
}

This is also the worst. Because the parameter you added:

  • made the function code more complex ;
  • made the callers (and sometimes the callees) code more complex in layers above/before, by constraining them to add arguments.

Of course, this will get worse as you’ll need to handle more and more cases:

myFunction(param, param1, param2) { 
if (param1 !== 'val1') {
if (param2 === 'val2' || param1 !== 'no2') {
doSomething(param, param1, param2)
} else {
doSomethingElse(param, param2)
}
}
}

As you can guess, this “parameters hell” can be applied to objects constructors as well (which has been mitigated by a context design: Dependency Injection systems).

Level 2: Context data

The next step is a Redux-like solution: encapsulate all variables (all app state) in a object that is alway accessible:

myFunction(context) { 
if (context.param1 !== 'val1') {
if (context.param2 === 'val2' || context.param1 !== 'no2') {
doSomething(context)
}
}
}

This improves the parameters solution by encapsulating all data in a context object. This will make your code:

  • simpler, by reducing the number of required parameters;
  • more flexible, by allowing you to change/add/remove which data you use, without changing functions signatures.

But this is still a bunch of poorly readable ifs.

Level 3: Context code

A way to simplify your code readability is to put it in a dedicated function. When using a context API, that function can be a method of the context itself:

myFunction(context) {
if (context.valsChecks()) {
doSomething()
}
}

The code is now more readable… and context-dependent. Delegating to the context, means that the implementation of valChecks() can vary depending on it:

class Context {

var1, var2

constructor(readonly user) {}

valsCheck(): boolean {
return this.user.isAdmin()
? this.var1 == "val1"
: this.var1 == "val1" && this.var2 == "val2"
}
}

So this delegation of the data check to the context allows the check to depend on the context, while keeping the code simpler. Simpler, but not as simple as it could be, as we still have a “if” in if (context.valsCheck()).

Level 4: Indirection

In some cases, it might make sense delegating the whole thing to the context:

myFunction(context) {
context.doSomething()
}

So that the checks are encapsulated in the context it as well:

class Context {

constructor(readonly user) {}

doSomething(): boolean {
return this.user.isAdmin() ? doSomethingAdmin() : doSomethingNormal()
}
}

This way, you’re able to change behavior depending on context. You can also change, add or remove some behaviors without having to change any of the callers.

Level 5: observe context

As a context can hold several different info, it may have to impact a number of different software components. For instance, if context.user changes, it may require to update a number of things, from user data to user privileges and other capabilities.

To implement such a “reactive” context, you can listen to its change:

context$.subscribe(newContext => {
this.context = newContext
updateUserView(this.context.user)
})

However, listening for the whole context reference might not be the best idea, since any update might not be about the user, and so you would trigger unncessary updates. To avoid this, just listen to what is of interest to you:

context.user$.subscribe(newUser => {
updateUserView(newUser) // Or context.user
})

Examples

Should you wonder why you should develop context-oriented code, here are a few use cases and implementations examples.

Use cases

What can you put in a context? Usually, a context is useful to sync with things that can change. This can be the currrent user, its language, the current page… many things.

Here is a non-limitative list of usage examples:

  • execute the same API in different ways, depending on context’s user privileges, language or feature flags.
  • testing different behavior on different populations (A/B testing): some users would have some A context and others a B context, without having to change any calling code of the app.
  • execute code according to some context’s transactional status. However note that context should not change during a transaction.
  • fetch more or less data;
  • display notification at different levels depending on user or current application state. For instance, displaying raised errors in a form rather that the default notifications system, because you’re in the context of filling a form.
  • Display logs according to the current context (and possibly a stack of nested contexts)

Implementations

If you wonder why you should invest in developing context-oriented code, just remember that this is nothing new, and has been used by various major software:

  • Redux has been a very trendy context implementation at some time;
  • React features a context API;
  • AngularJS used to provide components a “scope” that would typically include data — and even code — required to perform their operations. Those contexts were nested in a hierarchy from a “root” scope/context to more local contexts;
  • Some languages feature context/implicit reference, such as the Python’s with keyword, or Java’s “try with resource” feature which, in a way, handles an implicit resource management;
  • Some security patterns use Contextual code, as in Capability/Guarded objects which more secure than ACLs;
  • dependency injection (DI) systems implement a context of dependencies/resources.

How

Now that we understand how to implement a context and use it, how can we integrate it in an app? The context is an object, so we need to get a reference to it, virtually from anywhere.

In his presentation about a solution to instrument Node applications in production, Thomas Watson outlined the usual two approches for accessing context.

There are a few strategies for doing that:

  • pass it as a parameter: this is the most straightforward way, and also the safest regarding shared accesses, since the context is in your call stack. This is also the most tedious one, unfortunately, because of the recurring context parameter.
  • read it from a defined place such as an object field, a static constant, a static field of a class, or even the global space (globalThis, window). This can be convenient in some cases, but also implies risks of concurrent read/write access (should you use workers), lost updates, and is not compatible with the idea of instantiating multiple context instances (such as when building a hierarchy of nested contexts, from call to call).
  • TLS (Thread Local Storage) exists in platforms such as Java or Python. In Node, a CLS (Continuation-Local Storage) API allows to store data that remain accessible through async calls (callbacks, promise chains). It used to be “async hooks” and has now be standardized as "async context”. However this still requires API calls like asyncLocalStorage.getStore() and doesn’t solve the problem of having a reference to it from anywhere. Furthermore, it is a Node-only solution that won’t provide you context access from a web client.

As you can see, there is no absolute recommendation for accessing a context. It mostly depends on your usage, but maybe the “parameter” way is both the more flexible (for nesting different contexts), simplest, safest, and the most ubiquitous (client/server) one.

Drawbacks?

After implementing context in several apps, what red flags should we care about?

  • God object: There is an anti-pattern named like that, which points that you should not build nor refer to an object which aggregates nearly everything. Whereas the biggest context of an app could be considered a god object, it has not to be: each code using a context can be provided a suited context, limited to what it needs. Also, keep in mind that a context is typically a adaptation of some app’s data and services, and does not hold/manage them by itself.
  • Circular dependencies: If your context references data or services, what if those data or services also reference the context? To avoid this, make sure to either not reference the context, or at least not the actual implementation of it (interface, typically).
  • Lazy loading capabilities might impact your context implementation, since some services might not be available immediately. For instance accessing context.services.someModule would require loading it.
  • Fuzzier spec: As when a Dependency Injection API, using a context means requiring dependencies from the code and not “declaring” them in signature of functions or constructor. This is the price to pay for better flexibility, which saves you refactoring hassle as soon as you need to add or remove a dependency. However this can be mitigated by using different context types, each of these being dedicated to a specific use case, and so publishing a specific set of data and services.

Conclusion

Contexts-oriented programming uses delegation and adaptation to make code simpler (no more constructor injection hell, less functions parameters) and more flexible (easy to add a new context field, easy to change the context implementation to impact all relevant callers).

--

--