Skip to main content

9 - Advanced Dependency Injection in Angular

Master advanced DI in Angular: inject() function, InjectionToken, multi providers, functional guards/interceptors, EnvironmentInjector, and testing.

What do you think about this article?

At a Glance

Master advanced DI in Angular: inject() function, InjectionToken, multi providers, functional guards/interceptors, EnvironmentInjector, and testing.

Advanced Dependency Injection in Angular

Dependency Injection (DI) is the architectural backbone of Angular. Since version 2, the framework has offered a powerful and sophisticated DI system, but with Angular 14+ and the advent of standalone components, the system has evolved radically. New APIs like inject(), tree-shakable providers, functional guards and interceptors have transformed how we manage dependencies in modern Angular applications.

In this ninth article of the Modern Angular series, we will explore advanced Dependency Injection: from the new inject() function to custom tokens, from the injector hierarchy to multi-provider patterns, all the way to testing and dynamic component creation with injection context. If you already master the DI basics, this article will take you to the next level.

What You Will Learn

  • The inject() function and why it replaces constructor injection
  • How to create and use typed InjectionToken
  • The complete injector hierarchy: Root, Platform, Element, Component
  • The providedIn options and their impact on the bundle
  • The MULTI pattern for interceptors, guards, and multiple providers
  • Tree-shakable providers and bundle size optimization
  • Functional guards and interceptors with CanActivateFn and withInterceptors()
  • EnvironmentInjector and dynamic component creation
  • runInInjectionContext for utility functions with inject()
  • DI testing with TestBed and overrideProvider

Modern Angular Series Overview

# Article Topic
1Angular SignalsFine-grained reactivity
2Zoneless Change DetectionRemoving Zone.js
3New Template Syntax: @if, @for, @deferControl flow blocks
4Standalone ComponentsModule-free architecture
5Signal FormsReactive forms with Signals
6Angular SSR and Incremental HydrationServer-side rendering
7Core Web Vitals in AngularPerformance optimization
8Angular PWAProgressive Web Apps
9You are here - Advanced Dependency InjectionDI patterns and tokens
10Migrating from Angular 17 to 21Step-by-step migration

1. The inject() Function: Goodbye Constructor

The inject() function, introduced as a stable API in Angular 14, represents the most significant change in modern DI. Instead of declaring dependencies as constructor parameters, you use a function that can be called wherever an active injection context exists: in the constructor, in field initializers, in factory functions, and in utility functions.

Constructor injection vs inject()

// BEFORE: Constructor injection (traditional approach)
@Component({
  selector: 'app-user-list',
  standalone: true,
  template: '...'
})
export class UserListComponent {
  constructor(
    private userService: UserService,
    private router: Router,
    private route: ActivatedRoute,
    @Inject(API_URL) private apiUrl: string,
    @Optional() private logger?: LoggerService
  ) {}
}

// AFTER: inject() function (modern approach)
@Component({
  selector: 'app-user-list',
  standalone: true,
  template: '...'
})
export class UserListComponent {
  private userService = inject(UserService);
  private router = inject(Router);
  private route = inject(ActivatedRoute);
  private apiUrl = inject(API_URL);
  private logger = inject(LoggerService, { optional: true });

  // Fields are already available, no constructor needed
  users = this.userService.getUsers();
}
    

inject() Advantages Over Constructor Injection

Aspect Constructor inject()
Readability Long parameter lists, hard to manage Inline declaration, clear and concise
Inheritance Requires super() with all parameters No issues, fields are self-contained
Reusability Only in Angular class constructors Usable in functions, factories, utilities
Type safety Complete with decorators Complete, automatic type inference
Tree-shaking Limited by decorator metadata Optimal, direct class reference
Optional/Self/SkipSelf Decorators: @Optional(), @Self() Options object: { optional: true, self: true }

Where Can inject() Be Used

