03. Enforcer Pattern

tl;dr October 9, 2019

How can you cut down small if-else statements that recur across several functions? Let’s cover another pattern for nuking if-else statements on today’s episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.

Transcript

Over the past few episodes, we’ve been covering design patterns to help cut down the size and depth of if-else statements. If you’re new to this vendetta against if-else statements, hop back to the episode on nested ternaries to get up to speed.

Nested ternaries and the Router design pattern have helped us reduce the size and depth of cascading if-else statements, but we haven’t dealt with terse, non-cascading if-else statements that get copy-pasted across functions. These if-else statements often appear at the beginning of the function as a guard clause. They’re innocent and short, but like a weed they reproduce with each new feature, and the duplication is tricky to eradicate.

Today we’re continuing to work on a chatbot that helps outdoor enthusiasts find great trails to hike. This chatbot can respond to simple text commands, like list hikes, add hike and delete hike. If it doesn’t understand the command, it replies with a fallback message.

responder('list hikes');
// => 'Lost Lake, Canyon Creek Meadows'
responder('add hike Mirror Lake');
// => 'Added Mirror Lake!'
responder('delete hike Mirror Lake');
// => 'Removed Mirror Lake!'
responder('where is Mirror Lake');
// => "Sorry, I don't understand."

The code is a few steps forward from what we had last time: the responder function still follows the Router pattern, but we lifted the individual routes into functions to make the list of responses easier to read.

let hikes = [
  'Lost Lake',
  'Canyon Creek Meadows',
];

let listHikes = () =>
  hikes.join(', ');

let addHike = ([hike]) => {
  hikes.push(hike);
  return `Added ${hike}!`;
};

let deleteHike = ([hike]) => {
  hikes.splice(hikes.indexOf(hike), 1);
  return `Removed ${hike}!`;
};

let fallback = () =>
  `Sorry, I don't understand.`;

let responses = [
  { command: /^list hikes$/,
    response: listHikes },
  { command: /^add hike (.+)$/,
    response: addHike },
  { command: /^delete hike (.+)$/,
    response: deleteHike },
  { command: /^(.*)$/,
    response: fallback },
];

let responder = (message) => {
  let { command, response } = responses
    .find(({ command, response }) =>
      command.test(message)
    );
  return response(
    command.exec(message).slice(1)
  );
};

The responder function searches through the list of responses for a command that matches the chat message, then invokes the corresponding response function.

let responder = (message) => {
  let { command, response } = responses
    .find(({ command, response }) =>
      command.test(message)
    );
  return response(
    command.exec(message).slice(1)
  );
};

Today, we want to enforce that the add hike and delete hike commands are executed with the word “sudo” to prevent any accidental changes. Only some commands need sudo, and if the user forgets sudo, we want to provide feedback. So we can’t just add the word “sudo” directly to the regular expressions.

responder('list hikes');
// => 'Lost Lake, Canyon Creek Meadows'
responder('sudo add hike Mirror Lake');
// => "Sorry, I don't understand."
responder('sudo delete hike Mirror Lake');
// => "Sorry, I don't understand."
responder('where is Mirror Lake');
// => "Sorry, I don't understand."

