0

I recently started to work in a startup that been around for almost 6 years now. Our main code base is in Node.js, which means that it has been used here since the early days and therefore there is a fair share of "legacy code" running.

There are some issues, but right now I'm struggling with a specific one: how to deal with code that should just be executed in an specific environment?

There are dozens of code snippets like:

if (isDevelopment) {
  doThis();
} else if (isLocal) {
  doThat();
} else if (isProduction) {
  doOtherStuff();
}

IMHO, this is definetly a code smell.

Remember DRY. Application logic is now scattered through several files, which makes changes very cumbersome.

It's also difficult to remove or add new environments -- this is not a need right now, but who knows? For example, having a local and a development environment seems redundant to me.

I've been thinking in better ways of achieving the same results. My first idea was to abstract away the code, writing separate modules for each environments when needed, for example:

// lib/production/some-module.js
module.exports = // some production code

// lib/development/some-module.js
module.exports = // some development code

So, I would set the NODE_ENV variable and in the module that is a client of some-module, I could do:

const someModules = require('/path/to/lib/' 
 + process.env.NODE_ENV + 'some-module')

I could of course wrap this into an utility function:

function envRequire(prefix, moduleName) {
  return require(prefix.replace(/\/$/, '') + '/' 
    + process.env.NODE_ENV + '/' + moduleName)
}

Then, in my code I'd have something like:

const commonModule = require('/path/to/common-module')
const envAwareModule = envRequire('/path/to', 'my-module')

The advantages here is that I no longer need conditionals spread all over the application and environment-specific code can be tied toghether. Adding or removing environments it now less convoluted.

The disadvantages I see is that I'd probably have to write more code and if some module has different logic for only one environment, I'd have to replicate the "common" version in the others.

I could work around the latest by using symlinks or a "default" environment that could be used as a fallback.

Is this a good approach or can I do better in terms of workload, flexibility and code resilience?

Edit

Thinking about @jfriend00's answer, I started to wonder:

What if I abstracted some concepts into a base object and implemented specific code in separate module inheriting from the base?

My only question then would be how to load the proper module per environment. I could have a modules entry in my configuration files that would tell what are the right module to run.

Something like:

// conf.production.json
{
  // ...
  "modules": {
    "database-setup" : "./lib/database/setup-production"
  },
  // ...
}

// conf.development.json
{
  // ...
  "modules": {
    "database-setup" : "./lib/database/setup-development"
  },
  // ...
}

Then I'd define an "interface" for them in lib/database/setup-base and both could implement it.

I could define a helper module:

const conf = require.main.require(`./conf/conf.${process.env.NODE_ENV}`)

module.exports = function envRequire(moduleName) {
  if (conf.modules[moduleName] === undefined) {
    throw new Error('Module not set in conf')  
  }

  return require(conf.modules[moduleName])
}

And use it at bootstrap

const envRequire = require('./helper/env-require')
const dbSetup = envRequire('database-setup')

Any thoughts about this approach?

  • Is being DRY your only criteria for "better?" – Robert Harvey Oct 6 '16 at 19:31
  • No, I believe not. The if approach has other disadvantages, such as requiring changing several files whenever the behavior in a given environment should be changed. In our current state, this is not something easily fixable, but I'd like to establish a good standard for new projects. – Henrique Barcelos Oct 6 '16 at 19:35
  • 2
    So, flexibility and reduced workload then. Any other criteria? – Robert Harvey Oct 6 '16 at 19:36
  • 1
    I see your point... Let me edit the question to make this clearer... – Henrique Barcelos Oct 6 '16 at 19:37
  • 1
    One application that performs different business in different envs. Are not N different apps? Are these real different business or is it matter of settings? Is your app prametrizable? Does your app have any sort of config file, that could be retrieved depending on the env? Example – Laiv Oct 6 '16 at 21:35
1