The inject() function works ONLY within an injection context. Calling it outside this context throws a runtime error NG0203: inject() must be called from an injection context.

  • Allowed: component/service/directive/pipe constructors, field initializers, provider factory functions, functions executed inside runInInjectionContext()
  • Not allowed: component methods called after initialization, event callbacks, setTimeout/setInterval, standalone functions without context

A particularly powerful pattern is using inject() to create reusable utility functions that encapsulate injection logic:

Utility functions with inject()

// utils/navigation.ts
export function injectRouteParams() {
  const route = inject(ActivatedRoute);
  return toSignal(
    route.paramMap.pipe(
      map(params => ({
        id: params.get('id'),
        slug: params.get('slug')
      }))
    )
  );
}

export function injectQueryParams() {
  const route = inject(ActivatedRoute);
  return toSignal(route.queryParamMap);
}

// Usage in component
@Component({ ... })
export class ProductDetailComponent {
  params = injectRouteParams();
  // params() returns { id: '123', slug: 'my-product' }
}
    

2. InjectionToken: Typed Tokens for Non-Class Values

When a dependency is not a class but a primitive value (string, number, configuration object), Angular cannot use the class itself as a lookup key. InjectionToken solves this problem by creating unique, typed tokens that serve as keys in the DI registry.

Creating and using InjectionToken

import { InjectionToken, inject } from '@angular/core';

// Token with static value
export const API_BASE_URL = new InjectionToken<string>(
  'API_BASE_URL'
);

// Token with factory function (tree-shakable!)
export const IS_MOBILE = new InjectionToken<boolean>(
  'IS_MOBILE',
  {
    providedIn: 'root',
    factory: () => {
      const platform = inject(PLATFORM_ID);
      if (isPlatformBrowser(platform)) {
        return window.innerWidth < 768;
      }
      return false;
    }
  }
);

// Token for complex configuration
export interface AppConfig {
  apiUrl: string;
  production: boolean;
  features: {
    darkMode: boolean;
    analytics: boolean;
  };
}

export const APP_CONFIG = new InjectionToken<AppConfig>(
  'APP_CONFIG',
  {
    providedIn: 'root',
    factory: () => ({
      apiUrl: 'https://api.example.com',
      production: false,
      features: {
        darkMode: true,
        analytics: true
      }
    })
  }
);

// Usage in components
@Component({ ... })
export class AppComponent {
  private config = inject(APP_CONFIG);
  private isMobile = inject(IS_MOBILE);
}
    

When to Use InjectionToken

  • Configuration values: API URLs, feature flags, timeouts, limits
  • Browser APIs: window, document, localStorage (for SSR compatibility)
  • Swappable implementations: logging strategy, storage adapter, authentication provider
  • Dynamic factories: runtime-computed values that depend on other services

3. The Injector Hierarchy

One of the most powerful (and complex) aspects of Angular DI is its hierarchical structure. There is no single dependency container: Angular maintains a tree of injectors that mirrors the application structure. When a component requests a dependency, Angular walks up the tree until it finds one.

Angular Injector Hierarchy

  +---------------------------------------------+
  |           Platform Injector                  |
  |  (shared across Angular applications)        |
  |  Providers: PLATFORM_ID, PLATFORM_INITIALIZER|
  +----------------------+----------------------+
                         |
  +----------------------v----------------------+
  |            Root Injector                     |
  |  (providedIn: 'root', app.config.ts)        |
  |  Providers: HttpClient, Router, root services|
  +----------------------+----------------------+
                         |
         +---------------+---------------+
         |                               |
  +------v------+                +-------v-----+
  | Module      |                | Module      |
  | Injector    |                | Injector    |
  | (lazy mod.) |                | (lazy mod.) |
  +------+------+                +------+------+
         |                               |
  +------v------+                +-------v-----+
  | Element     |                | Element     |
  | Injector    |                | Injector    |
  | (component) |                | (component) |
  +------+------+                +------+------+
         |                               |
  +------v------+                +-------v-----+
  | Element     |                | Element     |
  | Injector    |                | Injector    |
  | (child)     |                | (child)     |
  +-------------+                +-------------+
      