We can make the regular expressions a little more lenient so the command is at least recognized:

 let responses = [
-  { command: /^list hikes$/,
+  { command: /list hikes$/,
   ...
-  { command: /^add hike (.+)$/,
+  { command: /add hike (.+)$/,
   ...
-  { command: /^delete hike (.+)$/,
+  { command: /delete hike (.+)$/,
   ...
 ];

But how should we enforce the use of sudo for these admin commands?

One tempting way to support a new, shared behavior like this is to add a new property to each response object: we’ll call it adminOnly.

 let responses = [
   ...
   { command: /add hike (.+)$/,
+    adminOnly: true,
     response: addHike },
   { command: /delete hike (.+)$/,
+    adminOnly: true,
     response: deleteHike },
   ...
 ];

Then in the responder, we’ll add a guard clause that checks if the route requires “sudo”, and if the word is missing, we’ll respond with “Not allowed.”

 let responder = (message) => {
-  let { command, response } = responses
+  let { command, adminOnly, response } = responses
     .find(({ command, response }) =>
       command.test(message)
     );
+  if (adminOnly && !message.startsWith('sudo')) {
+    return 'Not allowed!';
+  }
   return response(
     command.exec(message).slice(1)
   );
 };

When faced with this kind of feature request — that is, supporting a new behavior that can be generalized for related functions — many developers would probably do what we did and insert that behavior logic into the responder function. It’s quick, keeps the code DRY, and it just feels nice. But it’s also a premature abstraction that conflates responsibilities: the responder function has become responsible for routing and authorization logic.

Every time a feature requires a new qualifier, the responder will be edited. It won’t be long before there are several short if-else statements in the responder — which is precisely what the Router pattern was intended to help us demolish.

From a testing perspective, we can’t unit test the authorization logic for individual chat commands without going through the responder. We can only write integration tests for authorization.

Whenever you’re tempted to alter terse, single responsibility functions to incorporate a new behavior, take a step back and identify the most naive solution that still satisfies the single responsibility principle.

For example, what if we added this admin enforcement logic directly to the addHike() and deleteHike() response functions instead of the responder?

Let’s undo our changes. For the response functions to determine if sudo was used, we need to pass the full chat message:

 let responder = (message) => {
   ...
   return response(
-    command.exec(message).slice(1)
+    { message,
+      match: command.exec(message).slice(1) }
   );
 };

In addHike(), we can add a guard clause that checks if the message starts with “sudo” and returns “Not allowed” if it doesn’t. We can copy-paste this guard clause to deleteHike().

let addHike = ({ match: [hike], message }) => {
  if (!message.startsWith('sudo')) {
    return 'Not allowed!';
  }
  hikes.push(hike);
  return `Added ${hike}!`;
};

let deleteHike = ({ match: [hike], message }) => {
  if (!message.startsWith('sudo')) {
    return 'Not allowed!';
  }
  hikes.splice(hikes.indexOf(hike), 1);
  return `Removed ${hike}!`;
};

This naive solution is feature complete and leaves the responder function focused on one responsibility. But now one if-else statement has multiplied into two in our response functions. So how are we any better off? Well, by letting the naive solution play out, we’re equipped to build an abstraction that solves a concrete problem: the duplicated guard clause.

This guard clause represents a behavior, which we could call adminOnly. When you hear the word “behavior” or “trait”, we’re referring to a cross-cutting concern that can be shared across several functions, even if they do completely different things. The addHike() and deleteHike() response functions have different jobs, but they share a similar behavior.

A great way to share behavior in a language that supports functional programming is through function composition.

Suppose we had a function, called adminOnly(), that receives an unprotected function like addHike(), and returns a new version of addHike() that enforces the use of the “sudo” keyword:

 let responses = [
   ...
   { command: /add hike (.+)$/,
-    response: addHike },
+    response: adminOnly(addHike) },
   { command: /delete hike (.+)$/,
-    response: deleteHike },
+    response: adminOnly(deleteHike) },
   ...
 ];

adminOnly() is easy to code up once you get the parameter signature right. If the message contains the word “sudo”, it invokes the route it received as an argument. Otherwise, it returns the failure message.

let adminOnly = (route) => (request) =>
  request.message.split(' ').includes('sudo')
    ? route(request)
    : 'Not allowed!'
;

I like to call this kind of behavior function an Enforcer: it’s a Higher-Order Function with a guard clause that enforces some authorization rule, like requiring the word “sudo” or checking if the current user is an admin.

The add hike and delete hike commands behave exactly as they did in our first solution. But this time, we didn’t have to edit existing functions to support the new behavior: we only added new functions and composed them. It’s as though we’re writing immutable code, and like immutable data structures, this style of coding has great design benefits and prevents regressions. None of our existing unit tests will change, and the new code already follows the single responsibility principle.

We can even add new enforcement behaviors.

Suppose we want to enforce that the list hikes command include the word “please” with a new behavior called askNicely(). All we need to do is duplicate the adminOnly() behavior, then change the keyword and failure message:

let askNicely = (route) => (request) =>
  request.message.split(' ').includes('please')
    ? route(request)
    : 'You should ask nicely.'
;

let responses = [
  { command: /list hikes$/,
    response: askNicely(listHikes) },
  ...
];

And because these enforcers are built through function composition, they layer without additional work. To make the delete hike command require “sudo” and “please”, we just compose the behaviors.

 let responses = [
   ...
   { command: /delete hike (.+)$/,
-    response: adminOnly(deleteHike) },
+    response: adminOnly(askNicely(deleteHike)) },
   ...
 ];

But what about the duplication between these behaviors? Other than a different keyword and failure message, they look exactly the same. We can DRY them up into an enforcer factory called requireKeyword() that returns a new behavior based on a customizable keyword and failure message.

let requireKeyword = (word, fail) => (route) => (request) =>
  request.message.split(' ').includes(word)
    ? route(request)
    : fail
;

Now the adminOnly() and askNicely() behaviors can be replaced with partial invocations of the requireKeyword() enforcer factory!

let adminOnly = requireKeyword('sudo', 'Not allowed!');
let askNicely = requireKeyword('please', 'You should ask nicely.');

We’ve landed on a solution that satisfies the single responsibility principle, didn’t change existing functions, and produces descriptive code.

responder('list hikes');
// => 'You should ask nicely.'
responder('please list hikes');
// => 'Lost Lake, Canyon Creek Meadows'
responder('add hike Mirror Lake');
// => 'Not allowed!'
responder('sudo add hike Mirror Lake');
// => 'Added Mirror Lake!'
responder('sudo please delete hike Mirror Lake');
// => 'Removed Mirror Lake!'

The enforcer pattern pops up in other places, like guarding authenticated pages in a React web app:

let requireLogin = (Component) => (props) =>
  props.currentUser
    ? <Component {...props} />
    : <Redirect to="/login" />

let ActivityPage = ({ notifications }) =>
  <section>
    <h2>Recent Activity</h2>
    <Notifications notifications={notifications} />
  </section>

export default requireLogin(ActivityPage);

Or rendering a loading indicator while an API request finishes:

let withLoader = (msg) => (Component) => (props) =>
  props.loading
    ? <LoadingIndicator message={message} />
    : <Component {...props} />

let ProfileScreen = ({ stories, user }) =>
  <div>
    <h2>Stories from {user.name}</h2>
    <StoryList stories={stories} />
  </div>

export default withLoader('Wait…')(ProfileScreen);

Or protecting backend routes based on the current user:

let listAllUsers = (req, res) => {
  res.send(users);
};

let adminOnly = (req, res, next) =>
  req.user && req.user.isAdmin
    ? next()
    : res.sendStatus(401);

app.get(
  adminOnly,
  listAllUsers,
);

But we wouldn’t have discovered this pattern without writing the naive copy-paste solution first and letting the repetition guide the refactor.

So don’t try to prevent copy-paste prematurely: instead, let the code be duplicated, then DRY up the duplication through function composition. The naive copy-paste solution will lead you to a resilient abstraction that won’t be outgrown by the next feature.

Today, look for short, repeated if-else statements near the beginning of the function that guard the rest of the function, and try extracting them into an enforcer function.

That’s it for today. Want to keep leveling up your craft? Don’t forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.

Jonathan Lee Martin

Jonathan is an educator, writer and international speaker. He guides developers — from career switchers to senior developers at Fortune 100 companies — through their journey into web development.