Angular Dependency Injection

Read one Angular interview question at a time, then flip for the complete explanation, example, and code.

0/68 Known filtered set
Level
Read Full Guide Question: 1 of 68

Angular Interview Questions · Angular Dependency Injection Companion Article

Chapter 13: Angular Dependency Injection

Angular applications are usually made from many components, services, utilities, configuration objects, and framework features. These parts often depend on one another.

For example, a component may need:

  • a service to load products,
  • another service to check authentication,
  • a configuration value containing an API URL,
  • and a logging service for debugging.

Creating all these dependencies manually inside every component would make the application difficult to maintain and test.

Angular solves this problem using Dependency Injection, commonly called DI.

Dependency Injection allows a class to declare what it needs while Angular takes responsibility for finding or creating those dependencies.

Dependency Injection Basics

A dependency is simply something that another class needs in order to perform its work.

Consider a product component that needs a service:

TypeScript
export class ProductComponent {
  private productService = new ProductService();
}

This works in a very small example, but the component is now responsible for creating the service itself.

This creates several problems:

  • The component is tightly coupled to ProductService.
  • Replacing the service becomes harder.
  • Testing becomes more difficult.
  • Service configuration must be handled manually.
  • Sharing the same service instance becomes difficult.
  • Dependencies can become deeply nested.

With Angular dependency injection, the component asks Angular for the dependency instead.

TypeScript
import { Component, inject } from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product',
  template: `...`
})
export class ProductComponent {
  private productService = inject(ProductService);
}

Angular's injector looks for a provider associated with ProductService, obtains the appropriate instance, and supplies it to the component.

Basic DI Flow

The process can be understood as:

Text
Component
   ↓
Requests ProductService
   ↓
Angular Injector
   ↓
Finds Provider
   ↓
Creates or retrieves ProductService
   ↓
Returns Service Instance

The component does not need to know how the service was created.

This separation is one of the main benefits of dependency injection.

Providers

A provider tells Angular how a dependency should be created or obtained.

You can think of a provider as a rule stored inside Angular's dependency injection system.

Conceptually, it says:

Text
When someone requests this token,
provide this value.

A simple class provider may look like:

TypeScript
providers: [ProductService]

This is shorthand for a more explicit provider configuration:

TypeScript
providers: [
  {
    provide: ProductService,
    useClass: ProductService
  }
]

The provide property identifies the dependency.

The provider configuration tells Angular how to obtain its value.

Angular supports several important provider strategies:

  • class providers,
  • value providers,
  • factory providers,
  • existing providers.

Each is useful for a different situation.

Injectable Services

Services are one of the most common dependencies in Angular applications.

A service usually contains functionality that should not belong directly inside a component.

Typical responsibilities include:

  • calling APIs,
  • authentication,
  • business logic,
  • caching,
  • logging,
  • application state,
  • calculations,
  • notifications,
  • configuration access,
  • communication between unrelated parts of an application.

Example:

TypeScript
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  getProducts() {
    return ['Laptop', 'Phone', 'Tablet'];
  }
}

A component can inject the service:

TypeScript
import { Component, inject } from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-products',
  template: `...`
})
export class ProductsComponent {
  private productService = inject(ProductService);

  products = this.productService.getProducts();
}

The component uses the service without manually constructing it.

@Injectable

@Injectable() is an Angular decorator used on classes that participate in Angular's dependency injection system.

A common service declaration is:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class UserService {
}

The important part is:

TypeScript
providedIn: 'root'

It tells Angular that the service should be available through the application's root environment injector.

For most application-wide services, this is the simplest approach.

Examples include:

Text
AuthService
UserService
ProductService
LoggingService
ConfigurationService
ShoppingCartService

Why @Injectable() Matters

Suppose one service depends on another:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class OrderService {
  private paymentService = inject(PaymentService);
}

Angular needs dependency metadata to construct and manage these injectable classes correctly.

@Injectable() marks the class for use with Angular's DI system.

inject()

Angular provides the inject() function as a direct way to retrieve a dependency from the current injection context.

Example:

TypeScript
import { inject } from '@angular/core';

export class ProductComponent {
  private productService = inject(ProductService);
}

Multiple services can be injected:

TypeScript
export class CheckoutComponent {
  private cartService = inject(CartService);
  private paymentService = inject(PaymentService);
  private logger = inject(LoggerService);
}

This approach makes dependencies easy to identify near the fields where they are used.

Injecting Angular Services

Angular framework services can also be injected.

Example:

TypeScript
import { Router } from '@angular/router';
import { inject } from '@angular/core';

export class LoginComponent {
  private router = inject(Router);
}

Injection Is Based on Tokens

Although the syntax looks like Angular is simply retrieving a class, internally the argument acts as an injection token.

TypeScript
inject(ProductService)

Angular searches for the provider registered for ProductService.

Constructor Injection

Constructor injection is another established way of declaring dependencies.

Example:

TypeScript
import { Component } from '@angular/core';

@Component({
  selector: 'app-products',
  template: `...`
})
export class ProductsComponent {
  constructor(private productService: ProductService) {
  }
}

Angular supplies ProductService when it creates the component.

Multiple dependencies can be declared:

TypeScript
constructor(
  private authService: AuthService,
  private userService: UserService,
  private loggerService: LoggerService
) {
}

Constructor Injection vs inject()

Both approaches use Angular's dependency injection system.

Constructor style:

TypeScript
constructor(private productService: ProductService) {
}

inject() style:

TypeScript
private productService = inject(ProductService);

inject() can be particularly convenient in:

  • field initializers,
  • functional APIs,
  • provider factories,
  • guards,
  • interceptors,
  • helper functions that execute within an injection context.

The important design principle is not simply which syntax is shorter. Dependencies should remain clear, focused, and appropriate to the responsibility of the class.

Provider Tokens

Angular needs a key that identifies every dependency stored in an injector.

This key is called an injection token or provider token.

A class itself can act as a token.

Example:

TypeScript
providers: [
  {
    provide: ProductService,
    useClass: ProductService
  }
]

Here:

TypeScript
ProductService

is the token.

When Angular receives:

TypeScript
inject(ProductService)

it searches for the provider associated with that token.

Classes work well as tokens because classes exist at runtime.

However, not every dependency is represented by a class.

Sometimes you need to inject:

  • strings,
  • numbers,
  • configuration objects,
  • arrays,
  • interfaces,
  • function references,
  • environment-specific settings.

For these situations, Angular provides InjectionToken.

InjectionToken

TypeScript interfaces do not exist as JavaScript values at runtime.

For example:

TypeScript
interface AppConfig {
  apiUrl: string;
  production: boolean;
}

You cannot reliably use the interface itself as a runtime DI token.

Instead, create an InjectionToken.

TypeScript
import { InjectionToken } from '@angular/core';

export interface AppConfig {
  apiUrl: string;
  production: boolean;
}

export const APP_CONFIG =
  new InjectionToken<AppConfig>('app.config');

Now provide a value:

TypeScript
providers: [
  {
    provide: APP_CONFIG,
    useValue: {
      apiUrl: 'https://api.example.com',
      production: true
    }
  }
]

Inject it:

TypeScript
private config = inject(APP_CONFIG);

You can then use:

TypeScript
this.config.apiUrl

Why Generic Types Are Useful

This declaration:

TypeScript
InjectionToken<AppConfig>

tells TypeScript what value the token represents.

As a result:

TypeScript
const config = inject(APP_CONFIG);

has the expected AppConfig type.

This improves type safety and development tooling.

Good Uses for InjectionToken

Typical examples include:

Text
API_BASE_URL
APP_CONFIG
FEATURE_FLAGS
DEFAULT_PAGE_SIZE
LOGGER_CONFIG
AUTH_OPTIONS
STORAGE_PROVIDER

Use meaningful names so developers can immediately understand what each token represents.

Hierarchical Injectors

Angular's dependency injection system is hierarchical.

This means there is not necessarily one single flat container containing every dependency.

Providers can exist at different levels of the application.

A simplified view looks like:

Text
Application-Level Injector
        ↓
Parent Component
        ↓
Child Component
        ↓
Nested Child Component

When Angular needs a dependency, it resolves it according to the applicable injector hierarchy.