Hierarchy Levels

Level Scope Creation Typical Use
Platform All Angular apps on the page platformBrowserDynamic() Services shared across micro-frontends
Root Entire application (singleton) providedIn: 'root' or app.config.ts HttpClient, Router, global services
Module Lazy-loaded module Module providers array Feature-specific services
Element Component and its children Component providers array Local state, multiple instances

Dependency resolution follows a precise path: Angular starts from the injector of the component requesting the dependency and walks up the tree until it finds a matching provider. If no provider is found, a NullInjectorError is thrown (unless the dependency is marked as optional).

Component-level injection: multiple instances

// A service that manages local state
@Injectable()
export class FormStateService {
  private fields = signal<Map<string, any>>(new Map());
  private dirty = signal(false);

  setValue(key: string, value: any) {
    this.fields.update(m => {
      const newMap = new Map(m);
      newMap.set(key, value);
      return newMap;
    });
    this.dirty.set(true);
  }

  isDirty = this.dirty.asReadonly();
}

// Each component instance gets its own FormStateService
@Component({
  selector: 'app-editable-card',
  standalone: true,
  providers: [FormStateService], // LOCAL instance
  template: `
    <div class="card" [class.dirty]="formState.isDirty()">
      <ng-content />
    </div>
  `
})
export class EditableCardComponent {
  protected formState = inject(FormStateService);
}
    

4. The providedIn Options

The @Injectable() decorator accepts the providedIn option that determines which injector the service is automatically registered in. This option is fundamental for tree-shaking: Angular can remove unused services from the final bundle.

Available providedIn Options

Value Scope Tree-Shakable Use Case
'root' Singleton for the entire application Yes Global services (Auth, API, State)
'platform' Shared across Angular applications Yes Micro-frontends, Elements
'any' One instance per lazy-loaded module Yes Isolated state for lazy features
Component One instance per component No Local state, forms, dialogs
Comparing providedIn options

// Global singleton - the most common approach
@Injectable({ providedIn: 'root' })
export class AuthService {
  private currentUser = signal<User | null>(null);
  isAuthenticated = computed(() => this.currentUser() !== null);
}

// Shared across Angular apps on the same page
@Injectable({ providedIn: 'platform' })
export class SharedThemeService {
  theme = signal<'light' | 'dark'>('dark');
}

// One instance per lazy-loaded module
@Injectable({ providedIn: 'any' })
export class FeatureAnalyticsService {
  private events: AnalyticsEvent[] = [];
  // Each lazy feature gets its own independent tracker
}

// Instance per component (not tree-shakable)
@Injectable() // No providedIn
export class DialogStateService {
  isOpen = signal(false);
  data = signal<any>(null);
}

// Must be registered in the component providers array:
@Component({
  providers: [DialogStateService]
})
export class MyDialogComponent {
  state = inject(DialogStateService);
}
    

Be Careful with providedIn: 'any'

The 'any' option creates a separate instance for each lazy-loaded module. This can cause unexpected behavior if you expect a singleton. Use it only when you explicitly want to isolate state between feature modules. With standalone architecture and lazy routes, 'any' is rarely needed: prefer 'root' for singletons or component providers for local state.

5. Multi Providers: The MULTI Pattern

The multi-provider pattern allows associating multiple values with the same injection token. When the token is requested, Angular returns an array of all registered values. This pattern underpins critical features such as HTTP interceptors, route guards, and application initializers.

Creating and using multi-providers

import { InjectionToken, inject } from '@angular/core';

// Multi-provider token for custom validators
export const FORM_VALIDATORS = new InjectionToken<FormValidator[]>(
  'FORM_VALIDATORS'
);

export interface FormValidator {
  name: string;
  validate(value: any): string | null;
}

