2019-01-29
Last week, we all became experts on raw function decoration. Now, we’re able to extract repetitive aspects of our code into decorators, and we can apply these decorators to any raw function. First, we start with a raw function:
async function _fetchUser(username) {
return await axios.get(`${BASE}/users/${username}`);
}
And then, we decorate it:
const fetchUser = compose([
NotifiesOnFailure(["admin@briandamaged.org"]),
LogsErrors("Error while fetching User"),
MeasuresTransaction("fetch_user"),
])(_fetchUser);
See? It’s so simple, right? We even open-sourced a library to encapsulate the details. Ho hum. Life is boring.
Then again… this particular application of decorators only seems appropriate for production environments. Do we really want to send notification emails whenever an error occurs on our local environment? Probably not. So, let’s fix that:
const decorators = [];
// We only want to send notifications when the code
// is running on the "production" environment:
if(process.env.NODE_ENV === 'production') {
decorators.push(
NotifiesOnFailure(["admin@briandamaged.org"])
);
}
// We want to log errors on _every_ environment
decorators.push(
LogsErrors("Error while fetching User")
);
// Hmm... do we really want to measure the transaction
// performance on _every_ environment? Probably not.
// But, for now, we'll just pretend that we do.
decorators.push(
MeasuresTransaction("fetch_user")
);
const fetchUser = compose(decorators)(_fetchUser);
Ugh… why does it feel like we suddenly made the code worse again? Well, it’s because we did make it worse. Specifically:
Array
operations, which hinders readability.Ideally, we’d like to have a solution that looks like the first example, but behaves like the second example. But how?
Well, the code that instantiates/applies the decorators really doesn’t care what those decorators actually do. So, what if we created a decorator that simply did nothing? Something like this:
// This decorator simply returns the `next` function
// without actually decorating it.
const pass = (
(next)=>
next
);
Of course, NotifiesOnFailure(..)
is actually a decorator factory. So, what we really want is a decorator factory that creates a decorator that does nothing. Something like this:
const Pass = (
(...args)=>
(next)=>
next
)
Zoinks! With this invention, it means that we can rewrite our code as follows:
// We'll change the implementation of `NotifiesOnFailure`
// depending upon our operating environment.
let NotifiesOnFailure;
if(process.env.NODE_ENV === 'production') {
// Use the *real* implementation in production
NotifiesOnFailure = require('./lib/NotifiesOnFailure');
} else {
// Just "do nothing" in all other environments
NotifiesOnFailure = require('./lib/Pass');
}
const fetchUser = compose([
NotifiesOnFailure(["admin@briandamaged.org"]),
LogsErrors("Error while fetching User"),
MeasuresTransaction("fetch_user"),
])(_fetchUser);
Ta daa! Now the implementation of NotifiesOnFailure(..)
is chosen based upon the operating environment! If the code is running in production, then NotifiesOnFailure(..)
creates a decorator that will send emails; otherwise, it creates a decorator that will “do nothing”.
Okay, this is definitely a step in the right direction! Our decorators are being applied in a declarative fashion again, which significantly improves readability. But, the code is still tightly-coupled to the notion of “environments”. How do we fix that?
Easy: let’s use encapsulation! Our code ultimately doesn’t care why we’re only sending emails in the production environment. It just needs to know our decision. So, let’s encapsulate this decision process inside of a function:
function notificationsAreNeeded() {
return (process.env.NODE_ENV === 'production');
}
Now we can rewrite our logic for choosing the NotifiesOnFailure(..)
implementation as follows:
let NotifiesOnFailure;
if(notificationsAreNeeded()) { // LOOK HERE!
// Use the *real* implementation in production
NotifiesOnFailure = require('./lib/NotifiesOnFailure');
} else {
// Just "do nothing" in all other environments
NotifiesOnFailure = require('./lib/Pass');
}
Huzzah! The code is no longer directly coupled to the operating environments anymore… but it’s still cumbersome.
Sigh… if only there was a way to encapsulate this pattern as well. I mean, this seems like the type of logic we’d want to apply to our decorators frequently. It could be like… a decorator… for decorating… decorators…
But wait: that actually makes sense! A decorator is a function, and therefore it can be decorated! So, let’s encapsulate this pattern! Specifically, we want something that will “install” a decorator when a certain condition is true; otherwise, it will “do nothing”:
const InstallsWhen = (
(condition)=>
(
(condition())
? (decorator)=> decorator
: Pass
)
);
With this decorator-decorator in place, we can now refactor our example yet again:
const fetchUser = compose([
InstallsWhen(notificationsAreNeeded)(
NotifiesOnFailure(["admin@briandamaged.org"])
),
LogsErrors("Error while fetching User"),
MeasuresTransaction("fetch_user"),
])(_fetchUser);
Wooo! Now everything is perfect!!!
…
Let’s make it even more perfect.
So far, we’ve been changing the manner in which decorators are installed. But, what if we want to change the manner in which a decorator is applied? For instance, what if we only wanted to log 1% of all errors? Sure, we could bake that logic into the LogsErrors(..)
decorator, but it would certainly be messy.
Instead, we’ll define an AppliesWhen(..)
decorator-decorator. This decorator-decorator will only apply its wrapped decorator when a runtime condition evaluates to true
; otherwise, it will bypass the wrapped decorator. Like this:
const AppliesWhen = (
(condition)=>
(decorator)=>
(next)=>
async function() {
if(await condition.apply(this, arguments)) {
// Apply the wrapped decorator
return decorator(next).apply(this, arguments);
} else {
// Bypass the wrapped decorator
return next.apply(this, arguments);
}
}
);
Alright — let’s revise our code so that it only logs 1% of the errors:
const fetchUser = compose([
InstallsWhen(notificationsAreNeeded)(
NotifiesOnFailure(["admin@briandamaged.org"])
),
AppliesWhen(()=> Math.random() <= 0.01)(
LogsErrors("Error while fetching User")
),
MeasuresTransaction("fetch_user"),
])(_fetchUser);
Not bad! Of course, the probabilistic application of a decorator seems like a typical use-case for the AppliesWhen(..)
decorator-decorator. So, why don’t we encapsulate this use-case as well?
const Occasionally = (
(p)=>
AppliesWhen(()=> Math.random() <= p)
);
Now we have an even higher-level decorator-decorator that encapsulates this specific use-case! So, let’s revise our code once more:
const fetchUser = compose([
InstallsWhen(notificationsAreNeeded)(
NotifiesOnFailure(["admin@briandamaged.org"])
),
Occasionally(0.01)(
LogsErrors("Error while fetching User")
),
MeasuresTransaction("fetch_user"),
])(_fetchUser);
In the words of the NBA Jam Announcer: BOOM-SHAKA-LAKA!
To recap: we recognized that the installation/application of our decorators might be influenced by external factors, such as the operating environment. In many cases, these details can be abstracted away through the use of decorator-decorators. This allows the code to remain declarative yet still offers an incredible degree of flexibility and precision.