Why JavaScript projects need a plugin strategy

Plugins are a fixture across the software ecosystem. WordPress, jQuery, Vue, Gatsby and Eleventy all offer them, and the reason is straightforward: plugins let outside developers extend a core project without requiring the maintainers to build every feature themselves. Done well, a plugin system grows a community around a project while keeping the maintenance burden on the core small.

The concept goes by other names too — extensions, add-ons, modules — but the underlying idea is the same. To see what it takes to design one, let’s build a small plugin system for a JavaScript calculator called BetaCalc.

A minimal calculator

The starting point is a deliberately simple calculator object that prints results via console.log. It has a setValue method to display a number and plus and minus methods to operate on the currently displayed value:

// The Calculator
const betaCalc = {
  currentValue: 0,
  
  setValue(newValue) {
    this.currentValue = newValue;
    console.log(this.currentValue);
  },
  
  plus(addend) {
    this.setValue(this.currentValue + addend);
  },
  
  minus(subtrahend) {
    this.setValue(this.currentValue - subtrahend);
  }
};


// Using the calculator
betaCalc.setValue(3); // => 3
betaCalc.plus(3);     // => 6
betaCalc.minus(2);    // => 4

That handles basic arithmetic, but the project’s goal is to let other developers add their own buttons. That calls for a way to register external functionality.

A first pass at a plugin API

The simplest possible system adds a register method that takes a plugin, pulls out its exec function, and attaches it to the calculator object:

// The Calculator
const betaCalc = {
  // ...other calculator code up here


  register(plugin) {
    const { name, exec } = plugin;
    this[name] = exec;
  }
};

A plugin author would then register a squared button like this:

// Define the plugin
const squaredPlugin = {
  name: 'squared',
  exec: function() {
    this.setValue(this.currentValue * this.currentValue)
  }
};


// Register the plugin
betaCalc.register(squaredPlugin);

Because the exec function is attached directly to betaCalc, it gains access to the calculator’s this context, so it can read and update internal state:

betaCalc.setValue(3); // => 3
betaCalc.plus(2);     // => 5
betaCalc.squared();   // => 25
betaCalc.squared();   // => 625

This design has some virtues. A plugin is just an object literal, which makes it trivial to distribute via npm or import as an ES module. But there are real problems hiding in that simplicity.

Giving every plugin access to this means read/write access to the entire calculator object. A plugin could accidentally redefine an internal method like setValue, breaking BetaCalc and every other plugin along with it. That violates the open-closed principle, which says software should be open for extension but closed for modification.

The squared function also works by producing side effects — it mutates calculator state directly. That’s common in JavaScript, but it makes behavior harder to predict, especially when multiple plugins share the same internal state.

A safer architecture: pure functions and a plugins namespace

A revised design addresses both of those flaws. The calculator now keeps plugins separate from core methods and routes button presses through a single press method:

// The Calculator
const betaCalc = {
  currentValue: 0,
  
  setValue(value) {
    this.currentValue = value;
    console.log(this.currentValue);
  },
 
  core: {
    'plus': (currentVal, addend) => currentVal + addend,
    'minus': (currentVal, subtrahend) => currentVal - subtrahend
  },


  plugins: {},    


  press(buttonName, newVal) {
    const func = this.core[buttonName] || this.plugins[buttonName];
    this.setValue(func(this.currentValue, newVal));
  },


  register(plugin) {
    const { name, exec } = plugin;
    this.plugins[name] = exec;
  }
};
  
// Our Plugin
const squaredPlugin = { 
  name: 'squared',
  exec: function(currentValue) {
    return currentValue * currentValue;
  }
};


betaCalc.register(squaredPlugin);


// Using the calculator
betaCalc.setValue(3);      // => 3
betaCalc.press('plus', 2); // => 5
betaCalc.press('squared'); // => 25
betaCalc.press('squared'); // => 625

Three structural changes stand out. First, plugins live in their own plugins object, so they no longer see or touch BetaCalc’s internal properties. Second, a press method looks up a button by name. Third, and most importantly, plugin functions are now pure: they receive the current value as an argument and return a new value, rather than mutating state.

That shift to pure functions brings concrete benefits. The plugin API is simpler. Testing is easier for both the core calculator and the plugins themselves. And because plugins no longer depend on the calculator’s internal structure, the whole system is more loosely coupled.

This tighter architecture acts as a guardrail for plugin authors. They can only make the kinds of changes BetaCalc wants to permit — operating on currentValue — rather than rewriting core behavior.

The tradeoff is that it may now be too restrictive. A plugin author who wants to build a memory button or track a history of calculations has no way to store state across interactions. That’s not necessarily a flaw. The amount of power given to plugin authors is a balancing act: too much power threatens project stability, while too little makes plugins useless for real problems.

Where to go from here

The BetaCalc plugin system is intentionally minimal, and a real project would likely need more. Several directions are worth considering:

  • Error handling: Notify plugin authors when they forget to define a name or return a value. Proactively thinking about how the system can break makes it more robust.
  • Lifecycle hooks: Let plugins register callbacks for events like when the calculator is about to display a value, beyond just adding buttons.
  • Plugin state: Provide a dedicated place for plugins to store state that persists across multiple interactions.
  • Configuration: Allow plugins to accept initial settings at registration time.
  • Batched registration: Support registering a suite of related buttons in a single call, akin to a “statistics pack.”

For larger projects, studying how existing systems handle these concerns is a good starting point. jQuery, Gatsby, D3 and CKEditor all have documented plugin architectures worth examining. Familiarity with JavaScript design patterns — Addy Osmani’s book on the subject is a solid reference — helps too. Each pattern offers a different interface and degree of coupling, and knowing the options makes it easier to match a plugin architecture to your project’s needs.

Beyond patterns, software design principles like the Law of Demeter and dependency injection are relevant to plugin design. The key is to do that research early. Having to change a plugin architecture after its release forces every existing plugin author to rewrite their code, which is a fast way to lose trust and discourage contributions.

Building a good plugin system is genuinely hard because it requires balancing simplicity, power and longevity. But the payoff is significant: developers get the freedom to solve their own problems, end users get a growing set of opt-in features, and the core project gets an ecosystem without carrying the full cost of building it.