// Registering multiple providers with multi: true
export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: FORM_VALIDATORS,
      useValue: {
        name: 'required',
        validate: (v: any) => v ? null : 'Field is required'
      },
      multi: true
    },
    {
      provide: FORM_VALIDATORS,
      useValue: {
        name: 'minLength',
        validate: (v: string) =>
          v?.length >= 3 ? null : 'Minimum 3 characters'
      },
      multi: true
    },
    {
      provide: FORM_VALIDATORS,
      useFactory: () => {
        const http = inject(HttpClient);
        return {
          name: 'uniqueEmail',
          validate: async (v: string) => {
            // Async validation via API
            return null;
          }
        };
      },
      multi: true
    }
  ]
};

// Consuming in a service
@Injectable({ providedIn: 'root' })
export class ValidationService {
  private validators = inject(FORM_VALIDATORS);

  validateAll(value: any): string[] {
    return this.validators
      .map(v => v.validate(value))
      .filter((err): err is string => err !== null);
  }
}
    

Built-in Multi-Providers in Angular

  • HTTP_INTERCEPTORS - HTTP interceptors (legacy class-based approach)
  • APP_INITIALIZER - Functions executed before app bootstrap
  • ENVIRONMENT_INITIALIZER - Initializers for environment injectors
  • NG_VALIDATORS / NG_ASYNC_VALIDATORS - Form validators
  • ROUTES - Dynamic route configuration

6. Tree-Shakable Providers

One of the main advantages of modern Angular DI is support for tree-shaking providers. When a service is registered with providedIn: 'root' and a factory in the decorator, the bundler can completely remove it from the final build if no component imports it.

Tree-shakable vs Non tree-shakable

// TREE-SHAKABLE: the service self-registers
// If no component/service imports it, it is removed from the bundle
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  track(event: string) {
    // If AnalyticsService is not imported anywhere,
    // all this code is removed from the bundle
    console.log('Track:', event);
  }
}

// TREE-SHAKABLE with factory: computed dependencies
export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>(
  'FEATURE_FLAGS',
  {
    providedIn: 'root',
    factory: () => {
      const http = inject(HttpClient);
      // The factory is removed if FEATURE_FLAGS
      // is never injected
      return { darkMode: true, newDashboard: false };
    }
  }
);

// NOT TREE-SHAKABLE: registered in the providers array
// Always included in the bundle, even if unused
export const appConfig: ApplicationConfig = {
  providers: [
    LegacyService,  // Always in the bundle!
    { provide: LoggerService, useClass: ConsoleLoggerService }
  ]
};
    

Tree-Shaking Impact on Bundle Size

Approach Registration Tree-Shakable In Bundle If Unused
providedIn: 'root' In the service decorator Yes No (removed)
InjectionToken with factory In the token constructor Yes No (removed)
providers array In app.config.ts or component No Yes (always present)
NgModule providers In the module No Yes (always present)

Tree-Shaking Best Practices

  • Always use providedIn: 'root' for global services
  • Define factories in the tokens themselves, not in the providers array
  • Avoid registering services in the providers array of app.config.ts unless necessary (overrides, multi-providers)
  • Monitor bundle size with ng build --stats-json and webpack-bundle-analyzer

7. Functional Guards and Interceptors

Starting with Angular 15, guards and interceptors have evolved from classes with interfaces to simple functions. This functional approach reduces boilerplate, improves composability, and natively leverages the inject() function.

7.1 Functional Guards

Guards: from classes to functions

// BEFORE: Guard as class (deprecated)
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(
    private auth: AuthService,
    private router: Router
  ) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): boolean | UrlTree {
    if (this.auth.isAuthenticated()) {
      return true;
    }
    return this.router.createUrlTree(['/login']);
  }
}

// AFTER: Guard as function
export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) {
    return true;
  }
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url }
  });
};

