DEV Community

Shalvah
Shalvah

Posted on • Updated on • Originally published at blog.shalvah.me

Eliminating Hidden Dependencies

In software, a dependency occurs when one module in an application, A, depends on another module or environment, B. A hidden dependency happens when A depends on B in a way that isn't obvious.

To discover a hidden dependency, you usually have to dig into the source code of the module. A module could refer to anything from an entire service to a class or function to just a few lines of code.

Here's a small example of a dependency, comparing two ways it could be expressed:

let customer = Customer.find({id: 1});

// explicit -- the customer has to be passed to the cart
function Cart(customer) {
  this.customer = customer;
}
let cart = new Cart(customer);

// hidden -- the cart still needs a customer,
// but it doesn't say so outright
function Cart() {
  this.customer = customer; // a global variable `customer`
}
let cart = new Cart();

Enter fullscreen mode Exit fullscreen mode

Note the subtle difference? Both implementations of the Cart constructor depend on a customer object. But the first requires that you pass in this object, while the second expects that there's already a customer object available in the environment. A dev seeing let cart = new Cart() would have no way to tell that the cart object depends on a global customer variable, except they took a look at the Cart constructor.

Hidden dependencies in the wild

I'll share a few examples of hidden dependencies I've come across in real-world codebases.

PHP include files

Let's take a typical PHP backend app. In our index.php, the entry point of our app, we could have something like this:

include "config.php";
include "loader.php";
$app = new Application($config);
Enter fullscreen mode Exit fullscreen mode

The code looks suspicious, doesn't it? Where did the $config variable come from? Let’s see.

The include directive is similar to HTML <script> tags; it tells the interpreter to grab the contents of the specified file, execute it and, if it has a return statement, pass the return value to the caller. It’s a way of splitting code across multiple files. Like a <script> tag, include can also put variables into the global scope.

Let's look at the files we're including. The config.php file contains typical configuration settings for a backend app:

$config = [
    'database' => [
        'host' => '127.0.0.1',
        'port' => '3306',
        'name' => 'home',
    ],
    'redis' => [
        'host' => '127.0.0.1',
    ]
];
Enter fullscreen mode Exit fullscreen mode

The loader.php is basically a homemade class loader. Here’s a simplified version of its contents:

$loader = new Loader(__DIR__);
$loader->configure($config);
Enter fullscreen mode Exit fullscreen mode

See the problem? The code in loader.php (and the rest of the code in index.php) depends on some variable named $config, but it’s not obvious where $config is defined until you open up config.php. This coding pattern is actually not uncommon.

Including JavaScript files via <script> tags

This is probably a more common example. Compare these two code snippets (assume cart-fx and cart-utils are some random JS libraries):

<script src="//some-cdn/cart-fx.js"></script>
<script src="//some-cdn/cart-utils.js"></script>

/* lots and lots of code */

<script>
var cart = new Cart(CartManager.default, new Customer());
</script>
Enter fullscreen mode Exit fullscreen mode
import Cart from 'cart-fx';
import CartManager from 'cart-utils';

/* lots and lots of code */

const cart = new Cart(CartManager.default, new Customer());
Enter fullscreen mode Exit fullscreen mode

In the second, it's obvious that the Cart and CartManager variables were brought in (imported) from the cart-fx and cart-utils modules respectively. In the first, we're left to guess as to which module owns the Cart, and which owns the CartManager. And don't forget the Customer too! (Remember, our own code is also a module.)

Reading from the environment

I'm the culprit in this one. I wrote a PHP package some time ago to interact with an API. The package allowed you to pass your API keys to the constructor. Unfortunately, it also allowed you to instead specify your keys as environment variables, and the package would automatically make use of them. Take a peek, and don't laugh at me.

When I did that, I believed I was making the developer's life easier. In reality, though, I was making assumptions about the environment the code was running in, and tying the package's functionality to certain conditions being met in the environment.

So, why are hidden dependencies dangerous?

