A plugin enables a system to extend its core capabilities by providing a common foundation for developing them; it allows you to build modular, customizable, extensible, and easily maintainable applications.
In this blog post we’re going to review different architectural approaches to plug-ins of some popular Node.js tools such as Fastify, NestJS and ExpressJS.
There are some non-negotiable principles for a plugin system:
Plugin architectures usually include a plugin manager that has an essential role in managing the plugin's lifecycle; this involves things like plugin registration, validation, and loading.
If you are working with Node.js, You’ve probably already worked with some popular web frameworks, and there is a high chance that you had to install or interact with a plugin from their ecosystem.
Let’s review how the most popular web frameworks of Node.js handle the plugin architecture: Fastify, NestJS and ExpressJS
A high-performance web framework, being one of the fastest frameworks for Node.js. The core is a minimalist web framework by design; you will be using their plugin system all the time. Everything is a plugin, consisting of a single exported function specified in the register method (part of the Fastify core).
module.exports = function (fastify, options, done) {}
Fastify's approach to building a plugin allows you to extend the functionalities of the Framework by accessing the core system from within the plugin. Let's see it:
In Fastify, you add a plugin to the core system using a register function
const fastify = require('fastify')()
const fp = require('fastify-plugin')
const dbPlugin = require(db-plugin')
function myPlugin (fastify, opts, done) {
dbClient.connect(opts.url, (err, conn) => {
done()
})
}
fastify.register(fp(dbPlugin), { url: 'https://example.com' })
Fastify's straightforward approach to plugins works giving a great developer experience, and it's a big part of the framework's success.
NestJS is another popular Node.js web framework that aims to provide scalable server-side applications with an extensible application architecture that allows you to write modular code.
This framework relies heavily on a concept called Dependency Injection or DI. A software design pattern that manages your object dependencies differently. It uses a technique called Inversion of Control (IoC). Instead of explicitly knowing how to construct a service, it relies on a service injector that handles all the details about creating the service, known as the DI container; your application only knows how to interact with it via a well-defined interface.
The main advantage of using this pattern is that you can define abstractions that allow you to change a specific service's implementation details without breaking the contract with the consuming client.
NestJS relies on ES2016 decorators to specify a service injected with the NestJS IoC Container. These services are known as providers. In the end, they’re just functions called during a class definition.
Let’s see an example:
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';
@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];
findAll(): Cat[] {
return this.cats;
}
}
Only one specific provider instance is injected, following a singleton pattern. NestJS ensures a single instance is created by caching it. These mechanisms rely on a sophisticated dependency graph for resolving and injecting dependencies.
Providers are used from Controllers, responsible for handling incoming requests and returning responses to the client.
@Controller('cats')
export class CatsController {
// CatsService provider is injected from the constructor.
constructor(private catsService: CatsService) {}
}
A provider is injected to the controller via constructor injection, providers and controllers are added to the IoC Container via @Module:
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}

NestJS leverages TypeScript reflection to get metadata information used by the injector decorator.
If you are curious about this topic, TypeScript uses the reflect-metadata npm package to accomplish this.
Using DI has its ups and downs, but we can’t wait to see how we will write Node.js programs using this pattern once decorators are standardized and fully available.
Express is one of the most popular web frameworks for Node.js, the way you extend the framework functionality is by using Middlewares
The concept is similar to Fastify, functions that have access to the request and response objects with a next function to indicate that the middleware has finished processing successfully (identical to the done callback from Fastify). The next function runs in the context of an Express router.
A middleware can be added globally or to specific routes. Let’s see an example:
const express = require('express')
const app = express()
const loggerPlugin = function (req, res, next) {
console.log('Logger plugin')
next()
}
app.use(loggerPlugin)
app.get('/', (req, res) => {
res.send('Hello World!')
})
All requests will log: Logger plugin.
A key difference from Fastify is that you don’t have an options object to specify configuration values, but you can emulate a similar behavior by creating a configurable middleware:
// middleware.js
module.exports = function (options) {
return function (req, res, next) {
// Specify any configuration data in the options object
console.log('Configured url', options.url);
next()
}
}
Use the middleware with options:
const mw = require('./middleware')
app.use(mw({ url: 'http://localhost'}))
You’ve learned how popular web frameworks define a standard interface for registering plugins and extending their functionality by using third-party plugins or building your own.