// Guard with parameters: factory function
export function roleGuard(requiredRole: string): CanActivateFn {
  return (route, state) => {
    const auth = inject(AuthService);
    const router = inject(Router);

    if (auth.hasRole(requiredRole)) {
      return true;
    }
    return router.createUrlTree(['/forbidden']);
  };
}

// Route configuration
export const routes: Routes = [
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(m => m.DashboardComponent)
  },
  {
    path: 'admin',
    canActivate: [authGuard, roleGuard('admin')],
    loadComponent: () => import('./admin/admin.component')
      .then(m => m.AdminComponent)
  }
];
    

7.2 Functional Interceptors

Functional interceptors with withInterceptors()

import { HttpInterceptorFn } from '@angular/common/http';

// Authentication interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.getToken();

  if (token) {
    const cloned = req.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });
    return next(cloned);
  }
  return next(req);
};

// Logging interceptor
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const startTime = Date.now();
  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        const duration = Date.now() - startTime;
        console.log(`${req.method} ${req.url} - ${duration}ms`);
      }
    })
  );
};

// Retry interceptor with backoff
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, retryCount) => {
        if (error.status === 429 || error.status >= 500) {
          return timer(Math.pow(2, retryCount) * 1000);
        }
        throw error;
      }
    })
  );
};

// Registration in app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor,
        loggingInterceptor,
        retryInterceptor
      ])
    )
  ]
};
    

Class-based vs Functional Interceptors

Aspect Class-based (legacy) Functional (modern)
Syntax Class + HttpInterceptor interface Simple HttpInterceptorFn function
Registration HTTP_INTERCEPTORS multi-provider withInterceptors([])
DI Constructor inject()
Composition Hard to parameterize Factory functions for parameters
Tree-shaking Limited Optimal

8. EnvironmentInjector and Dynamic Components

EnvironmentInjector is a type of injector introduced in Angular 14 that operates at the "environment" level (root, route, lazy module) rather than at the DOM element level. It is essential when creating components dynamically with createComponent() or ViewContainerRef, as it allows dynamically created components to access the correct injection context.

Creating a custom EnvironmentInjector

import {
  Component,
  EnvironmentInjector,
  createEnvironmentInjector,
  createComponent,
  ViewContainerRef,
  inject
} from '@angular/core';

// Service for creating components with a custom DI context
@Injectable({ providedIn: 'root' })
export class DynamicComponentService {
  private parentInjector = inject(EnvironmentInjector);

  createIsolatedComponent<T>(
    component: Type<T>,
    vcr: ViewContainerRef,
    providers: StaticProvider[] = []
  ) {
    // Create a new EnvironmentInjector with additional providers
    const childInjector = createEnvironmentInjector(
      [
        { provide: DIALOG_DATA, useValue: { title: 'My Dialog' } },
        ...providers
      ],
      this.parentInjector
    );

    // Create the component in the new context
    const componentRef = createComponent(component, {
      environmentInjector: childInjector,
      hostElement: document.createElement('div')
    });

    // Insert into the view
    vcr.insert(componentRef.hostView);

    // Important: destroy the injector when the component is destroyed
    componentRef.onDestroy(() => childInjector.destroy());

    return componentRef;
  }
}

// Usage in a host component
@Component({
  selector: 'app-dialog-host',
  standalone: true,
  template: `<ng-container #outlet />`
})
export class DialogHostComponent {
  private dynamicService = inject(DynamicComponentService);
  private vcr = inject(ViewContainerRef);

  openDialog(dialogComponent: Type<any>, data: any) {
    this.dynamicService.createIsolatedComponent(
      dialogComponent,
      this.vcr,
      [{ provide: DIALOG_DATA, useValue: data }]
    );
  }
}
    

When to Use EnvironmentInjector

  • Dynamic dialogs/modals: components created at runtime that need injected data
  • Plugin system: dynamic loading of components with isolated providers
  • Micro-frontends: each micro-app gets its own injection context
  • Advanced testing: creating isolated injection contexts for tests