The location where a provider is registered affects:

  • where the dependency is available,
  • how long the instance can live,
  • whether components share an instance,
  • whether a subtree gets its own instance.

This makes provider placement an architectural decision rather than just a syntax choice.

Component-Level Providers

A service can be provided directly by a component.

Example:

TypeScript
@Component({
  selector: 'app-editor',
  template: `...`,
  providers: [EditorStateService]
})
export class EditorComponent {
  private editorState = inject(EditorStateService);
}

Angular creates the service in association with that component's injector scope.

Children in the relevant component subtree can also resolve that provider unless another provider closer to them overrides it.

Why Use Component-Level Providers?

Component-level providers are useful when each component instance needs isolated state.

Imagine two editors:

HTML
<app-editor></app-editor>
<app-editor></app-editor>

If EditorStateService is provided by EditorComponent, each editor can receive its own service instance.

This can be useful for:

  • wizard state,
  • form state,
  • temporary editor state,
  • local feature state,
  • isolated dashboards,
  • reusable widgets.

Using a root provider in these situations could unintentionally cause unrelated component instances to share data.

Root-Level Providers

Application-wide services are commonly registered at the root level.

The typical declaration is:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class AuthService {
}

The service is then available throughout the application wherever that provider can be reached through DI.

Root-level providers are appropriate for services such as:

  • authentication,
  • API communication,
  • global application state,
  • analytics,
  • logging,
  • user session management,
  • global configuration.

Example:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class AuthService {
  isLoggedIn() {
    return true;
  }
}

Any component can request it:

TypeScript
private authService = inject(AuthService);

Root vs Component Scope

Use root scope when state or functionality should be shared widely.

Use component scope when a particular component subtree requires its own independent instance.

Choosing the correct scope prevents many state-management problems.

Value Providers

A value provider supplies an existing value instead of asking Angular to create a class.

It uses:

TypeScript
useValue

Example:

TypeScript
export const API_URL =
  new InjectionToken<string>('api.url');

Provider:

TypeScript
providers: [
  {
    provide: API_URL,
    useValue: 'https://api.example.com'
  }
]

Usage:

TypeScript
private apiUrl = inject(API_URL);

Value providers are useful for:

  • configuration values,
  • constants,
  • feature settings,
  • test doubles,
  • static objects,
  • predefined functions.

Another example:

TypeScript
export const PAGE_SIZE =
  new InjectionToken<number>('page.size');
TypeScript
providers: [
  {
    provide: PAGE_SIZE,
    useValue: 20
  }
]

The consumer does not need to know where the value came from.

Class Providers

A class provider tells Angular which class should be instantiated for a token.

It uses:

TypeScript
useClass

Example:

TypeScript
providers: [
  {
    provide: LoggerService,
    useClass: ConsoleLoggerService
  }
]

Now:

TypeScript
inject(LoggerService)

returns an instance created using ConsoleLoggerService.

This is useful when you want consumers to depend on one abstraction while changing the implementation.

For example:

Text
LoggerService
    ↓
ConsoleLoggerService

Development could use one implementation:

TypeScript
{
  provide: LoggerService,
  useClass: ConsoleLoggerService
}

while another configuration could use:

TypeScript
{
  provide: LoggerService,
  useClass: RemoteLoggerService
}

The consumer does not need to change.

Factory Providers

Sometimes creating a dependency requires logic.

For example, a service implementation may depend on:

  • application configuration,
  • environment,
  • another service,
  • runtime conditions.

Angular supports factory providers through:

TypeScript
useFactory

Example:

TypeScript
export function createLogger() {
  return new LoggerService();
}

Provider:

TypeScript
providers: [
  {
    provide: LoggerService,
    useFactory: createLogger
  }
]

Factories become more useful when dependencies are involved.

TypeScript
export function createApiService(config: AppConfig) {
  return new ApiService(config.apiUrl);
}

A factory provider can declare the values it depends on.

Another modern pattern is using inject() inside a provider factory when the factory executes in Angular's injection context.

Example:

TypeScript
providers: [
  {
    provide: ApiService,
    useFactory: () => {
      const config = inject(APP_CONFIG);
      return new ApiService(config.apiUrl);
    }
  }
]

Factory providers are useful when simple class construction is not enough.