First off, you general want each body of code to encapsulate its own behavior so if it wants to implement something differently for a development environment vs. a production environment, that code should be local to the module that cares about it and other code that uses that module should not have to know anything about the differences. Encapsulating like this (which is generally a good thing) means that you will have if (isDevelopment) type logic sprinkled around your code base because any module that wants to do anything differently for that environment will use that type of code inside its own module.

The only way to avoid that is to try to centralize all code that cares about the environment and that generally flies in the face of having modules be self-contained and resuable on their own. So, you end up living with the fact that if (isDevelopment) may appear in lots of places.

Now, even though you adhere to the principle of encapsulated, resuable modules, you can still endeavor to be clean, efficient and DRY with your implementation within a given module by doing a number of things:

1) Put tests of the environment into the actual implementation methods at the lowest level possible, not into the caller of those methods. So, rather than:

if (isDevelopment) {
    doAction1();
} else {
    doAction2();
}

Create a single method that just says doAction() and then put the if test at the lowest level possible so the callers don't have to worry about the environment difference. They can just call the method and let the implementation take care of the difference.

2) Use intelligent defaults so if you add a new environment, most pieces of code will have an appropriate default behavior and won't necessarily have to be aware of the new environment. So, rather than what you show:

if (isDevelopment) {
  doThis();
} else if (isLocal) {
  doThat();
} else if (isProduction) {
  doOtherStuff();
}

Where the addition of a new environment would cause this code to do nothing, you can decide that for this particular code, production is the default behavior so if you implement the above as:

if (isDevelopment) {
  doThis();
} else if (isLocal) {
  doThat();
} else {
  // production is default behavior
  doOtherStuff();
}

Then, if you add a new environment, all your code already will have a default safe behavior for that new environment. You may still have to add awareness of the new environment to some code, but not necessarily all.

3) Consider sub-environment modifiers rather than a whole new environment so if you have development, local and production and you want to specify a new behavior that is mostly an off-shoot from development, rather than create a whole new environment for that, you can just create a modifier for the development environment. So, then only code that actually needs to know about the new configuration option needs to do anything. As any example, you wouldn't make a new developmentDatabaseLogging environment in order to tell the database to turn logging on. Instead, you'd create a separate configuration option for database logging that only the database would pay attention to. No other code would need to change. The same would go for logging levels. In this way you create configuration options for specific modules and then only those modules need to be aware of them or modify their code when they are added (a description/benefit of encapsulation).

4) Use normal DRY mechanisms. Whenever you find yourself repeating environment testing code across multiple modules, create a new shared module with that code in it so each place that uses it can share the implementation. For reasons I can't quite explain, the push to stand-alone modularity in node.js seems to pull people away from creating common utility function that multiple modules can use. It almost seems backwards because modules are so encouraged and easy and built-in to your design already, but people don't seem to think about grouping some shared utility functions into a module just so multiple other modules can all use them. But, obviously this is easy and often required to stay DRY.

5) As long as none of your configuration options are dynamic (they don't change after startup), you can sometimes abstract away a lot of the tests for the environment at module startup by testing the environment at startup and pre-setting certain configuration values or methods to just do the appropriate thing for the environment. This keeps your runtime code from having to test anything. For example, if a local configuration uses a local in-memory database and the production and development environments use a database on an external server, then you could, upon startup, create the appropriate database object and store that locally in the module and all code from then on just uses the abstract database object without regards for the environment. If multiple places in the code might initialize the database, then you create a single, shared factory function that has the environment test in it in just one place to create the appropriate type of database object.

My first idea was to abstract away the code, writing separate modules for each environments when needed ...

Completely separate module implementations for the different environments are rarely the way to go because you end up with way too much duplicated code which is a bad thing that must be double to triple maintained. Instead, it makes more sense to use an object oriented approach where your 95% of common code is implemented in a base object and then, if required, you can subclass that for each environment and add only the 5% different code to each subclass. You will, of course, have to design the base class to be sub-classable for the right things, but that can usually be done with a little forethrought.

  • thanks a lot for your thorough answer. Much of what you've written was already in my head, but I was having trouble materializing it in an understantable way. – Henrique Barcelos Oct 7 '16 at 14:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.