9. runInInjectionContext: Injection Anywhere

The runInInjectionContext() function, introduced in Angular 16, allows running arbitrary code within an injection context. This solves one of the limitations of inject(): the ability to call it only during the construction phase. With runInInjectionContext(), any function can access dependencies.

Using runInInjectionContext()

import {
  runInInjectionContext,
  EnvironmentInjector,
  inject,
  DestroyRef
} from '@angular/core';

// Utility function that requires inject()
function createAutoSaveEffect(formId: string) {
  const http = inject(HttpClient);
  const destroyRef = inject(DestroyRef);

  const intervalId = setInterval(() => {
    const formData = getFormData(formId);
    http.post('/api/autosave', formData).subscribe();
  }, 30000);

  // Automatic cleanup when the context is destroyed
  destroyRef.onDestroy(() => clearInterval(intervalId));
}

// Usage in component: works in constructor (active context)
@Component({ ... })
export class EditorComponent {
  constructor() {
    createAutoSaveEffect('editor-form');
  }
}

// Usage outside constructor: runInInjectionContext
@Component({ ... })
export class DashboardComponent {
  private envInjector = inject(EnvironmentInjector);

  onTabSelected(tabId: string) {
    // This method is called from a click handler,
    // so inject() would not work directly
    runInInjectionContext(this.envInjector, () => {
      createAutoSaveEffect(tabId);
    });
  }
}

// Advanced pattern: create a function-based "service locator"
export function createServiceAccessor<T>(token: ProviderToken<T>) {
  let cachedInstance: T | undefined;
  return {
    init: () => {
      cachedInstance = inject(token);
    },
    get: () => {
      if (!cachedInstance) {
        throw new Error('Service not initialized. Call init() first.');
      }
      return cachedInstance;
    }
  };
}
    

Be Careful with runInInjectionContext

While powerful, runInInjectionContext() should not be overused. Use it when you have a concrete reason to run code with injection outside the construction phase. If you find yourself using it frequently, the component design can likely be improved. Also remember that the injector passed must be an EnvironmentInjector (not an ElementInjector), so providers registered at the component level will not be accessible.

10. Testing Dependency Injection

One of the greatest advantages of DI is testability. Angular provides powerful tools for configuring, replacing, and verifying dependencies in unit tests. With the modern approach based on inject() and standalone components, test setup has become significantly simpler.

Testing with TestBed and provider overrides

import { TestBed } from '@angular/core/testing';

describe('UserService', () => {
  let service: UserService;
  let httpMock: jest.Mocked<HttpClient>;

  beforeEach(() => {
    // Mock the HttpClient
    const mockHttp = {
      get: jest.fn(),
      post: jest.fn()
    };

    TestBed.configureTestingModule({
      providers: [
        UserService,
        { provide: HttpClient, useValue: mockHttp },
        { provide: API_BASE_URL, useValue: 'http://test-api.com' }
      ]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpClient) as jest.Mocked<HttpClient>;
  });

  it('should fetch users from the API', () => {
    const mockUsers = [{ id: 1, name: 'Alice' }];
    httpMock.get.mockReturnValue(of(mockUsers));

    service.getUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
      expect(httpMock.get).toHaveBeenCalledWith(
        'http://test-api.com/users'
      );
    });
  });
});

// Testing a standalone component with overrides
describe('DashboardComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [DashboardComponent],
    })
    .overrideComponent(DashboardComponent, {
      set: {
        providers: [
          {
            provide: AnalyticsService,
            useValue: { track: jest.fn() }
          }
        ]
      }
    })
    .compileComponents();
  });

  it('should track page view on init', () => {
    const fixture = TestBed.createComponent(DashboardComponent);
    const analytics = fixture.debugElement.injector.get(AnalyticsService);

    fixture.detectChanges();

    expect(analytics.track).toHaveBeenCalledWith('dashboard_view');
  });
});