Typical situations include:

  • conditional implementations,
  • configuration-dependent services,
  • adapting third-party libraries,
  • constructing objects from several dependencies.

Avoid putting large amounts of business logic inside provider factories. Their main responsibility should be dependency creation and configuration.

Existing Providers

An existing provider creates an alias for another registered dependency.

It uses:

TypeScript
useExisting

Example:

TypeScript
providers: [
  NewLoggerService,
  {
    provide: OldLoggerService,
    useExisting: NewLoggerService
  }
]

Now requests for both tokens can resolve to the existing NewLoggerService instance.

Conceptually:

Text
OldLoggerService
       ↓
      Alias
       ↓
NewLoggerService Instance

This is different from creating another class instance.

Why useExisting Is Useful

It is useful when:

  • migrating from an old token to a new token,
  • maintaining backward compatibility,
  • providing multiple names for one dependency,
  • exposing one implementation through different abstractions.

If you want two tokens to point to the same existing dependency, useExisting is often more appropriate than useClass.

Understanding Provider Types Together

The major provider strategies solve different problems.

ProviderMain PurposeTypical Example
useValueSupply an existing valueConfiguration
useClassInstantiate another classAlternative implementation
useFactoryCreate value using logicRuntime configuration
useExistingAlias another providerBackward compatibility

A useful mental model is:

Text
useValue
"Here is the value."

useClass
"Create this class."

useFactory
"Run this function to create the value."

useExisting
"Use the dependency that already belongs to this other token."

Injection Context

The inject() function cannot simply be called from arbitrary application code.

It requires an Angular injection context.

An injection context exists while Angular is performing certain dependency-injection operations.

Common valid situations include:

  • field initialization of an Angular-created injectable class,
  • construction of a class created through DI,
  • provider factories,
  • InjectionToken factories,
  • Angular APIs that intentionally execute callbacks within an injection context.

Example:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class OrderService {
  private logger = inject(LoggerService);
}

The field initializer runs while Angular is constructing the injectable class, so inject() can resolve the dependency.

Another example:

TypeScript
export const API_CLIENT = new InjectionToken<ApiClient>(
  'api.client',
  {
    factory: () => {
      const config = inject(APP_CONFIG);
      return new ApiClient(config.apiUrl);
    }
  }
);

The token factory executes in an injection context.

Invalid Usage

Calling inject() later from an ordinary method is not automatically valid.

Do not assume this will work:

TypeScript
loadData() {
  const service = inject(ProductService);
}

The method may execute long after Angular has completed dependency construction.

A safer approach is to inject the dependency during class initialization:

TypeScript
private productService = inject(ProductService);

loadData() {
  return this.productService.getProducts();
}

Understanding injection context prevents confusing runtime dependency injection errors.

Optional Dependencies

Sometimes a dependency is useful when available but should not be required.

Angular supports optional dependency resolution.

Using inject():

TypeScript
private logger = inject(LoggerService, {
  optional: true
});

If Angular cannot find the provider, the result can be null instead of the injection failing because the provider is missing.

The application should therefore handle the missing dependency.

TypeScript
save() {
  this.logger?.log('Saving data');
}

Optional injection is useful for:

  • optional integrations,
  • plugin-style features,
  • optional logging,
  • configurable library behavior,
  • parent-child coordination where the parent dependency may not exist.

Do not make a dependency optional simply to hide configuration errors.

If a service is essential to the feature, it should normally remain required.

How Angular Resolves a Dependency

Consider:

TypeScript
private userService = inject(UserService);

A simplified resolution process is:

Text
1. Angular receives the UserService token.
2. Angular examines the relevant injector.
3. It looks for a provider matching UserService.
4. If necessary, resolution continues through the applicable injector hierarchy.
5. When a provider is found, Angular obtains or creates its value.
6. Angular returns that value to the consumer.

This explains why provider location matters.

Suppose:

Text
Application
    |
    └── Dashboard
          |
          └── Report

If Report requests a dependency that is not locally provided, Angular can resolve it from an appropriate ancestor injector.

If Dashboard provides a different version of the same token, consumers within that scope may receive the closer provider rather than the more global one.

This ability to override dependencies is one of the powerful features of hierarchical DI.