Two reasons:

  • It's easy to unintentionally remove the module depended upon without removing the dependency. For instance, take the case of my package above. Imagine a developer setting up an app which uses the package in a new environment. While deciding what environment variables to carry over from the old environment, the dev might neglect to add the ones needed by the package, because they can't find them used anywhere in the codebase.
  • A small change to the dependent code could break the entire application, or introduce bugs. Take the case of our index.php file above -- swapping the first two lines might seem like a harmless change, but it would break the app, because line 2 depends on a variable set in line 1. (This actually happened to me, and led to me writing this article.)

An even more serious case of this would be something like this:

$config = [...];
include "bootstrap.php";
$app = new Application($config);
Enter fullscreen mode Exit fullscreen mode

Supposing our bootstrap.php file makes some important changes to $config. If for some reason, the second line is moved to the bottom, the app would run without throwing any errors, but the crucial configuration changes that bootstrap.php makes would be unseen by the app.

Getting Rid of Hidden Dependencies

Like much of software engineering, there are no hard rules for dealing with hidden dependencies. Over time, I've discovered some guiding principles that help me achieve this:

  1. Write code that's truly modular, not just split across multiple files. An ideal module aims to be self-contained and have minimal dependence on shared global state. A module should also explicitly state its dependencies.
  2. Reduce the number of assumptions a module needs to make about its environment or other modules.
  3. Expose a clear interface. Ideally, beyond things like function/class signatures, a user of your module should not have to look at the source code to figure out what the module's dependencies are.
  4. Avoid littering the environment. Resist the temptation to add variables to the parent scope. As often as possible, prefer explicitly returning or exporting variables to the caller.

With these in mind, let's try to refactor the first example. First, we'll make the config file return the configuration array, so the caller can explicitly set that to a variable;

// config.php

return [
    'database' => [
        'host' => '127.0.0.1',
        'port' => '3306',
        'name' => 'home',
    ],
    'redis' => [
        'host' => '127.0.0.1',
    ]
];
Enter fullscreen mode Exit fullscreen mode

The next thing we can do is change the loader file to return a function. This function will take a config parameter. In this way, we’re making it clear that the process of loading files depends on some preset configuration:

// loader.php

return function (array $config)
{
  $loader = new Loader(__DIR__);
  $loader->configure($config);
}
Enter fullscreen mode Exit fullscreen mode

Putting these together, we end up with our index.php file looking like this:

$config = include 'config.php';
(include 'loader.php')($config);
$app = new Application($config);
Enter fullscreen mode Exit fullscreen mode

We can even go a bit further:

$config = include 'config.php';

$loadClasses = include 'loader.php';
$loadClasses($config);

$app = new Application($config);
Enter fullscreen mode Exit fullscreen mode

Now anyone looking at index.php can tell at a glance that:

  1. The file config.php returns something (we can guess this is some sort of configuration, but that’s not important now)
  2. Both the loader file and the Application depend on that something in order to do their work

Much better, isn't it?

Let's take a dig at our second example. We could refactor this in a couple of ways: switch to import/require for supported browsers, or use build tools that would provide polyfills for that. But there's a small change we could make that would make things somewhat better:

<script src="//some-cdn/cart-fx.js"></script>
<script src="//some-cdn/cart-utils.js"></script>

/* lots and lots of code */

<script>
var cart = new CartFx.Cart(CartUtils.CartManager.default, new Customer());
</script>
Enter fullscreen mode Exit fullscreen mode

By attaching the CartManager and Cart objects onto the global CartFx and CartUtils objects, we've effectively moved those two into namespaces, reducing the number of potentially hidden dependencies to just one per module.

Sometimes, you just can't help it

There are times where you might be constrained by available tools, limited resources and whatnot. It's important, though, to keep in mind that what seems so obvious to you, the author of the code, might not be so to a newcomer, and look for little optimizations you can make to improve this.

Have any experiences with hidden dependencies or techniques for handling them? Do share in the comments.

Latest comments (0)