// Testing functional guards
describe('authGuard', () => {
  it('should redirect to login when not authenticated', () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: AuthService,
          useValue: { isAuthenticated: () => false }
        },
        provideRouter([])
      ]
    });

    const injector = TestBed.inject(EnvironmentInjector);

    // Run the guard in the injection context
    const result = runInInjectionContext(injector, () =>
      authGuard(
        {} as ActivatedRouteSnapshot,
        { url: '/dashboard' } as RouterStateSnapshot
      )
    );

    expect(result).toBeInstanceOf(UrlTree);
  });

  it('should allow access when authenticated', () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: AuthService,
          useValue: { isAuthenticated: () => true }
        },
        provideRouter([])
      ]
    });

    const injector = TestBed.inject(EnvironmentInjector);

    const result = runInInjectionContext(injector, () =>
      authGuard(
        {} as ActivatedRouteSnapshot,
        { url: '/dashboard' } as RouterStateSnapshot
      )
    );

    expect(result).toBe(true);
  });
});
    

DI Testing Patterns

Scenario Method Example
Replace a service provide + useValue { provide: AuthService, useValue: mockAuth }
Component-level override overrideComponent Component local providers
Test InjectionToken provide + useValue { provide: API_URL, useValue: 'http://test' }
Test functional guards runInInjectionContext Run the guard in TestBed context
Spy on real methods inject + spyOn spyOn(TestBed.inject(Svc), 'method')

DI Testing Best Practices

  • Prefer manual mocks over jasmine.createSpyObj for better type control
  • Test services in isolation: mock all dependencies, test only the service logic
  • Use TestBed.inject() to access services registered in the testing module
  • For functional guards and interceptors: runInInjectionContext() with the TestBed injector
  • Verify errors: test that NullInjectorError is thrown when required providers are missing

Summary and Next Steps

In this article we explored advanced Dependency Injection in Angular, from the new inject() API all the way to testing. Modern Angular DI is much more than a simple object creation mechanism: it is a sophisticated system that influences the architecture, performance, and maintainability of your application.

Key Takeaways

  • The inject() function replaces constructor injection, offering better readability, reusability, and tree-shaking
  • InjectionToken enables injecting non-class values with strong typing and tree-shakable factories
  • The injector hierarchy (Platform, Root, Module, Element) determines the scope and lifetime of dependencies
  • providedIn: 'root' is the preferred option for tree-shakable singleton services
  • The multi: true pattern underpins interceptors, guards, and initializers
  • Functional guards (CanActivateFn) and functional interceptors (HttpInterceptorFn) reduce boilerplate and improve composability
  • EnvironmentInjector and createEnvironmentInjector() enable dynamic component creation with isolated DI contexts
  • runInInjectionContext() allows using inject() outside the construction phase
  • DI testing leverages TestBed, overrideComponent, and runInInjectionContext for comprehensive mocking and verification

In the next and final article of the series, Migrating from Angular 17 to 21, we will see how to apply all the knowledge gained in this series to migrate an existing application to Angular's modern APIs, with a step-by-step guide covering every aspect of the transition.

Frequently asked questions

What is the main change introduced by the inject() function in modern Angular Dependency Injection?

The inject() function allows declaring dependencies as a function that can be called within an injection context, replacing the need to declare them as constructor parameters.

How does Angular resolve dependencies when they are not classes but primitive values such as strings or numbers?

Angular uses typed tokens created with InjectionToken to serve as keys in the DI registry for resolving non-class dependencies.

What is the purpose of the providedIn options in the @Injectable decorator, and how does it affect tree-shaking?

The providedIn option determines which injector a service is automatically registered in, allowing Angular to remove unused services from the final bundle through tree-shaking.

What is the multi-provider pattern, and what are its benefits in modern Angular applications?

The multi-provider pattern allows associating multiple values with the same injection token, returning an array of all registered values when requested, which underpins features like HTTP interceptors and route guards.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects