Dependency Injection — Providers, Scope, and the inject() Function

Angular’s dependency injection (DI) system is not just a convenience — it is the backbone of the entire framework. Every service, component, directive, pipe, and guard is managed by Angular’s injector hierarchy. Understanding how providers work, what scopes mean in practice (root, component, platform), and when to use the modern inject() function versus constructor injection gives you the ability to control service lifetimes, share state precisely, and test every layer of your application in isolation. This lesson builds a complete mental model of Angular’s DI system from the ground up.

Provider Scope Reference

Scope Syntax Instance Created Use For
Root (singleton) providedIn: 'root' Once for the entire application Shared services — TaskService, AuthService, ApiService
Component providers: [MyService] in @Component Once per component instance — destroyed with component Services that hold per-component state (form state, local filters)
Route providers: [MyService] in route definition Once per route — destroyed on navigation away Services scoped to a feature page
Platform providedIn: 'platform' Once per platform (rare — SSR) Services shared between multiple Angular apps on the page
Any (deprecated) providedIn: 'any' Once per lazy-loaded module Deprecated — avoid

Provider Configuration Forms

Form Syntax Provides Use For
Class provider { provide: MyService, useClass: MyService } New instance of the class Default — shorthand: just MyService
Alternative class { provide: Logger, useClass: ConsoleLogger } Instance of a different class Swap implementation — testing, environment-specific
Value provider { provide: API_URL, useValue: 'https://...' } The exact value Configuration constants, tokens
Factory provider { provide: Token, useFactory: fn, deps: [Dep] } Return value of the factory function Complex initialisation, conditional providers
Existing (alias) { provide: NewToken, useExisting: OldToken } Same instance as another token Backwards-compatible aliasing
Note: providedIn: 'root' is the recommended scope for almost all services in a MEAN Stack Angular application. It ensures a single shared instance for the lifetime of the app, enables Angular’s tree-shaking (services not actually used are excluded from the production bundle), and requires no configuration in app.config.ts. Use component-level providers only when a service needs to hold state that is strictly scoped to one component instance — like a form management service or a local selection state.
Tip: The modern inject() function (Angular 14+) is now the preferred alternative to constructor injection. private taskService = inject(TaskService) at the class field level is cleaner than the constructor parameter approach, works identically with DI, and enables composable injection patterns — you can call inject() inside factory functions, base classes, and helper functions that are not themselves injected. The only constraint is that inject() must be called in an injection context (constructor, field initialiser, or a function called during DI resolution).
Warning: Component-level providers (providers: [MyService] in @Component) create a new instance of the service for every component instance, and that instance is destroyed when the component is destroyed. If you accidentally provide a service at the component level that you intended to be a singleton, each component will have its own isolated state — API calls will be made multiple times, cached data will not be shared, and state changes in one component will not be visible in others. Always verify your intended scope.

Complete DI Examples

// ── InjectionToken — typed constant injection ──────────────────────────────
import { InjectionToken, inject } from '@angular/core';

// Define a typed token for configuration
export const API_URL = new InjectionToken<string>('API_URL');
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

export interface AppConfig {
    apiUrl:    string;
    wsUrl:     string;
    maxUploadSize: number;
}

// app.config.ts — provide the token value
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideHttpClient(),

        // Provide a typed constant
        { provide: API_URL, useValue: environment.apiUrl },

        // Provide a config object
        {
            provide: APP_CONFIG,
            useValue: {
                apiUrl:        environment.apiUrl,
                wsUrl:         environment.wsUrl,
                maxUploadSize: 10 * 1024 * 1024,   // 10 MB
            } as AppConfig,
        },

        // Factory provider — depends on other injected tokens
        {
            provide: LoggerService,
            useFactory: (config: AppConfig) => {
                return new LoggerService(config.apiUrl, environment.production);
            },
            deps: [APP_CONFIG],
        },
    ],
};

// ── inject() function — modern DI approach ────────────────────────────────
import { Injectable, inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class TaskService {
    // Modern: class field injection — no constructor needed
    private http      = inject(HttpClient);
    private apiUrl    = inject(API_URL);
    private authStore = inject(AuthStore);
    private toast     = inject(ToastService);

    getAll() {
        return this.http.get<Task[]>(`${this.apiUrl}/tasks`);
    }
}

// ── Component-level provider — scoped service instance ────────────────────
import { Component, inject } from '@angular/core';

// Service with local state
@Injectable()   // No providedIn — must be explicitly provided
export class TaskFilterState {
    status   = signal('');
    priority = signal('');
    query    = signal('');

    reset(): void {
        this.status.set('');
        this.priority.set('');
        this.query.set('');
    }
}

// Component provides its own instance — destroyed with component
@Component({
    selector: 'app-task-list',
    standalone: true,
    providers: [TaskFilterState],   // new instance per TaskListComponent
    template: `...`,
})
export class TaskListComponent {
    filterState = inject(TaskFilterState);   // gets the locally-scoped instance
}

// ── Swapping service implementations via providers ─────────────────────────
// Abstract logger interface
export abstract class Logger {
    abstract log(message: string, data?: unknown): void;
    abstract error(message: string, error?: unknown): void;
}

// Production implementation
@Injectable()
export class RemoteLogger extends Logger {
    log(msg: string, data?: unknown)   { /* send to logging service */ }
    error(msg: string, err?: unknown)  { /* alert + send to error tracker */ }
}

// Development implementation
@Injectable()
export class ConsoleLogger extends Logger {
    log(msg: string, data?: unknown)  { console.log('[LOG]', msg, data); }
    error(msg: string, err?: unknown) { console.error('[ERR]', msg, err); }
}

// app.config.ts — swap based on environment
providers: [
    {
        provide:   Logger,
        useClass:  environment.production ? RemoteLogger : ConsoleLogger,
    },
]

// Any service that injects Logger gets the right implementation automatically:
@Injectable({ providedIn: 'root' })
export class TaskService {
    private logger = inject(Logger);   // ConsoleLogger in dev, RemoteLogger in prod
}

// ── Route-level providers ─────────────────────────────────────────────────
// app.routes.ts
export const routes: Routes = [
    {
        path:      'tasks',
        component: TaskListComponent,
        providers: [
            TaskFilterState,    // one instance for this route and all child routes
            TaskSortState,
        ],
    },
];

How It Works

Step 1 — The Injector Tree Mirrors the Component Tree

Angular maintains a hierarchy of injectors: the root injector at the top (created once for the app), route-level injectors for lazy-loaded features, and component injectors for components with local providers. When a component requests a service, Angular walks up the injector tree until it finds a provider that can supply the service. Root-provided services are found at the top and always return the same instance. Component-provided services are found at the component level and return a new instance per component.

Step 2 — providedIn: ‘root’ Enables Tree-Shaking

When a service is registered with providedIn: 'root', Angular’s build tools can detect at compile time whether any component or service actually injects it. If a service is declared but never used, the build tool can exclude it from the production bundle entirely. This tree-shaking does not work when services are registered in providers arrays — those are always included in the bundle even if unused.

Step 3 — inject() Works in Any Injection Context

The inject() function must be called during Angular’s DI resolution — in a constructor body, in class field initialisers (which run during the constructor), or in functions explicitly called within those contexts. It cannot be called outside DI resolution (e.g. in ngOnInit() or event handlers). This restriction exists because inject() reads from the current injector, which is only available during construction. The benefit is cleaner code with no boilerplate constructor parameters.

Step 4 — InjectionToken Provides Type-Safe Non-Class Dependencies

Only TypeScript classes can be used as DI tokens by default because they have a runtime identity. For non-class dependencies (strings, numbers, interfaces, configuration objects), use InjectionToken<T>. The generic type parameter provides TypeScript type safety — inject(API_URL) returns a string without any type assertion. The string description in new InjectionToken('description') helps identify the token in Angular’s debugging tools.

Step 5 — Factory Providers Handle Complex Initialisation

When creating a service requires more than just calling new ServiceClass() — perhaps it needs configuration data, environment checks, or other injected dependencies — a factory provider gives you full control. The useFactory function receives the dependencies listed in deps as arguments and returns the configured instance. This is how you conditionally provide different implementations or perform async-style initialisation (using a synchronous factory that returns a pre-configured instance).

Real-World Example: Environment-Aware Service Configuration

// environments/environment.ts
export const environment = {
    production:  false,
    apiUrl:      'http://localhost:3000/api/v1',
    wsUrl:       'http://localhost:3000',
    enableDebug: true,
};

// environments/environment.prod.ts
export const environment = {
    production:  true,
    apiUrl:      'https://api.taskmanager.io/v1',
    wsUrl:       'wss://api.taskmanager.io',
    enableDebug: false,
};

// core/tokens.ts — all injection tokens in one place
import { InjectionToken } from '@angular/core';

export const ENVIRONMENT = new InjectionToken<typeof environment>('ENVIRONMENT');
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
export const WS_URL       = new InjectionToken<string>('WS_URL');

// app.config.ts
import { environment } from '../environments/environment';
import { ENVIRONMENT, API_BASE_URL, WS_URL } from './core/tokens';

export const appConfig: ApplicationConfig = {
    providers: [
        { provide: ENVIRONMENT,   useValue: environment },
        { provide: API_BASE_URL,  useValue: environment.apiUrl },
        { provide: WS_URL,        useValue: environment.wsUrl },
        provideRouter(routes),
        provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    ],
};

// Usage in any service:
@Injectable({ providedIn: 'root' })
export class ApiService {
    private baseUrl = inject(API_BASE_URL);
    private http    = inject(HttpClient);

    get<T>(path: string) {
        return this.http.get<T>(`${this.baseUrl}${path}`);
    }

    post<T>(path: string, body: unknown) {
        return this.http.post<T>(`${this.baseUrl}${path}`, body);
    }
}

Common Mistakes

Mistake 1 — Providing a singleton service at component level by accident

❌ Wrong — each component instance gets its own CartService — cart data not shared:

@Component({
    providers: [CartService]  // local scope — each instance has its own cart!
})
export class ProductListComponent {
    cart = inject(CartService);
}

✅ Correct — use providedIn: ‘root’ for shared singleton services:

@Injectable({ providedIn: 'root' })
export class CartService { /* single shared instance */ }

Mistake 2 — Calling inject() outside an injection context

❌ Wrong — inject() called in ngOnInit, outside DI resolution:

export class MyComponent implements OnInit {
    ngOnInit(): void {
        const service = inject(MyService);  // Error: inject() must be called from an injection context
    }
}

✅ Correct — inject() only in constructor or field initialiser:

export class MyComponent {
    private service = inject(MyService);  // class field — runs during construction

    ngOnInit(): void {
        this.service.doSomething();  // use the injected field
    }
}

Mistake 3 — Forgetting @Injectable() on non-root services

❌ Wrong — service without decorator cannot have its own dependencies:

export class TaskFilterState {   // No @Injectable() decorator!
    private http = inject(HttpClient);   // Error: inject() not in DI context
}

✅ Correct — all services that use inject() or DI need @Injectable():

@Injectable()   // required even without providedIn when provided elsewhere
export class TaskFilterState {
    private http = inject(HttpClient);  // now works correctly
}

Quick Reference

Need Approach
Application singleton @Injectable({ providedIn: 'root' })
Per-component instance @Injectable() + providers: [MyService] in @Component
Per-route instance providers: [MyService] in route definition
Inject a service private svc = inject(MyService)
Inject a token private url = inject(API_URL)
Typed constant token new InjectionToken<string>('description')
Swap implementation { provide: Logger, useClass: ConsoleLogger }
Provide a value { provide: TOKEN, useValue: 'value' }
Factory provider { provide: T, useFactory: fn, deps: [Dep] }

🧠 Test Yourself

A service is decorated with @Injectable({ providedIn: 'root' }). A component also lists it in its providers: [MyService] array. Which instance does the component receive when it injects the service?