Practical Example: Application Configuration

Suppose an application needs API configuration.

First define the type:

TypeScript
export interface ApiConfig {
  baseUrl: string;
  timeout: number;
}

Create a token:

TypeScript
import { InjectionToken } from '@angular/core';

export const API_CONFIG =
  new InjectionToken<ApiConfig>('api.config');

Provide the configuration:

TypeScript
providers: [
  {
    provide: API_CONFIG,
    useValue: {
      baseUrl: 'https://api.example.com',
      timeout: 5000
    }
  }
]

Use it inside a service:

TypeScript
@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private config = inject(API_CONFIG);

  getApiUrl() {
    return `${this.config.baseUrl}/products`;
  }
}

This design is better than hardcoding the URL in several services.

The configuration is centralized, strongly typed, and replaceable.

Practical Example: Switching Implementations

Suppose an application has:

TypeScript
export abstract class StorageService {
  abstract save(key: string, value: string): void;
}

One implementation:

TypeScript
@Injectable()
export class BrowserStorageService extends StorageService {
  save(key: string, value: string) {
    localStorage.setItem(key, value);
  }
}

Provider:

TypeScript
providers: [
  {
    provide: StorageService,
    useClass: BrowserStorageService
  }
]

Consumer:

TypeScript
export class SettingsComponent {
  private storage = inject(StorageService);

  saveSettings() {
    this.storage.save('theme', 'dark');
  }
}

SettingsComponent depends on StorageService, not directly on BrowserStorageService.

Another implementation can therefore replace it later with less impact on the consumer.

This is one of the practical reasons DI improves maintainability.

Practical Example: Component-Specific State

Consider a reusable shopping filter panel.

TypeScript
@Injectable()
export class FilterStateService {
  selectedCategory = '';
}

Provide it directly on the component:

TypeScript
@Component({
  selector: 'app-product-filter',
  providers: [FilterStateService],
  template: `...`
})
export class ProductFilterComponent {
  private filterState = inject(FilterStateService);
}

If multiple instances of ProductFilterComponent appear on the page, each component can manage its own local filter service instance.

This is very different from putting the service in the root injector, where consumers would normally share application-level state.

Dependency Injection and Testing

DI also improves testability.

Suppose a component depends on an API service.

Production implementation:

TypeScript
class ProductApiService {
  getProducts() {
    // Real API request
  }
}

During testing, the real API dependency can be replaced with a controlled test implementation.

TypeScript
class FakeProductApiService {
  getProducts() {
    return ['Test Product'];
  }
}

The test can configure:

TypeScript
{
  provide: ProductApiService,
  useClass: FakeProductApiService
}

The component itself does not need to be rewritten.

This allows tests to isolate the behavior being tested instead of depending on external systems.

Value providers can also be useful in tests:

TypeScript
{
  provide: API_CONFIG,
  useValue: {
    baseUrl: 'test-api',
    timeout: 100
  }
}

Common Dependency Injection Mistakes

Creating Services Manually

Avoid:

TypeScript
private service = new ProductService();

when the service is designed to participate in Angular DI.

Manual creation bypasses Angular's provider system and makes dependencies harder to replace.

Prefer:

TypeScript
private service = inject(ProductService);

when appropriate.

Providing a Global Service at Component Level Accidentally

Consider:

TypeScript
@Component({
  providers: [AuthService]
})

If AuthService is supposed to represent one application-wide authentication state, creating a component-scoped version may produce unexpected independent instances.

Before adding a service to providers, decide whether you actually want a new scoped instance.

Providing the Same Service in Unnecessary Places

Developers sometimes register the same service repeatedly because they assume every component needs its own provider declaration.

A service with:

TypeScript
@Injectable({
  providedIn: 'root'
})

normally does not need to be added to every component's providers array.

Provider scope should be intentional.

Using Strings as Informal Tokens

Avoid loosely defined token patterns that are difficult to type-check and maintain.

For configuration and other non-class dependencies, prefer properly typed InjectionToken objects.

Example:

TypeScript
export const APP_CONFIG =
  new InjectionToken<AppConfig>('app.config');

Calling inject() From Arbitrary Methods

