A simple pattern that keeps your Angular app modular, scalable, and sane.
Why I Needed It
A few months ago, I was working on a large Angular 20 project.
It had everything: analytics, error monitoring, A/B testing, feature flags, and more integrations than I could count.
Every new service wanted to âinitializeâ itself at startup.
Soon, my main.ts looked like a spaghetti monster of async calls and environment checks.
I knew there had to be a cleaner way.
Thatâs when I revisited something most Angular devs overlook: multi-providers.
âď¸ The Hidden Power of Multi-Providers
Angularâs DI system can do more than inject single services.
With a multi provider, you can register multiple implementations under one InjectionToken.
When you inject that token, you get an array of all registered items, perfect for a plugin system.
This pattern lets you add or remove features without touching the core codebase.
Each plugin is just a class with an init() method and a unique ID.
Step 1 â Define the Plugin Contract
Minimum Angular version: 19+ (uses provideAppInitializer)
// core/plugins/plugin.token.ts
import { InjectionToken } from '@angular/core';
export interface AppPlugin {
readonly id: string;
readonly order?: number;
isEnabled?(): boolean;
init(): void | Promise<void>;
}
export const APP_PLUGINS = new InjectionToken<AppPlugin[]>('app.plugins');
Thatâs it. A minimal interface.
Each plugin knows how to initialize itself, and Angular will collect them all through this token.
Step 2 â A Registry to Run Them All
We need one lightweight service to coordinate everything at startup.
// core/plugins/plugin-registry.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { APP_PLUGINS, AppPlugin } from './plugin.token';
@Injectable({ providedIn: 'root' })
export class PluginRegistry {
private readonly plugins = inject(APP_PLUGINS, { optional: true }) ?? [];
private readonly platformId = inject(PLATFORM_ID);
async initAll(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return; // skip SSR
const eligible = this.plugins
.filter(p => p.isEnabled?.() ?? true)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
for (const p of eligible) {
try {
await Promise.resolve(p.init());
console.info(`[Plugin] ${p.id} initialized`);
} catch (err) {
console.error(`[Plugin] ${p.id} failed`, err);
}
}
}
}
In one of my apps, this registry replaced nearly 200 lines of manual startup logic.
Now, every integration just registers itself and runs automatically.
Step 3 â Bootstrap Cleanly with provideAppInitializer
Angular 19 introduced provideAppInitializer(), a small but powerful helper that replaces boilerplate APP_INITIALIZER factories.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAppInitializer, inject } from '@angular/core';
import { AppComponent } from './app/app.component';
import { PluginRegistry } from './core/plugins/plugin-registry.service';
import { APP_PLUGINS } from './core/plugins/plugin.token';
import { SentryPlugin } from './core/plugins/sentry.plugin';
import { GoogleAnalyticsPlugin } from './core/plugins/ga.plugin';
bootstrapApplication(AppComponent, {
providers: [
{ provide: APP_PLUGINS, useClass: SentryPlugin, multi: true },
{ provide: APP_PLUGINS, useClass: GoogleAnalyticsPlugin, multi: true },
provideAppInitializer(() => inject(PluginRegistry).initAll()),
]
});
Compatibility (Angular 15â18):
These versions donât have provideAppInitializer(). Use the deprecated APP_INITIALIZER token instead; everything else stays the same.
// Angular 15â18
import { APP_INITIALIZER, inject } from '@angular/core';
import { PluginRegistry } from './core/plugins/plugin-registry.service';
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useFactory: () => {
const registry = inject(PluginRegistry);
return () => registry.initAll();
}
}
];
One line replaces all the âinit this, then thatâ chaos, and it runs safely before your root component renders.
Step 4 â Real Plugins in Action
Hereâs how the plugins look in practice.
Each one is self-contained and only loads if itâs actually enabled.
// core/plugins/ga.plugin.ts
import { Injectable } from '@angular/core';
import { AppPlugin } from './plugin.token';
@Injectable({ providedIn: 'root' })
export class GoogleAnalyticsPlugin implements AppPlugin {
readonly id = 'ga4';
readonly order = 10;
isEnabled() {
return !!(window as any).ENV?.GA_MEASUREMENT_ID;
}
async init() {
const id = (window as any).ENV.GA_MEASUREMENT_ID;
if (!id) return;
await this.loadScript(`https://www.googletagmanager.com/gtag/js?id=${id}`);
(window as any).dataLayer = (window as any).dataLayer || [];
function gtag(...args: any[]) { (window as any).dataLayer.push(args); }
(window as any).gtag = gtag;
gtag('js', new Date());
gtag('config', id, { anonymize_ip: true });
}
private loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.async = true;
s.src = src;
s.onload = () => resolve();
s.onerror = reject;
document.head.appendChild(s);
});
}
}
// core/plugins/sentry.plugin.ts
import { Injectable } from '@angular/core';
import { AppPlugin } from './plugin.token';
@Injectable({ providedIn: 'root' })
export class SentryPlugin implements AppPlugin {
readonly id = 'sentry';
readonly order = 5;
isEnabled() {
return !!(window as any).ENV?.SENTRY_DSN;
}
async init() {
const dsn = (window as any).ENV.SENTRY_DSN;
if (!dsn) return;
const Sentry = await import('@sentry/browser');
Sentry.init({ dsn, tracesSampleRate: 0.1 });
}
}
In production, both run automatically, no imports, no conditionals, no spaghetti.
Step 5 â Feature-Scoped Plugins
This pattern scales nicely across domains.
A payments library, for example, can register its own plugin without touching the core app:
// libs/payments/payment.plugin.ts
import { APP_PLUGINS, AppPlugin } from '@app/core/plugins/plugin.token';
import { Provider } from '@angular/core';
class PaymentsAuditPlugin implements AppPlugin {
readonly id = 'payments-audit';
init() { /* custom logic */ }
}
export const providePaymentsPlugins: Provider[] = [
{ provide: APP_PLUGINS, useClass: PaymentsAuditPlugin, multi: true }
];
Attach it right in the route config:
{
path: 'payments',
providers: [providePaymentsPlugins],
loadComponent: () => import('./payments.component').then(m => m.PaymentsComponent)
}
Now every feature can extend global behavior independently. No central bottlenecks.
⥠What This Gives You
From experience, this small pattern delivers huge wins:
- Extensibility: add or remove integrations safely
- Stability: a broken plugin canât crash the app
- SSR friendly: browser-only code stays browser-side
- Testable: mock any plugin easily in unit tests
- Maintainable: cross-cutting logic lives in one place
Why It Matters
In one of our enterprise apps, we had six different analytics SDKs, all fighting for control of window.dataLayer.
After moving to this plugin registry, we bootstrapped them cleanly, logged failures, and never touched them again.
Multi-providers are the unsung hero of Angularâs DI system.
They turn a monolith into a composable frontend, with zero external libraries and full type safety.
Looking Ahead
Angularâs DI has been rock-solid for years, and it keeps improving around developer experience and performance.
The good news is: the multi-provider pattern isnât going anywhere.
Itâs stable, fast, and perfectly aligned with Angularâs standalone architecture.
đŹ Final Thoughts
If youâve ever scaled an Angular app across multiple teams, you know how startup logic can spiral out of control.
This pattern wonât just clean it up, itâll future-proof it.
Give it a try in your next project.
Youâll never go back to manual âinitâ scripts again.
Looking back, this pattern didnât just clean up our code, it changed how we think about scalability in Angular.
Sometimes, true architecture isnât about adding more frameworks, but about using what Angular already gives us, properly.
Author: Anastasios Theodosiou
Senior Software Engineer | Angular Certified Developer | Building Scalable Frontend Systems
If you found this useful, follow for more deep dives into real-world Angular architecture.
Top comments (1)
I think this beautiful
But you load all enabled plugins on init
You may use module federation in order to build lazy loaded angular plugin based app
Some comments may only be visible to logged-in visitors. Sign in to view all comments.