The Plugin Design Pattern
The plugin architecture is a widely adopted software pattern valued for its adaptability, expandability, and modular separation. It is composed of a central core and a collection of independent plugin modules.
This discussion explores how to construct a plugin-based architecture within Angular by leveraging its Dependency Injection mechanism, highlighting why this approach is a valuable addition to a developer's skill set.
Understanding the Plugin Pattern
At its core, the plugin pattern is straightforward: the Core System handles essential operations and coordinates the Plugins, yet remains neutral in its behavior. The Plugins deliver domain-specific capabilities and remain unaware of both the core's workings and the presence of other plugins.
The Core System also establishes the communication contract that governs interactions between itself and the Plugins.
Plugins do not necessarily have to be crafted for a specific Core System; in such situations, an adapter becomes necessary to align the Plugins with the established contract.
The architectural pillars here include the Inversion of Control (IoC) Principle and the Dependency Inversion Principle (DIP, the "D" in SOLID).
Whereas Plugins embody IoC by taking over behavior and control from the primary flow or Core System, the DIP serves to prevent coupling and define the contract.
Dependency Injection is not the sole mechanism for achieving a plugin architecture or adhering to IoC; alternatives like callbacks, schedulers, event loops, and message queues also fit the bill.
For deeper insights into plugin architectures, consider these resources:
Applying Plugins to Angular Libraries
The Angular ecosystem is vibrant and expansive, with new packages released continuously and favored tools frequently updated.
Within this landscape, not every library adopts a plugin architecture, and appropriately so. This pattern is NOT a universal fix; it shouldn't be applied to every library you design.
Still, certain situations benefit enormously from the plugin pattern, offering maintainers significant adaptability and offloading the burden of implementing every conceivable feature for a domain.
In component libraries, for instance, content projection serves as an effective means to achieve IoC. This makes it straightforward to develop plugins that enhance the core component's functionality or tailor its interface.
The focus here, however, shifts to another Angular design pattern: the Dependency Injection (DI) pattern.
Building a Plugin Architecture with Dependency Injection
As previously outlined, the plugin architecture comprises two parts: the Core System and the Plugins.
The Plugins rely on the Core System, but the reverse is not true. Consequently, the design process should begin with the Core System.
The essential components required are:
- PluginContract, which defines the interface that Plugins will implement and that the Core System relies on for interaction.
- PluginInjectionToken, in some technologies, the
- PluginContract could function as the injection token directly. However, because interfaces are not true runtime constructs in TypeScript and vanish during compilation, an additional token must be defined. This separation also aids in maintaining distinct responsibilities.
- OrchestrationService is tasked with collecting all plugins, managing their execution, and handling error conditions.
Additionally, configuration components are introduced—these may be optional for simpler systems but are key to building adaptable libraries.
- PluginConfiguration holds details on how a plugin integrates with the core. The OrchestrationService consults it to decide whether to activate a plugin and in what manner. Plugins can extend this to adjust their internal settings, while the core might supply default configuration values.
- PluginConfigurationToken, serving as the injection token for PluginConfiguration.
- CoreConfiguration sets up core-level parameters, influencing the system's overall behavior.
- CoreConfigurationToken, serving as the injection token for CoreConfiguration.
The resulting dependency structure is illustrated in Fig.2.
Implementing the Angular Library
With the architectural overview in place, let's examine the concrete implementation details for building our library according to this pattern.
Structuring the core system
To begin, we need to define the contract that every plugin must satisfy to communicate with the core system.
import { PluginConfig } from './plugin.config';
export interface SystemPlugin {
config: PluginConfig; // 👈
operationA(...args: unknown[]): unknown;
operationB(...args: unknown[]): unknown;
operationZ(...args: unknown[]): unknown;
}
The signature of this contract is flexible and depends entirely on the library you are designing. The one mandatory element is the config. We require the Plugin to include it because the Core System relies on this configuration to manage the Plugin appropriately.
For this demonstration, the config is intentionally minimal and generic. However, the underlying concept is to specify within the PluginConfig all the aspects of Plugin behavior that should be customizable. This configuration can be utilized by both the Plugin itself and the Core System.
export interface PluginConfig {
optionA: unknown;
optionB: unknown;
optionZ: unknown;
}
Since Angular's Dependency Injection operates on tokens rather than TypeScript interfaces, we need to create injection tokens for our contracts. Let's add those now.
import { InjectionToken } from '@angular/core';
import { SystemPlugin } from './plugin';
export const pluginToken: InjectionToken<SystemPlugin> = new InjectionToken(
'__PLUGIN_TOKEN__'
);
and
import { InjectionToken } from '@angular/core';
import { PluginConfig } from './plugin.config';
export const pluginConfigToken: InjectionToken<PluginConfig> = new InjectionToken(
'__PLUGIN_CONFIG_TOKEN__'
);
The next component we'll implement is the configuration for the Core System.
export interface CoreConfig {
coreOptionsA: unknown;
coreOptionsB: unknown;
coreOptionsZ: unknown;
}
The CoreConfig, like every other aspect of this example, should be tailored to fit the specific needs of your library. For the sake of clarity, let's consider it as the collection of all adjustable settings for the Core System. In some cases, certain options from the CoreConfig serve as the global defaults for optional attributes within the PluginConfig.
Following the same approach, we'll establish the coreConfigToken to enable injection of the CoreConfig.
import { InjectionToken } from '@angular/core';
import { CoreConfig } from './core.config';
export const coreConfigToken: InjectionToken<CoreConfig> = new InjectionToken(
'__CORE_CONFIG_TOKEN__'
);
Now, let's turn our attention to the OrchestratorService, which serves as the central hub of our library. In this example, the OrchestratorService also acts as the primary entry point to the library. However, this is not a strict requirement; the entry point could be a directive, a separate service that delegates to the orchestrator, or any other interaction mechanism between the consumer and our library.
import { Inject, Injectable, Optional } from '@angular/core';
import { coreConfigToken } from './core-config.token';
import { CoreConfig } from './core.config';
import { SystemPlugin } from './plugin';
import { pluginToken } from './plugin.token';
@Injectable({ providedIn: 'root' })
export class OrchestratorService {
private readonly plugins: SystemPlugin[];
constructor(
@Optional()
@Inject(pluginToken)
plugins: SystemPlugin[],
@Inject(coreConfigToken) private config: CoreConfig
) {
plugins = plugins || [];
this.plugins = Array.isArray(plugins) ? plugins : [plugins];
}
coreOperationA(...args: any[]): unknown {
// just a demonstration of what can be done
return this.plugins
.filter((plugin) => this.canPluginExecute(plugin))
.reduce<unknown>((acc, plugin) => plugin.operationA(acc), null);
}
private canPluginExecute(plugin: SystemPlugin): boolean {
// implement any validation to determine whether the plugin should be executed or not
// use the core config and/or the driver config
// just a demonstration of what can be done
return (
(this.config.coreOptionsA as boolean) &&
(plugin.config.optionA as boolean)
);
}
}
This service contains quite a bit of logic. Given its size relative to the other files, let's break it down to clarify what's happening.
constructor(
@Optional()
@Inject(pluginToken)
plugins: SystemPlugin[],
@Inject(coreConfigToken) private config: CoreConfig
) {
plugins = plugins || [];
this.plugins = Array.isArray(plugins) ? plugins : [plugins];
}
Our first task is to inject the dependencies we'll require.
The most critical dependency is the Plugins – note the plural. We're injecting an array of Plugins. That said, there may be scenarios where only a single Plugin is provided, or even none at all. In those instances, we must normalize the input into an array format.
But how does Angular's DI allow us to receive multiple instances of the same injection token? This is one of the fundamental features that makes DI a great fit for a plugin architecture. We'll delve into the details when we set up our Plugins, but the answer lies in the multi option of Angular's StaticProvider.
coreOperationA(...args: any[]): unknown {
// just a demonstration of what can be done
return this.plugins
.filter((plugin) => this.canPluginExecute(plugin))
.reduce<unknown>((acc, plugin) => plugin.operationA(acc), null);
}
The coreOperationA method illustrates how client code can interact with our library and how the OrchestratorService orchestrates our plugins.
This implementation demonstrates how to filter plugins based on their configuration at a given point in time, and how to sequence different plugins to construct a response. Actual production implementations will likely differ, but the core principle remains: the orchestrator can access all registered plugins and make decisions about them.
Finally, the canPluginExecute method is used to decide whether a specific Plugin should be utilized, which takes into account both the CoreConfig and the PluginConfig.
private canPluginExecute(plugin: SystemPlugin): boolean {
// implement any validation to determine whether the plugin should be executed or not
// use the core config and/or the driver config
// just a demonstration of what can be done
return (
(this.config.coreOptionsA as boolean) &&
(plugin.config.optionA as boolean)
);
}
That essentially covers the Core System. Well, almost – we still need a module to tie everything together and provide the global configuration. Let's see how to accomplish that.
import { ModuleWithProviders, NgModule } from '@angular/core';
import { CoreConfig } from './core.config';
import { coreConfigToken } from './core-config.token';
@NgModule()
export class CoreSystemModule {
static forRoot(config: CoreConfig): ModuleWithProviders<CoreSystemModule> {
return {
ngModule: CoreSystemModule,
providers: [{ provide: coreConfigToken, useValue: config }],
};
}
}
The static forRoot method is used to accept the CoreConfig from the consumer and register it with the DI container. Strictly speaking, we might not need a module if we weren't passing configuration, but this is a widely recognized pattern for configuring Angular libraries.
Developing the Plugins
The Core System may export everything necessary and could theoretically function on its own, but a plugin architecture loses its purpose without actual plugins.
Let's begin by implementing our SystemPlugin contract. This is where the specific logic for our Plugin will ultimately reside.
import { Inject, Injectable } from '@angular/core';
import {
PluginConfig,
pluginConfigToken,
SystemPlugin,
} from 'projects/core-system/src/public-api';
@Injectable()
export class ExamplePlugin implements SystemPlugin {
constructor(@Inject(pluginConfigToken) readonly config: PluginConfig) {}
operationA(...args: unknown[]): unknown {
throw new Error('Method not implemented.');
}
operationB(...args: unknown[]): unknown {
throw new Error('Method not implemented.');
}
operationZ(...args: unknown[]): unknown {
throw new Error('Method not implemented.');
}
}
We need to inject our PluginConfig and fulfill the interface requirements. The actual implementation logic will be unique to each specific Plugin. This example contains unimplemented methods to illustrate the overall structure and intent.
The most important piece, of course, is the Plugin's configuration and its registration with the DI system.
import { ModuleWithProviders, NgModule } from '@angular/core';
import {
PluginConfig,
pluginConfigToken,
pluginToken,
} from 'projects/core-system/src/public-api';
import { ExamplePlugin } from './example.plugin';
export function examplePluginFactory(config: PluginConfig): ExamplePlugin {
return new ExamplePlugin(config);
}
@NgModule()
export class ExamplePluginModule {
static forRoot(
config: PluginConfig
): ModuleWithProviders<ExamplePluginModule> {
return {
ngModule: ExamplePluginModule,
providers: [
{ provide: pluginConfigToken, useValue: config },
{
provide: pluginToken,
useFactory: examplePluginFactory,
deps: [pluginConfigToken],
multi: true,
},
],
};
}
}
First, we accept the PluginConfig from the consumer and provide it to the DI system. Then, it's time to register our Plugin itself.
Because our Plugin depends on the provided PluginConfig, we must use a factory function along with the deps property to establish that dependency.
The truly essential element here is the multi option. Omitting it can cause the entire system to malfunction, as it could override all other plugins and provide only the one without multi. When set to true, it unlocks the capability to inject multiple instances using a single token – in this case, our Plugins.
And that's it! We're now ready to leverage our plugin-based library.
Putting It into Practice
As with any Angular library, our first step is to import it and configure its module.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { CoreSystemModule } from 'projects/core-system/src/public-api';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
CoreSystemModule.forRoot({
coreOptionsA: '_A_',
coreOptionsB: '_B_',
coreOptionsZ: '_Z_',
}),
],
bootstrap: [AppComponent],
})
export class AppModule {}
With the core module configured, we can start interacting with the OrchestratorService or any other public API our library exposes.
However, without any Plugins registered, our system won't be very effective. Let's add the one we've already created.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { CoreSystemModule } from 'projects/core-system/src/public-api';
import { ExamplePluginModule } from 'projects/example-plugin/src/public-api';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
CoreSystemModule.forRoot({
coreOptionsA: '_A_',
coreOptionsB: '_B_',
coreOptionsZ: '_Z_',
}),
ExamplePluginModule.forRoot({
optionA: '-A-',
optionB: '-B-',
optionZ: '-Z-',
}),
],
bootstrap: [AppComponent],
})
export class AppModule {}
And just like that, our system is fully configured without the CoreSystemModule having any knowledge about this specific Plugin.
Notice how straightforward the configuration of this Plugin Architecture is. In the same way we imported our ExamplePluginModule, we can import any number of additional Plugins following the same pattern. The Core System will automatically discover all imported Plugins and manage their execution.
With everything in place, let's demonstrate a basic usage scenario.
import { Component } from '@angular/core';
import { OrchestratorService } from 'projects/core-system/src/public-api';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
title = 'plugins-architecture-demo';
constructor(private orchestrator: OrchestratorService) {
this.orchestrator.coreOperationA();
}
}
The consuming application or library only needs to inject the entry point we've exposed. In this case, that's the OrchestrationService. From there, it can interact with the library freely. The Plugins themselves are an internal implementation detail; the client code remains completely unaware of their existence, except when providing their configuration.
Wrapping Up
The Plugin Architecture approach offers a robust way to build extensible applications by applying the Inversion of Control Principle, shifting core responsibilities toward the Plugins themselves.
Throughout this series, we walked through constructing a custom Angular library grounded in a Plugin Architecture, relying on Angular Dependency Injection for wiring things together. We also touched on the key Dependency Injection concepts that help keep our Plugins isolated from the Core System.
The complete implementation is available in this repository.
For a practical example of this pattern in action, take a look at Lumberjack.


