Hourglass logic: time's up!

Originally published Oct 17, 2022·Tagged #javascript, #software-architecture, #web-development, #react

There's one pattern that I've seen time and time again in codebases that have evolved over time, and I often seek to refactor it out to avoid its hidden pitfalls. If I told you that I called this "hourglass logic", would you be able to spot it in the following code?

function beginCheckout(checkoutProvider) {
  switch (checkoutProvider) {
    case 'stripe':
      // Suppose this code initiates a Stripe checkout
      stripe.doSomething();
      maybeSomeOtherLogic();
      break;

    case 'paypal':
      // Suppose this code initiates a PayPal checkout
      paypal.doSomethingElse();
      perhapsMoreLogicHereToo();
      break;

    case 'ethereum':
      // Suppose this code initiates a checkout using Ethereum
      metamask.doSomethingOther();
      finalizeTransaction();
      break;

    default:
      break;
  }
}

function CheckoutPage() {
  return (
    <div>
      <button
        onClick={() => beginCheckout('stripe')}
      >Check out with credit card</button>
      <button
        onClick={() => beginCheckout('paypal')}
      >Check out with PayPal</button>
      <button
        onClick={() => beginCheckout('ethereum')}
      >Check out with Ethereum</button>
    </div>
  );
}

This is a simple example, but intentionally so, because this problem can hide very easily in a large codebase -- especially when beginCheckout and CheckoutPage might live in different files. The "hourglass" comes from the fact that you have three buttons, both of which narrow control into the same beginCheckout function, which only differentiates between the buttons and then "fans out" again into three separate branches.

Based on my experience reading other people's code, here are a few reasons I find this problematic:

  • It's hard for other programmers to follow. Adding a level of indirection means the next engineer to touch this code has to work harder to understand it, for no gain. Typically, hourglass logic will introduce an extra enum (as this example has effectively done) or configuration object to pass along. If the object is only used for this one purpose, then it doesn't contribute to the health of the overall codebase.
  • It encourages spaghetti code. Building upon the previous point, once you have that config object, it's tempting to use it elsewhere in the codebase. Now, there's this object that was designed for this short-lived, disposable usage, that gets used in multiple places. Often, these objects aren't as well-thought-out as, say, data transfer objects or long-lived classes, and create problems of their own.
  • Changes to one branch might affect the other branches. Now that all of these flows are related, a change to one might impact the others. For example, consider the case where you want to return a value from beginCheckout to display on the page. This might contain a transaction ID for a credit card transaction, an email address for a PayPal transaction, or an Ethereum address for an Ethereum transaction. You would need another hourglass function just to handle the return values, since all three get returned from the same function! Plus, you'd need some kind of structure to hold all three... the complexity snowballs from here.

What's my favorite solution for this? Refactor the switch cases into separate functions, delete the hourglass function, and refer directly to the separate functions from the UI:

function beginStripeCheckout() {
  // Suppose this code initiates a Stripe checkout
  stripe.doSomething();
  maybeSomeOtherLogic();
}

function beginPayPalCheckout() {
  // Suppose this code initiates a PayPal checkout
  paypal.doSomethingElse();
  perhapsMoreLogicHereToo();
}

function beginEthereumCheckout() {
  // Suppose this code initiates a checkout using Ethereum
  metamask.doSomethingOther();
  finalizeTransaction();
}

function CheckoutPage() {
  return (
    <div>
      <button
        onClick={() => beginStripeCheckout()}
      >Check out with credit card</button>
      <button
        onClick={() => beginPayPalCheckout()}
      >Check out with PayPal</button>
      <button
        onClick={() => beginEthereumCheckout()}
      >Check out with Ethereum</button>
    </div>
  );
}

This has the added benefits of being shorter and easier to test!

Now, there may well be valid reasons for writing hourglass code. Perhaps data is being passed over the network or stored in a database, and needs to be processed later. As with any tool, use this particular refactoring skill wisely, and only when applicable. As the saying goes, "when all you have is a hammer, everything looks like a nail".