Avoid treating inject() as a global service locator that can be called anywhere.

Prefer resolving the dependency in a valid injection context and storing it:

TypeScript
private service = inject(ProductService);

Then use:

TypeScript
load() {
  return this.service.getProducts();
}

Making Every Dependency Optional

This may hide genuine provider configuration problems:

TypeScript
inject(ImportantService, {
  optional: true
});

Use optional dependencies only when the absence of that dependency is an expected and supported application state.

NullInjectorError and Missing Providers

One common DI problem occurs when Angular cannot find a provider for a requested token.

Conceptually:

Text
Component
   ↓
requests Service
   ↓
Injector searches
   ↓
No provider found
   ↓
Injection error

When this happens, check:

  • Is the service decorated correctly?
  • Does it use providedIn: 'root'?
  • Was the provider registered manually where required?
  • Is the token correct?
  • Is the component outside the intended provider scope?
  • Are two different InjectionToken objects being created accidentally?
  • Is the provider registered only inside another component subtree?

Do not immediately add providers everywhere. First identify the intended lifetime and scope of the dependency.

Provider Scope and Application Design

Provider placement affects application behavior.

Consider these three cases.

Application-Wide State

Text
Authentication
User session
Global preferences
Application configuration

A root-level provider is usually appropriate.

Feature-Specific Shared State

Text
Checkout workflow
Admin feature state
Feature-specific cache

The provider can be scoped to the appropriate feature or route configuration where the application architecture requires it.

Component-Specific State

Text
Editor state
Form wizard
Reusable widget state
Temporary selection state

A component-level provider may be appropriate.

Choosing scope deliberately helps prevent unexpected data sharing.

Dependency Injection and Separation of Concerns

One of the biggest architectural benefits of DI is that components do not need to know how their dependencies are implemented.

Without DI:

Text
Component
   ↓
Creates API client
   ↓
Loads configuration
   ↓
Creates logger
   ↓
Handles storage

With DI:

Text
Component
   ↓
Requests required dependencies
   ↓
Angular DI system supplies them

The component can focus on presentation and feature behavior rather than infrastructure construction.

This usually produces code that is easier to:

  • understand,
  • test,
  • reuse,
  • replace,
  • extend,
  • maintain.

Choosing Between Provider Types

Use useValue when you already have the value.

TypeScript
{
  provide: API_URL,
  useValue: 'https://example.com'
}

Use useClass when Angular should create an implementation class.

TypeScript
{
  provide: LoggerService,
  useClass: ConsoleLoggerService
}

Use useFactory when creation requires logic.

TypeScript
{
  provide: ApiService,
  useFactory: () => {
    const config = inject(APP_CONFIG);
    return new ApiService(config.apiUrl);
  }
}

Use useExisting when another registered dependency should be used for the token.

TypeScript
{
  provide: OldLogger,
  useExisting: NewLogger
}

Use InjectionToken when the dependency cannot conveniently use a class as its runtime token.

TypeScript
const API_URL =
  new InjectionToken<string>('api.url');

Dependency Injection Best Practices

Keep Dependencies Focused

A component requiring ten or fifteen services may be doing too much.

For example:

TypeScript
private auth = inject(AuthService);
private user = inject(UserService);
private products = inject(ProductService);
private orders = inject(OrderService);
private payment = inject(PaymentService);
private email = inject(EmailService);
private logger = inject(LoggerService);

This can indicate that responsibilities should be reviewed.

Prefer Clear Provider Scope

Ask:

Text
Should this instance belong to the whole application?

Should it belong only to one feature?

Should every component instance get its own copy?

The answer determines where the provider should live.

Keep Configuration Outside Business Logic

Instead of:

TypeScript
const apiUrl = 'https://api.example.com';

inside many services, inject configuration through a suitable token.

This improves portability and maintainability.

Use Typed Tokens

Prefer:

TypeScript
new InjectionToken<AppConfig>('app.config')

over loosely typed configuration patterns.

The compiler can then help identify incorrect usage.

Avoid Service Locator Style

DI should make dependencies clear.

Do not create generic helper code whose purpose is simply to retrieve arbitrary services from an injector whenever required.

Explicit dependencies generally make architecture easier to understand.

