How I Built a Plugin-Based Architecture in Angular 19+ đź’‰

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 20 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.

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.

Similar Posts