Keep Factory Providers Small

A provider factory should mainly construct or configure the dependency.

If a factory contains substantial business logic, that logic probably belongs in a dedicated service.

Make Optional Dependencies Truly Optional

Code using an optional dependency should work correctly when the dependency does not exist.

Example:

TypeScript
private analytics = inject(AnalyticsService, {
  optional: true
});

trackPage() {
  this.analytics?.track('page-view');
}

The feature continues working even when analytics is not provided.

A Mental Model for Angular DI

A useful way to understand Angular Dependency Injection is to separate it into four ideas.

1. Consumer

The class that needs something.

TypeScript
ProductComponent

2. Token

The identifier used to request the dependency.

TypeScript
ProductService

or:

TypeScript
APP_CONFIG

3. Provider

The rule describing how the token should be resolved.

TypeScript
{
  provide: APP_CONFIG,
  useValue: config
}

4. Injector

The Angular runtime mechanism that stores and resolves providers.

The complete relationship is:

Text
Consumer
   ↓
Requests Token
   ↓
Injector searches for Provider
   ↓
Provider supplies value
   ↓
Dependency returned to Consumer

Once this model is clear, advanced DI features become much easier to understand.

Real-World Dependency Injection Example

Consider an e-commerce checkout component.

It may need:

Text
CartService
PaymentService
OrderService
NotificationService
CHECKOUT_CONFIG

The component declares the dependencies:

TypeScript
@Component({
  selector: 'app-checkout',
  template: `...`
})
export class CheckoutComponent {
  private cart = inject(CartService);
  private payment = inject(PaymentService);
  private orders = inject(OrderService);
  private notifications = inject(NotificationService);
  private config = inject(CHECKOUT_CONFIG);

  checkout() {
    // Coordinate checkout operations
  }
}

Each dependency has a separate responsibility.

The checkout component coordinates them without needing to know:

  • how the services were created,
  • where configuration originated,
  • which concrete logger is being used,
  • how the provider hierarchy is implemented internally.

That is the practical value of dependency injection.

Dependency Injection Decision Guide

When adding a new dependency, ask these questions:

  1. Is the dependency a service class?

Consider an injectable service.

  1. Should it be available application-wide?

Consider root-level provisioning.

  1. Does each component need its own instance?

Consider component-level providers.

  1. Is the dependency a configuration value or interface-shaped object?

Consider InjectionToken.

  1. Do you already have the exact value?

Use useValue.

  1. Should another class implement the dependency?

Use useClass.

  1. Does construction require runtime logic?

Use useFactory.

  1. Should the token point to an already registered dependency?

Use useExisting.

  1. Is the dependency genuinely optional?

Use optional injection and handle the missing value.

  1. Are you calling inject() from valid DI-managed code?

Confirm that the code runs in an injection context.

Key Takeaways

Angular Dependency Injection separates using a dependency from creating a dependency.

The most important concepts to remember are:

  • Dependencies are resources required by components, services, directives, and other application code.
  • Providers tell Angular how dependencies should be supplied.
  • @Injectable() allows classes to participate correctly in Angular's DI system.
  • inject() retrieves dependencies from the current injection context.
  • Constructor injection is another way of declaring class dependencies.
  • Classes can act as dependency injection tokens.
  • InjectionToken is useful for configuration, interfaces, primitives, functions, and other values without a suitable runtime class token.
  • Root-level providers are appropriate for broadly shared services.
  • Component-level providers can create isolated service instances for component subtrees.
  • Angular uses hierarchical dependency resolution rather than one simple flat provider container.
  • useValue supplies an existing value.
  • useClass tells Angular which class to instantiate.
  • useFactory creates a dependency using a function.
  • useExisting aliases one token to another dependency.
  • inject() must execute in an appropriate injection context.
  • Optional injection should be used only when a dependency can genuinely be absent.
  • Correct provider scope is important for predictable state sharing.
  • DI makes Angular applications easier to test, configure, extend, and maintain.

A strong understanding of tokens, providers, injectors, scope, and injection context is more valuable than simply memorizing inject() or @Injectable() syntax. These concepts explain how Angular connects application classes and how developers can control dependency lifetime and implementation as an application grows.

Question Hint