DEV Community

Cover image for Dependency Injection in Angular: From Constructor Injection to Modern DI
Abanoub Kerols
Abanoub Kerols

Posted on

Dependency Injection in Angular: From Constructor Injection to Modern DI


Dependency Injection (DI) is one of the most important concepts in Angular.

If you understand Angular DI deeply, you understand much more than how to inject a service into a component. You understand how Angular creates objects, controls their lifetime, chooses implementations, scopes dependencies, handles configuration, supports testing, and builds hierarchical application architecture.

And Angular DI has evolved significantly.

We started with:

constructor(private userService: UserService) {}
Enter fullscreen mode Exit fullscreen mode

Then Angular introduced:

private userService = inject(UserService);
Enter fullscreen mode Exit fullscreen mode

Modern Angular goes even further with:

  • EnvironmentInjector
  • InjectionToken
  • useValue
  • useClass
  • useFactory
  • useExisting
  • multi
  • providedIn
  • providers
  • viewProviders
  • Optional
  • Self
  • SkipSelf
  • Host
  • forwardRef
  • runInInjectionContext
  • functional guards and resolvers
  • provider functions such as provideRouter() and provideHttpClient()
  • environment-level providers
  • hierarchical scopes
  • tree-shakable providers
  • advanced library DI patterns

This article explains the old Angular DI model, the modern model, and how everything fits together, then builds a complete application that uses these concepts in practice.


1. What Is Dependency Injection?

Before Angular, imagine this service:

export class UserService {
  getUser() {
    return {
      id: 1,
      name: 'Abanoub'
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now a component needs it.

A naive approach is:

export class UserComponent {

  private userService = new UserService();

}
Enter fullscreen mode Exit fullscreen mode

This works, but it creates strong coupling.

The component decides:

  • which implementation to use
  • how the dependency is created
  • when it is created
  • how its dependencies are created

The component is doing too much.

Instead, we want:

export class UserComponent {

  constructor(private userService: UserService) {}

}
Enter fullscreen mode Exit fullscreen mode

The component says:

"I need a UserService."

It does not say:

"I will create a UserService."

That distinction is the foundation of Dependency Injection.

Angular's DI system is responsible for providing dependencies to components, directives, services, functions, and other framework-managed code.


2. Dependency Injection vs Dependency Inversion

These concepts are related but not identical.

Dependency Injection

DI is a mechanism/pattern for supplying dependencies from outside a class.

constructor(private logger: LoggerService) {}
Enter fullscreen mode Exit fullscreen mode

Dependency Inversion

Dependency Inversion is a broader architectural principle.

High-level modules should depend on abstractions rather than concrete implementations.

For example:

export abstract class PaymentGateway {
  abstract pay(amount: number): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Then:

@Injectable()
export class StripePaymentGateway implements PaymentGateway {
  async pay(amount: number) {
    // Stripe implementation
  }
}
Enter fullscreen mode Exit fullscreen mode

Angular DI can connect them:

providers: [
  {
    provide: PaymentGateway,
    useClass: StripePaymentGateway
  }
]
Enter fullscreen mode Exit fullscreen mode

Now the application depends on:

PaymentGateway
Enter fullscreen mode Exit fullscreen mode

rather than:

StripePaymentGateway
Enter fullscreen mode Exit fullscreen mode

This is where Angular DI becomes an architectural tool rather than simply a convenience.


3. What Is a Dependency?

A dependency is anything a class needs to perform its job.

It doesn't have to be a service.

A dependency can be:

class
Enter fullscreen mode Exit fullscreen mode

or:

object
Enter fullscreen mode Exit fullscreen mode

or:

string
Enter fullscreen mode Exit fullscreen mode

or:

number
Enter fullscreen mode Exit fullscreen mode

or:

function
Enter fullscreen mode Exit fullscreen mode

or:

configuration
Enter fullscreen mode Exit fullscreen mode

For example:

class UserService {

  constructor(
    private http: HttpClient,
    private config: AppConfig
  ) {}

}
Enter fullscreen mode Exit fullscreen mode

Both are dependencies.

Angular's DI system can provide services, values, functions, configuration objects, and other JavaScript values.


4. The Three Important DI Concepts

Most Angular DI problems become much easier once you understand these three terms:

Token
   ↓
Provider
   ↓
Injector
Enter fullscreen mode Exit fullscreen mode

For example:

providers: [
  {
    provide: UserService,
    useClass: UserService
  }
]
Enter fullscreen mode Exit fullscreen mode

Here:

Token

UserService
Enter fullscreen mode Exit fullscreen mode

The token identifies what we want.

Provider

{
  provide: UserService,
  useClass: UserService
}
Enter fullscreen mode Exit fullscreen mode

The provider tells Angular how to create or obtain it.

Injector

The injector stores provider information and resolves dependencies when requested.

Conceptually:

Component
    |
    | "Give me UserService"
    ↓
Injector
    |
    | finds provider
    ↓
Provider
    |
    | creates/returns instance
    ↓
UserService
Enter fullscreen mode Exit fullscreen mode

5. The Old Angular DI Model

Before modern Angular patterns became common, you typically saw:

@Injectable({
  providedIn: 'root'
})
export class UserService {}
Enter fullscreen mode Exit fullscreen mode

and:

@Component({
  selector: 'app-user',
  template: `...`
})
export class UserComponent {

  constructor(
    private userService: UserService
  ) {}

}
Enter fullscreen mode Exit fullscreen mode

This is still valid.

It is not an obsolete API.

The major change is that Angular now gives developers another, often cleaner, way to inject dependencies.


6. Constructor Injection

The classic Angular DI syntax is constructor injection:

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

  getUser() {
    return {
      id: 1,
      name: 'Abanoub'
    };
  }

}
Enter fullscreen mode Exit fullscreen mode

Then:

@Component({
  selector: 'app-user',
  template: `
    <h1>{{ user.name }}</h1>
  `
})
export class UserComponent {

  constructor(
    private userService: UserService
  ) {}

  user = this.userService.getUser();

}
Enter fullscreen mode Exit fullscreen mode

Angular sees:

UserService
Enter fullscreen mode Exit fullscreen mode

and asks the DI system to resolve it.

Why constructor injection was popular

It has several advantages:

  • explicit dependencies
  • easy to understand
  • works naturally with TypeScript
  • dependencies are available when the class is created
  • excellent for traditional Angular codebases

But it also has limitations.

For example:

constructor(
  private http: HttpClient,
  private auth: AuthService,
  private router: Router,
  private logger: LoggerService,
  private config: ConfigService
) {}
Enter fullscreen mode Exit fullscreen mode

Large constructors can become noisy.

There are also limitations around TypeScript's decorator metadata and modern standard decorators.

Angular therefore introduced the inject() API as a modern alternative. Angular's migration tooling can convert constructor-based injection to inject().


7. Modern Angular DI: inject()

Today you will frequently see:

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

@Component({
  selector: 'app-user',
  template: `...`
})
export class UserComponent {

  private userService = inject(UserService);

}
Enter fullscreen mode Exit fullscreen mode

This is dependency injection too.

Angular resolves:

UserService
Enter fullscreen mode Exit fullscreen mode

from the currently active injector.

The important thing is:

inject(UserService)
Enter fullscreen mode Exit fullscreen mode

does not mean:

new UserService()
Enter fullscreen mode Exit fullscreen mode

Angular still controls the dependency lifecycle and provider resolution.

The inject() function is supported in injection contexts such as constructors, field initializers, provider factories, and other framework-managed DI contexts.


8. Constructor Injection vs inject()

Old style

export class DashboardComponent {

  constructor(
    private userService: UserService,
    private router: Router
  ) {}

}
Enter fullscreen mode Exit fullscreen mode

Modern style

export class DashboardComponent {

  private userService = inject(UserService);
  private router = inject(Router);

}
Enter fullscreen mode Exit fullscreen mode

Both are valid.

A practical modern Angular style is:

private userService = inject(UserService);
private router = inject(Router);
Enter fullscreen mode Exit fullscreen mode

because dependencies are declared close to where they are used and TypeScript can infer types naturally.

However, constructor injection remains perfectly legitimate, especially in existing applications and when working with patterns that depend on constructor parameters. Angular's own guidance explicitly supports both approaches.


9. The Injection Context

This is one of the most important modern Angular DI concepts.

You cannot call:

inject(UserService)
Enter fullscreen mode Exit fullscreen mode

anywhere.

This is invalid:

export class UserComponent {

  ngOnInit() {

    const service = inject(UserService); // ❌

  }

}
Enter fullscreen mode Exit fullscreen mode

Why?

Because ngOnInit() executes after the object has already been created.

Angular's DI context is available during class creation/initialization and specific framework-managed contexts, not arbitrary later callbacks.


10. Valid Places for inject()

Field initializer

export class UserComponent {

  private userService = inject(UserService);

}
Enter fullscreen mode Exit fullscreen mode

Constructor

export class UserComponent {

  constructor() {

    const service = inject(UserService);

  }

}
Enter fullscreen mode Exit fullscreen mode

Provider factory

{
  provide: UserService,
  useFactory: () => {

    const logger = inject(LoggerService);

    return new UserService(logger);

  }
}
Enter fullscreen mode Exit fullscreen mode

InjectionToken factory

const API_URL = new InjectionToken<string>(
  'API_URL',
  {
    providedIn: 'root',

    factory: () => {

      const config = inject(AppConfig);

      return config.apiUrl;

    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Functional route guard

export const authGuard = () => {

  const auth = inject(AuthService);

  return auth.isAuthenticated();

};
Enter fullscreen mode Exit fullscreen mode

Angular documents functional guards and similar framework APIs as injection contexts.


11. Why inject() Fails in Async Code

This is a common mistake:

async loadUser() {

  await delay(1000);

  const userService = inject(UserService); // ❌

}
Enter fullscreen mode Exit fullscreen mode

After the await, you are no longer inside the original synchronous injection context.

Instead:

private userService = inject(UserService);

async loadUser() {

  await delay(1000);

  this.userService.getUser();

}
Enter fullscreen mode Exit fullscreen mode

Capture dependencies while you are inside the injection context.


12. runInInjectionContext()

Sometimes you genuinely need to execute a function inside an injection context.

Angular provides:

runInInjectionContext()
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

  private injector = inject(EnvironmentInjector);

  execute() {

    runInInjectionContext(
      this.injector,
      () => {

        const logger = inject(LoggerService);

        logger.log('Hello');

      }
    );

  }

}
Enter fullscreen mode Exit fullscreen mode

This creates a temporary injection context for the callback.

Use this when you actually need dynamic DI.

Do not use it as a replacement for normal injection.


13. Injector

Angular also exposes the injector itself.

private injector = inject(Injector);
Enter fullscreen mode Exit fullscreen mode

You can then resolve dependencies manually:

const service = this.injector.get(UserService);
Enter fullscreen mode Exit fullscreen mode

This can be useful when the dependency needs to be resolved later.

For example:

private injector = inject(Injector);

loadLater() {

  setTimeout(() => {

    const userService =
      this.injector.get(UserService);

    userService.getUser();

  }, 1000);

}
Enter fullscreen mode Exit fullscreen mode

Angular's debugging guidance recommends capturing dependencies normally where possible and using Injector.get() for deferred retrieval when necessary.


14. @Injectable()

The classic way to declare an injectable service is:

@Injectable()
export class UserService {}
Enter fullscreen mode Exit fullscreen mode

However, this alone doesn't necessarily make the service globally available.

You need a provider.

For example:

@Component({
  providers: [
    UserService
  ]
})
export class UserComponent {}
Enter fullscreen mode Exit fullscreen mode

Or:

bootstrapApplication(AppComponent, {
  providers: [
    UserService
  ]
});
Enter fullscreen mode Exit fullscreen mode

15. providedIn

The most common modern approach is:

@Injectable({
  providedIn: 'root'
})
export class UserService {}
Enter fullscreen mode Exit fullscreen mode

This tells Angular that the service belongs to the root injector.

It is also tree-shakable.

That means Angular's build system can remove unused injectable definitions from the final bundle when they are not referenced.

For most application-wide services:

@Injectable({
  providedIn: 'root'
})
Enter fullscreen mode Exit fullscreen mode

is the default choice.


16. providedIn: 'root'

@Injectable({
  providedIn: 'root'
})
export class AuthService {}
Enter fullscreen mode Exit fullscreen mode

This normally gives the application a single instance associated with the root environment injector.

Every component requesting:

inject(AuthService)
Enter fullscreen mode Exit fullscreen mode

will resolve the same root-scoped instance unless a closer provider overrides it.


17. providedIn: 'platform'

Historically Angular also supported:

@Injectable({
  providedIn: 'platform'
})
Enter fullscreen mode Exit fullscreen mode

This places the service in the platform-level injector rather than the application root.

It is useful for special scenarios involving multiple Angular applications sharing a platform.

It is not something you should use by default.


18. The Historical providedIn: 'any'

Older Angular versions supported:

providedIn: 'any'
Enter fullscreen mode Exit fullscreen mode

This had special behavior with lazy-loaded injectors.

Modern Angular documentation marks 'any' as deprecated.

So if you see:

providedIn: 'any'
Enter fullscreen mode Exit fullscreen mode

in older code, understand what it does before changing it, but don't treat it as the preferred modern pattern.


19. providedIn: NgModule

You may also encounter:

@Injectable({
  providedIn: SomeModule
})
Enter fullscreen mode Exit fullscreen mode

This belongs to the older NgModule-oriented DI model.

The modern Angular architecture favors environment providers and standalone APIs.

Angular's current documentation marks providedIn: NgModule as deprecated for InjectionToken configuration, and modern applications generally prefer environment-based provider configuration.


20. Manual Providers

Instead of:

@Injectable({
  providedIn: 'root'
})
export class UserService {}
Enter fullscreen mode Exit fullscreen mode

you can manually provide:

providers: [
  UserService
]
Enter fullscreen mode Exit fullscreen mode

For example:

@Component({
  selector: 'app-profile',
  providers: [
    UserService
  ],
  template: `...`
})
export class ProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

Now the service belongs to this component's injector.


21. Component-Level Providers

This is extremely important.

Consider:

@Component({
  providers: [
    CounterService
  ]
})
export class CounterComponent {

  private counter = inject(CounterService);

}
Enter fullscreen mode Exit fullscreen mode

Every instance of CounterComponent gets its own CounterService.

For example:

<app-counter></app-counter>
<app-counter></app-counter>
Enter fullscreen mode Exit fullscreen mode

Conceptually:

CounterComponent #1
   |
   └── CounterService #1

CounterComponent #2
   |
   └── CounterService #2
Enter fullscreen mode Exit fullscreen mode

This is completely different from:

@Injectable({
  providedIn: 'root'
})
Enter fullscreen mode Exit fullscreen mode

where both components normally resolve the same root instance.

Angular's component providers configure the component's ElementInjector, and that instance is destroyed with the component.


22. The Provider Shorthand

This:

providers: [
  UserService
]
Enter fullscreen mode Exit fullscreen mode

is effectively a shorthand for:

providers: [
  {
    provide: UserService,
    useClass: UserService
  }
]
Enter fullscreen mode Exit fullscreen mode

This leads us to one of the most important DI concepts:

Provider Recipes

Angular supports several provider strategies.


23. useClass

providers: [
  {
    provide: LoggerService,
    useClass: ConsoleLoggerService
  }
]
Enter fullscreen mode Exit fullscreen mode

Whenever something asks for:

LoggerService
Enter fullscreen mode Exit fullscreen mode

Angular creates:

ConsoleLoggerService
Enter fullscreen mode Exit fullscreen mode

This is extremely useful for abstraction.

For example:

abstract class PaymentGateway {

  abstract pay(amount: number): Promise<void>;

}
Enter fullscreen mode Exit fullscreen mode

Production:

providers: [
  {
    provide: PaymentGateway,
    useClass: StripePaymentGateway
  }
]
Enter fullscreen mode Exit fullscreen mode

Testing:

providers: [
  {
    provide: PaymentGateway,
    useClass: FakePaymentGateway
  }
]
Enter fullscreen mode Exit fullscreen mode

The consumer doesn't change.


24. useValue

Sometimes you don't need Angular to create an object.

You already have the value.

Use:

useValue
Enter fullscreen mode Exit fullscreen mode

Example:

export const API_URL =
  new InjectionToken<string>('API_URL');

bootstrapApplication(AppComponent, {

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

});
Enter fullscreen mode Exit fullscreen mode

Then:

export class ApiService {

  private apiUrl = inject(API_URL);

}
Enter fullscreen mode Exit fullscreen mode

useValue can provide strings, numbers, objects, arrays, functions, configuration, and other static values.


25. Why You Need InjectionToken

This won't work:

export interface AppConfig {
  apiUrl: string;
}
Enter fullscreen mode Exit fullscreen mode

and:

constructor(private config: AppConfig) {}
Enter fullscreen mode Exit fullscreen mode

Why?

Because interfaces disappear during TypeScript compilation.

At runtime:

AppConfig
Enter fullscreen mode Exit fullscreen mode

doesn't exist.

Angular needs a runtime token.

That's what InjectionToken provides.


26. InjectionToken

export interface AppConfig {

  apiUrl: string;
  production: boolean;

}

export const APP_CONFIG =
  new InjectionToken<AppConfig>('APP_CONFIG');
Enter fullscreen mode Exit fullscreen mode

Provide:

providers: [
  {
    provide: APP_CONFIG,

    useValue: {
      apiUrl: 'https://api.example.com',
      production: false
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

Inject:

private config = inject(APP_CONFIG);
Enter fullscreen mode Exit fullscreen mode

Now:

this.config.apiUrl
Enter fullscreen mode Exit fullscreen mode

is strongly typed.

Angular recommends InjectionToken for dependencies that do not have a runtime representation, such as interfaces, functions, arrays, and parameterized types.


27. InjectionToken Identity Matters

This is a subtle but important bug.

Don't do this:

const API_URL =
  new InjectionToken<string>('API_URL');
Enter fullscreen mode Exit fullscreen mode

in one file and:

const API_URL =
  new InjectionToken<string>('API_URL');
Enter fullscreen mode Exit fullscreen mode

in another.

Even though the names are identical, these are two different objects.

Angular compares token identity, not the description string.

Always export one token:

export const API_URL =
  new InjectionToken<string>('API_URL');
Enter fullscreen mode Exit fullscreen mode

and import that same token everywhere.


28. useFactory

Factories are useful when the dependency must be dynamically constructed.

Example:

export const API_CLIENT_PROVIDER = {

  provide: ApiClient,

  useFactory: () => {

    const http = inject(HttpClient);
    const config = inject(APP_CONFIG);

    return new ApiClient(
      http,
      config.apiUrl
    );

  }

};
Enter fullscreen mode Exit fullscreen mode

Now Angular executes the factory when it needs the dependency.

Factories are excellent for:

  • runtime configuration
  • environment-dependent implementations
  • complex initialization
  • combining multiple dependencies
  • library configuration

Angular supports both useFactory providers and factory-based injection tokens.


29. Factory Dependencies with deps

The older explicit style is:

{
  provide: ApiClient,

  useFactory: (
    http: HttpClient,
    config: AppConfig
  ) => {

    return new ApiClient(
      http,
      config.apiUrl
    );

  },

  deps: [
    HttpClient,
    APP_CONFIG
  ]
}
Enter fullscreen mode Exit fullscreen mode

Modern Angular code can often use inject() directly inside the factory:

{
  provide: ApiClient,

  useFactory: () => {

    const http = inject(HttpClient);
    const config = inject(APP_CONFIG);

    return new ApiClient(
      http,
      config.apiUrl
    );

  }
}
Enter fullscreen mode Exit fullscreen mode

30. useExisting

This is one of the most misunderstood provider types.

Suppose:

class NewLogger {}

class OldLogger {}
Enter fullscreen mode Exit fullscreen mode

You can alias:

providers: [
  NewLogger,

  {
    provide: OldLogger,
    useExisting: NewLogger
  }
]
Enter fullscreen mode Exit fullscreen mode

Now:

inject(NewLogger)
Enter fullscreen mode Exit fullscreen mode

and:

inject(OldLogger)
Enter fullscreen mode Exit fullscreen mode

return the same instance.


31. useExisting vs useClass

This distinction matters.

useClass

{
  provide: Logger,
  useClass: ConsoleLogger
}
Enter fullscreen mode Exit fullscreen mode

Angular creates an instance of:

ConsoleLogger
Enter fullscreen mode Exit fullscreen mode

useExisting

{
  provide: LegacyLogger,
  useExisting: ConsoleLogger
}
Enter fullscreen mode Exit fullscreen mode

Angular returns the already-existing ConsoleLogger instance.

Conceptually:

useClass

Logger ───────> ConsoleLogger #1


useExisting

Logger ───────┐
              ├──> ConsoleLogger #1
LegacyLogger ─┘
Enter fullscreen mode Exit fullscreen mode

Angular's documentation explicitly distinguishes these behaviors.


32. multi: true

Sometimes you don't want one implementation.

You want many.

For example:

export const LOG_HANDLERS =
  new InjectionToken<LogHandler[]>(
    'LOG_HANDLERS'
  );
Enter fullscreen mode Exit fullscreen mode

Then:

providers: [

  {
    provide: LOG_HANDLERS,
    useClass: ConsoleLogHandler,
    multi: true
  },

  {
    provide: LOG_HANDLERS,
    useClass: RemoteLogHandler,
    multi: true
  },

  {
    provide: LOG_HANDLERS,
    useClass: AnalyticsLogHandler,
    multi: true
  }

]
Enter fullscreen mode Exit fullscreen mode

Now:

private handlers = inject(LOG_HANDLERS);
Enter fullscreen mode Exit fullscreen mode

returns an array.

LOG_HANDLERS
     |
     ├── ConsoleLogHandler
     ├── RemoteLogHandler
     └── AnalyticsLogHandler
Enter fullscreen mode Exit fullscreen mode

This pattern is heavily used in extensible Angular architecture.


33. providers vs viewProviders

These look similar:

providers
Enter fullscreen mode Exit fullscreen mode

and:

viewProviders
Enter fullscreen mode Exit fullscreen mode

but they have an important difference.

Consider:

<app-parent>

  <app-child />

</app-parent>
Enter fullscreen mode Exit fullscreen mode

and:

<app-parent>

  <ng-content />

</app-parent>
Enter fullscreen mode Exit fullscreen mode

providers can be visible to projected content.

viewProviders restricts the provider to the component's own view.

Example:

@Component({
  providers: [
    ThemeService
  ]
})
Enter fullscreen mode Exit fullscreen mode

The service can be available to projected content.

But:

@Component({
  viewProviders: [
    ThemeService
  ]
})
Enter fullscreen mode Exit fullscreen mode

keeps it within the component's own view.

Angular recommends providers by default unless you specifically need the isolation behavior of viewProviders.


34. Angular's Injector Hierarchy

This is where Angular DI becomes really powerful.

Modern Angular has two major injector hierarchies:

EnvironmentInjector hierarchy

        Root
         |
    Environment
         |
    Feature / Route
Enter fullscreen mode Exit fullscreen mode

and:

ElementInjector hierarchy

Component
   |
Child Component
   |
Grandchild Component
Enter fullscreen mode Exit fullscreen mode

Angular documents these as the EnvironmentInjector and ElementInjector hierarchies.


35. EnvironmentInjector

The EnvironmentInjector is used for application/environment-level providers.

For example:

bootstrapApplication(AppComponent, {
  providers: [
    UserService
  ]
});
Enter fullscreen mode Exit fullscreen mode

Or:

@Injectable({
  providedIn: 'root'
})
export class AuthService {}
Enter fullscreen mode Exit fullscreen mode

These belong to the environment hierarchy.

This is the modern standalone Angular replacement for many things developers historically configured through NgModule.providers.


36. ElementInjector

Angular also creates an injector associated with elements/components.

For example:

@Component({
  providers: [
    CounterService
  ]
})
export class CounterComponent {}
Enter fullscreen mode Exit fullscreen mode

This configures the component's ElementInjector.

Angular creates these element-level injectors implicitly, and they are empty unless providers are configured on components or directives.


37. How Angular Resolves a Dependency

Suppose:

@Component({
  providers: [
    UserService
  ]
})
export class ChildComponent {

  private user = inject(UserService);

}
Enter fullscreen mode Exit fullscreen mode

Angular roughly searches:

Child ElementInjector
        ↓
Parent ElementInjector
        ↓
Ancestor ElementInjectors
        ↓
EnvironmentInjector hierarchy
        ↓
Root
Enter fullscreen mode Exit fullscreen mode

The important rule is:

Angular uses the closest matching provider.

If a child defines its own provider, it can override the parent.

Angular's documented resolution process first walks the relevant element injector hierarchy and then falls back to the environment hierarchy.


38. Provider Override

Imagine:

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

  name = 'Global Logger';

}
Enter fullscreen mode Exit fullscreen mode

Then:

@Component({
  providers: [
    {
      provide: LoggerService,
      useValue: {
        name: 'Local Logger'
      }
    }
  ]
})
export class FeatureComponent {}
Enter fullscreen mode Exit fullscreen mode

Inside:

FeatureComponent
Enter fullscreen mode Exit fullscreen mode

you get:

Local Logger
Enter fullscreen mode Exit fullscreen mode

while another component outside that subtree still gets:

Global Logger
Enter fullscreen mode Exit fullscreen mode

This is dependency scoping.


39. Self

Sometimes you want Angular to search only the current injector.

With inject():

inject(UserService, {
  self: true
});
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Current Injector
      |
      X
Parent
      |
      X
Root
Enter fullscreen mode Exit fullscreen mode

If the dependency isn't available locally, Angular fails.

This is useful when you explicitly require a local provider.


40. SkipSelf

skipSelf does the opposite.

inject(UserService, {
  skipSelf: true
});
Enter fullscreen mode Exit fullscreen mode

Angular skips the current injector and starts searching from the parent.

Conceptually:

Current Injector  ← skipped

Parent Injector   ← start here
       ↓
Grandparent
       ↓
Root
Enter fullscreen mode Exit fullscreen mode

This is useful when a child provider overrides a parent provider but you specifically need the parent instance.


41. Optional

Sometimes a dependency is optional.

Instead of throwing an error:

inject(AnalyticsService);
Enter fullscreen mode Exit fullscreen mode

use:

inject(AnalyticsService, {
  optional: true
});
Enter fullscreen mode Exit fullscreen mode

Now Angular can return:

null
Enter fullscreen mode Exit fullscreen mode

if the provider doesn't exist.

Example:

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

track() {

  this.analytics?.track('click');

}
Enter fullscreen mode Exit fullscreen mode

42. Host

host limits the DI search according to the host boundary.

inject(UserService, {
  host: true
});
Enter fullscreen mode Exit fullscreen mode

This becomes particularly important with:

  • component boundaries
  • directives
  • viewProviders
  • content projection

It is an advanced DI modifier and should be used when you actually need control over the resolution boundary. Angular's hierarchical DI documentation provides detailed examples of how host, skipSelf, and viewProviders interact.


43. Combining DI Modifiers

You can combine options.

For example:

inject(UserService, {
  skipSelf: true,
  optional: true
});
Enter fullscreen mode Exit fullscreen mode

Meaning roughly:

"Don't use the current injector. Search the parents, but don't throw if nothing is found."

You can also use:

inject(UserService, {
  host: true,
  skipSelf: true,
  optional: true
});
Enter fullscreen mode Exit fullscreen mode

These combinations become useful in advanced component/library architectures.


44. The Old Decorator Modifiers

In constructor injection, you may see:

constructor(
  @Optional()
  private analytics: AnalyticsService
) {}
Enter fullscreen mode Exit fullscreen mode

or:

constructor(
  @Self()
  private service: LocalService
) {}
Enter fullscreen mode Exit fullscreen mode

or:

constructor(
  @SkipSelf()
  private service: ParentService
) {}
Enter fullscreen mode Exit fullscreen mode

or:

constructor(
  @Host()
  private service: HostService
) {}
Enter fullscreen mode Exit fullscreen mode

You may also see:

@Inject(TOKEN)
Enter fullscreen mode Exit fullscreen mode

These are older constructor-oriented APIs.

Modern inject() expresses the same concepts through options:

inject(TOKEN, {
  optional: true
});
Enter fullscreen mode Exit fullscreen mode

45. @Inject()

Suppose the dependency isn't represented by a class:

export const API_URL =
  new InjectionToken<string>('API_URL');
Enter fullscreen mode Exit fullscreen mode

Old style:

constructor(
  @Inject(API_URL)
  private apiUrl: string
) {}
Enter fullscreen mode Exit fullscreen mode

Modern:

private apiUrl = inject(API_URL);
Enter fullscreen mode Exit fullscreen mode

The modern syntax is usually much cleaner.


46. forwardRef()

Sometimes Angular needs to reference something before the TypeScript runtime declaration is available.

Example:

@Component({
  providers: [
    {
      provide: ParentComponent,
      useExisting: forwardRef(
        () => ChildComponent
      )
    }
  ]
})
export class ChildComponent {}
Enter fullscreen mode Exit fullscreen mode

forwardRef() allows Angular to resolve a reference later.

It is mostly useful for circular references and unusual declaration-order scenarios.

Angular documents forwardRef() specifically for resolving references that cannot yet be directly referenced because of declaration order or circular relationships.


47. NgModule-Based DI

Older Angular applications commonly looked like:

@NgModule({
  declarations: [
    AppComponent
  ],

  imports: [
    BrowserModule
  ],

  providers: [
    UserService
  ],

  bootstrap: [
    AppComponent
  ]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

The application started with:

platformBrowserDynamic()
  .bootstrapModule(AppModule);
Enter fullscreen mode Exit fullscreen mode

This architecture is still supported.

But modern Angular applications increasingly use:

bootstrapApplication()
Enter fullscreen mode Exit fullscreen mode

with standalone components and environment providers.


48. Modern Standalone DI

Modern Angular:

bootstrapApplication(
  AppComponent,
  {
    providers: [
      UserService
    ]
  }
);
Enter fullscreen mode Exit fullscreen mode

You can also use Angular's provider functions:

bootstrapApplication(
  AppComponent,
  {
    providers: [
      provideRouter(routes),
      provideHttpClient()
    ]
  }
);
Enter fullscreen mode Exit fullscreen mode

This is one of the biggest architectural changes from the older NgModule era.


49. Provider Functions

Modern Angular libraries frequently expose APIs such as:

provideRouter(...)
Enter fullscreen mode Exit fullscreen mode

and:

provideHttpClient(...)
Enter fullscreen mode Exit fullscreen mode

Instead of asking developers to manually understand dozens of providers, the library exposes a single configuration function.

You can build the same pattern yourself.

Example:

export interface AnalyticsConfig {
  trackingId: string;
}

const ANALYTICS_CONFIG =
  new InjectionToken<AnalyticsConfig>(
    'ANALYTICS_CONFIG'
  );

export function provideAnalytics(
  config: AnalyticsConfig
) {

  return [
    {
      provide: ANALYTICS_CONFIG,
      useValue: config
    },

    AnalyticsService
  ];

}
Enter fullscreen mode Exit fullscreen mode

Consumer:

bootstrapApplication(AppComponent, {

  providers: [
    provideAnalytics({
      trackingId: 'GA-123'
    })
  ]

});
Enter fullscreen mode Exit fullscreen mode

This provideX() pattern is especially useful for Angular libraries because it encapsulates implementation details and makes configuration composable.


50. Feature Functions: withX()

Modern Angular libraries also use patterns such as:

provideSomething(
  withCaching(),
  withLogging(),
  withRetry()
)
Enter fullscreen mode Exit fullscreen mode

You can implement the same architecture.

Example:

export function withLogging(): AnalyticsFeature {

  return {
    providers: [
      LoggingAnalyticsHandler
    ]
  };

}
Enter fullscreen mode Exit fullscreen mode

Then:

provideAnalytics(
  config,
  withLogging()
);
Enter fullscreen mode Exit fullscreen mode

This gives libraries a clean, composable API.


51. InjectionToken with a Factory

An InjectionToken can provide its own factory.

Example:

export const API_URL =
  new InjectionToken<string>(
    'API_URL',
    {
      providedIn: 'root',

      factory: () => {
        return 'https://api.example.com';
      }
    }
  );
Enter fullscreen mode Exit fullscreen mode

Now you don't need:

providers: [
  {
    provide: API_URL,
    useValue: ...
  }
]
Enter fullscreen mode Exit fullscreen mode

The token knows how to create itself.

This is another tree-shakable DI pattern.


52. DI and Testing

One of the biggest reasons DI exists is testability.

Without DI:

class UserService {

  private api =
    new RealApiClient();

}
Enter fullscreen mode Exit fullscreen mode

Testing becomes difficult.

With DI:

class UserService {

  private api =
    inject(ApiClient);

}
Enter fullscreen mode Exit fullscreen mode

Test:

TestBed.configureTestingModule({

  providers: [

    UserService,

    {
      provide: ApiClient,

      useClass: FakeApiClient
    }

  ]

});
Enter fullscreen mode Exit fullscreen mode

Now the real API doesn't need to be called.

This is Dependency Injection's architectural value.


53. DI and Mocking

Suppose:

export abstract class PaymentGateway {

  abstract pay(
    amount: number
  ): Promise<boolean>;

}
Enter fullscreen mode Exit fullscreen mode

Production:

{
  provide: PaymentGateway,
  useClass: StripePaymentGateway
}
Enter fullscreen mode Exit fullscreen mode

Testing:

{
  provide: PaymentGateway,
  useClass: FakePaymentGateway
}
Enter fullscreen mode Exit fullscreen mode

Your business logic doesn't know which implementation it received.

That's dependency inversion + DI.


54. DI and SOLID

Angular DI strongly supports SOLID principles.

Single Responsibility

A component doesn't need to create its dependencies.

Open/Closed

You can replace implementations without modifying consumers.

Liskov Substitution

Abstractions can be backed by interchangeable implementations.

Interface Segregation

Smaller tokens/interfaces can define focused contracts.

Dependency Inversion

High-level services can depend on abstractions.

DI doesn't automatically make an architecture SOLID, but it provides an excellent mechanism for implementing these principles.


55. DI Is Not a Global Singleton System

This is a common misconception.

People often say:

"Angular services are singletons."

That's not always true.

Consider:

@Injectable({
  providedIn: 'root'
})
export class CartService {}
Enter fullscreen mode Exit fullscreen mode

This is normally application-wide.

But:

@Component({
  providers: [
    CartService
  ]
})
export class CheckoutComponent {}
Enter fullscreen mode Exit fullscreen mode

creates a different scoped instance.

You can therefore have:

Root CartService
        |
        ├── Component A → root instance
        |
        └── Checkout → local instance
Enter fullscreen mode Exit fullscreen mode

Angular DI is fundamentally about scope and resolution, not simply singletons.


56. Service Lifetime

Where you provide a dependency influences its lifetime.

Root provider

providedIn: 'root'
Enter fullscreen mode Exit fullscreen mode

Usually lives for the application lifetime.

Component provider

@Component({
  providers: [CartService]
})
Enter fullscreen mode Exit fullscreen mode

Lives with that component's injector.

When the component is destroyed, its scoped service can be destroyed as well.

This makes component-level DI extremely useful for isolated state.


57. A Perfect Use Case: Local Component State

Imagine:

@Injectable()
export class WizardState {

  currentStep = 1;

  next() {
    this.currentStep++;
  }

}
Enter fullscreen mode Exit fullscreen mode

Then:

@Component({
  selector: 'app-checkout-wizard',

  providers: [
    WizardState
  ],

  template: `...`
})
export class CheckoutWizard {

  state = inject(WizardState);

}
Enter fullscreen mode Exit fullscreen mode

Now every checkout wizard gets independent state.

You don't need:

WizardState #1
WizardState #2
WizardState #3
Enter fullscreen mode Exit fullscreen mode

manually.

Angular's injector gives you the correct scoped instance.


58. Route-Level Providers

Modern Angular also allows providers to be associated with routes.

Conceptually:

export const routes: Routes = [

  {
    path: 'admin',

    providers: [
      AdminService
    ],

    loadComponent: () =>
      import('./admin.component')
        .then(m => m.AdminComponent)
  }

];
Enter fullscreen mode Exit fullscreen mode

This is powerful because you can scope dependencies to a feature.

For example:

Application
   |
   ├── Public area
   |
   └── Admin route
          |
          └── AdminService
Enter fullscreen mode Exit fullscreen mode

The admin dependency doesn't have to become globally available.


59. DI in Functional Guards

Older Angular:

@Injectable()
export class AuthGuard
  implements CanActivate {

  constructor(
    private auth: AuthService
  ) {}

  canActivate() {
    return this.auth.isAuthenticated();
  }

}
Enter fullscreen mode Exit fullscreen mode

Modern Angular can use:

export const authGuard: CanActivateFn = () => {

  const auth =
    inject(AuthService);

  return auth.isAuthenticated();

};
Enter fullscreen mode Exit fullscreen mode

This is a great example of how inject() enables functional Angular APIs.


60. DI in Functional Resolvers

The same principle applies to resolvers:

export const userResolver: ResolveFn<User> =
  () => {

    const userService =
      inject(UserService);

    return userService.getCurrentUser();

  };
Enter fullscreen mode Exit fullscreen mode

No class is required.

Angular supplies the injection context.


61. DI and Directives

DI isn't only for components.

@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective {

  private element =
    inject(ElementRef);

}
Enter fullscreen mode Exit fullscreen mode

Angular can inject framework-provided tokens such as:

ElementRef
Enter fullscreen mode Exit fullscreen mode

and application services.

Angular documents DI as available to directives and components because they participate in the Angular DI system.


62. DI and Pipes

Injectable dependencies can also be used in pipes.

For example:

@Pipe({
  name: 'currencyFormat'
})
export class CurrencyFormatPipe {

  private config =
    inject(AppConfig);

  transform(value: number) {

    return `${this.config.currency}${value}`;

  }

}
Enter fullscreen mode Exit fullscreen mode

63. DI and Interceptors

Modern functional interceptors are another excellent example:

export const authInterceptor:
  HttpInterceptorFn = (req, next) => {

    const auth =
      inject(AuthService);

    const token =
      auth.getToken();

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

  };
Enter fullscreen mode Exit fullscreen mode

The function itself doesn't receive AuthService as a parameter.

Angular creates the injection context in which the interceptor runs.


64. DI and Environment Configuration

A professional Angular application often needs:

API_URL
Enter fullscreen mode Exit fullscreen mode
APP_NAME
Enter fullscreen mode Exit fullscreen mode
FEATURE_FLAGS
Enter fullscreen mode Exit fullscreen mode
DEFAULT_PAGE_SIZE
Enter fullscreen mode Exit fullscreen mode

Instead of hardcoding these everywhere, define tokens.

export interface AppConfig {

  apiUrl: string;

  appName: string;

  features: {
    analytics: boolean;
    darkMode: boolean;
  };

}
Enter fullscreen mode Exit fullscreen mode

Then:

export const APP_CONFIG =
  new InjectionToken<AppConfig>(
    'APP_CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode

Provide it once:

bootstrapApplication(AppComponent, {

  providers: [

    {
      provide: APP_CONFIG,

      useValue: {
        apiUrl: 'https://api.example.com',
        appName: 'Task Manager',

        features: {
          analytics: true,
          darkMode: true
        }
      }

    }

  ]

});
Enter fullscreen mode Exit fullscreen mode

Now every service can consume strongly typed configuration.


65. Complete Project: Task Management Application

Let's build a realistic Angular architecture that demonstrates DI from beginning to end.

Our application will contain:

Task Management App

├── Configuration
├── Authentication
├── Logging
├── API Client
├── Task Repository
├── Task Service
├── Feature-specific providers
├── Payment abstraction
└── Testing implementation
Enter fullscreen mode Exit fullscreen mode

66. Project Structure

src/
└── app/

    ├── core/
    │   ├── config/
    │   │   └── app-config.ts
    │   │
    │   ├── logging/
    │   │   ├── logger.ts
    │   │   └── console-logger.ts
    │   │
    │   ├── auth/
    │   │   └── auth.service.ts
    │   │
    │   └── api/
    │       └── api-client.ts
    │
    ├── tasks/
    │   ├── task.model.ts
    │   ├── task.repository.ts
    │   ├── task.service.ts
    │   └── task-list.component.ts
    │
    ├── app.component.ts
    ├── app.config.ts
    └── main.ts
Enter fullscreen mode Exit fullscreen mode

67. Application Configuration

Create:

// core/config/app-config.ts

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

export interface AppConfig {

  apiUrl: string;

  appName: string;

  features: {
    analytics: boolean;
  };

}

export const APP_CONFIG =
  new InjectionToken<AppConfig>(
    'APP_CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode

68. Provide Configuration

// app.config.ts

import {
  ApplicationConfig
} from '@angular/core';

import {
  APP_CONFIG
} from './core/config/app-config';

export const appConfig:
  ApplicationConfig = {

  providers: [

    {
      provide: APP_CONFIG,

      useValue: {
        apiUrl: 'https://api.example.com',

        appName: 'Task Manager',

        features: {
          analytics: true
        }
      }
    }

  ]

};
Enter fullscreen mode Exit fullscreen mode

69. Logger Abstraction

Create:

// core/logging/logger.ts

export abstract class Logger {

  abstract info(
    message: string
  ): void;

  abstract error(
    message: string
  ): void;

}
Enter fullscreen mode Exit fullscreen mode

Now the application depends on:

Logger
Enter fullscreen mode Exit fullscreen mode

not:

ConsoleLogger
Enter fullscreen mode Exit fullscreen mode

70. Logger Implementation

// core/logging/console-logger.ts

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

import {
  Logger
} from './logger';

@Injectable()
export class ConsoleLogger
  implements Logger {

  info(message: string) {

    console.log(
      `[INFO] ${message}`
    );

  }

  error(message: string) {

    console.error(
      `[ERROR] ${message}`
    );

  }

}
Enter fullscreen mode Exit fullscreen mode

71. Connect the Abstraction

In app.config.ts:

providers: [

  {
    provide: Logger,
    useClass: ConsoleLogger
  }

]
Enter fullscreen mode Exit fullscreen mode

Now:

inject(Logger)
Enter fullscreen mode Exit fullscreen mode

returns:

ConsoleLogger
Enter fullscreen mode Exit fullscreen mode

The consumer doesn't care about the implementation.


72. Authentication Service

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

  private token =
    'fake-jwt-token';

  isAuthenticated(): boolean {

    return !!this.token;

  }

  getToken(): string {

    return this.token;

  }

}
Enter fullscreen mode Exit fullscreen mode

Because it is root-provided, the application can share the service.


73. API Client

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

  private http =
    inject(HttpClient);

  private config =
    inject(APP_CONFIG);

  get<T>(path: string) {

    return this.http.get<T>(
      `${this.config.apiUrl}${path}`
    );

  }

}
Enter fullscreen mode Exit fullscreen mode

Now this service demonstrates DI with:

ApiClient
   |
   ├── HttpClient
   |
   └── APP_CONFIG
Enter fullscreen mode Exit fullscreen mode

Angular constructs the dependency graph.


74. Task Repository

Define:

export abstract class TaskRepository {

  abstract getTasks():
    Observable<Task[]>;

}
Enter fullscreen mode Exit fullscreen mode

Production implementation:

@Injectable()
export class HttpTaskRepository
  implements TaskRepository {

  private api =
    inject(ApiClient);

  getTasks() {

    return this.api.get<Task[]>(
      '/tasks'
    );

  }

}
Enter fullscreen mode Exit fullscreen mode

75. Connect Repository to DI

providers: [

  {
    provide: TaskRepository,

    useClass: HttpTaskRepository
  }

]
Enter fullscreen mode Exit fullscreen mode

Now:

inject(TaskRepository)
Enter fullscreen mode Exit fullscreen mode

returns:

HttpTaskRepository
Enter fullscreen mode Exit fullscreen mode

This is dependency inversion in practice.


76. Task Service

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

  private repository =
    inject(TaskRepository);

  getTasks() {

    return this.repository.getTasks();

  }

}
Enter fullscreen mode Exit fullscreen mode

Notice the architecture:

TaskService
     |
     ↓
TaskRepository
     |
     ↓
HttpTaskRepository
     |
     ↓
ApiClient
     |
     ↓
HttpClient
Enter fullscreen mode Exit fullscreen mode

The TaskService does not know how HTTP works.

That's good architecture.


77. Component

@Component({
  selector: 'app-task-list',

  template: `

    <h1>Tasks</h1>

    @for (task of tasks(); track task.id) {

      <article>

        <h2>{{ task.title }}</h2>

        <p>{{ task.description }}</p>

      </article>

    }

  `
})
export class TaskListComponent {

  private taskService =
    inject(TaskService);

  tasks = signal<Task[]>([]);

  constructor() {

    this.taskService
      .getTasks()
      .subscribe(tasks => {

        this.tasks.set(tasks);

      });

  }

}
Enter fullscreen mode Exit fullscreen mode

Now the component doesn't know anything about:

  • HTTP
  • URLs
  • authentication
  • repositories
  • configuration

It only knows:

TaskService
Enter fullscreen mode Exit fullscreen mode

That's exactly what DI should help you achieve.


78. Full Dependency Graph

The final graph looks like:

                    AppConfig
                       │
                       ↓
HttpClient ───────→ ApiClient
                       │
                       ↓
              HttpTaskRepository
                       │
                       ↓
                TaskRepository
                       │
                       ↓
                  TaskService
                       │
                       ↓
                TaskListComponent
Enter fullscreen mode Exit fullscreen mode

Angular's DI system resolves this graph.

You don't manually write:

new ApiClient(...)
new HttpTaskRepository(...)
new TaskService(...)
Enter fullscreen mode Exit fullscreen mode

Angular handles construction and provider resolution.


79. Replacing the Repository

Imagine tomorrow you replace REST with GraphQL.

Create:

@Injectable()
export class GraphQLTaskRepository
  implements TaskRepository {

  getTasks() {

    // GraphQL implementation

  }

}
Enter fullscreen mode Exit fullscreen mode

Change the provider:

{
  provide: TaskRepository,

  useClass:
    GraphQLTaskRepository
}
Enter fullscreen mode Exit fullscreen mode

The following code doesn't change:

TaskService
Enter fullscreen mode Exit fullscreen mode

or:

TaskListComponent
Enter fullscreen mode Exit fullscreen mode

That's the real power of abstraction + DI.


80. Adding a Fake Repository for Testing

@Injectable()
export class FakeTaskRepository
  implements TaskRepository {

  getTasks() {

    return of([
      {
        id: 1,
        title: 'Learn Angular DI',
        description:
          'Understand providers and injectors'
      }
    ]);

  }

}
Enter fullscreen mode Exit fullscreen mode

Test configuration:

providers: [

  {
    provide: TaskRepository,

    useClass:
      FakeTaskRepository
  }

]
Enter fullscreen mode Exit fullscreen mode

Now the entire application can run without a backend.


81. Factory-Based API Client

Suppose development and production require different API clients.

You can create:

export const API_CLIENT_PROVIDER = {

  provide: ApiClient,

  useFactory: () => {

    const http =
      inject(HttpClient);

    const config =
      inject(APP_CONFIG);

    const logger =
      inject(Logger);

    return new ApiClient(
      http,
      config,
      logger
    );

  }

};
Enter fullscreen mode Exit fullscreen mode

This is useful when object construction requires multiple dependencies or runtime decisions.


82. Multiple Log Handlers

Let's make logging extensible.

export const LOG_HANDLERS =
  new InjectionToken<LogHandler[]>(
    'LOG_HANDLERS'
  );
Enter fullscreen mode Exit fullscreen mode

Provide:

providers: [

  {
    provide: LOG_HANDLERS,
    useClass: ConsoleLogHandler,
    multi: true
  },

  {
    provide: LOG_HANDLERS,
    useClass: RemoteLogHandler,
    multi: true
  }

]
Enter fullscreen mode Exit fullscreen mode

Then:

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

  private handlers =
    inject(LOG_HANDLERS);

  info(message: string) {

    for (const handler
      of this.handlers) {

      handler.info(message);

    }

  }

}
Enter fullscreen mode Exit fullscreen mode

Now adding another logging destination requires adding another provider rather than changing LoggerService.


83. Building a provideTasks() API

We can package the entire feature.

export function provideTasks(): Provider[] {

  return [

    {
      provide: TaskRepository,

      useClass:
        HttpTaskRepository
    },

    TaskService

  ];

}
Enter fullscreen mode Exit fullscreen mode

Then:

bootstrapApplication(
  AppComponent,
  {
    providers: [
      provideTasks()
    ]
  }
);
Enter fullscreen mode Exit fullscreen mode

For a real library, you would typically return an appropriate provider configuration and may compose optional features.

This is the same architectural idea behind Angular's provider-function APIs.


84. Advanced provideX() Architecture

A more sophisticated API can look like:

provideTasks(
  withCaching(),
  withLogging(),
  withRetry()
)
Enter fullscreen mode Exit fullscreen mode

Internally:

export function provideTasks(
  ...features: TaskFeature[]
): Provider[] {

  return [

    TaskService,

    ...features.flatMap(
      feature => feature.providers
    )

  ];

}
Enter fullscreen mode Exit fullscreen mode

This pattern is excellent for reusable Angular libraries.


85. viewProviders in Real Applications

Consider a reusable component:

@Component({
  selector: 'app-data-table',

  viewProviders: [
    TableState
  ],

  template: `
    <table>
      ...
    </table>

    <ng-content />
  `
})
export class DataTableComponent {}
Enter fullscreen mode Exit fullscreen mode

If TableState is an internal implementation detail, viewProviders can prevent projected content from accidentally depending on it.

This is a real architectural reason to use viewProviders.


86. Lightweight Injection Tokens

Angular libraries sometimes want a parent component to communicate with a child without forcing a hard runtime dependency on the concrete component class.

For example:

export abstract class HeaderToken {

  abstract close(): void;

}
Enter fullscreen mode Exit fullscreen mode

Then:

@Component({
  selector: 'app-header',

  providers: [
    {
      provide: HeaderToken,
      useExisting: HeaderComponent
    }
  ]
})
export class HeaderComponent
  extends HeaderToken {

  close() {
    // ...
  }

}
Enter fullscreen mode Exit fullscreen mode

Consumers depend on:

HeaderToken
Enter fullscreen mode Exit fullscreen mode

instead of:

HeaderComponent
Enter fullscreen mode Exit fullscreen mode

Angular documents this as a lightweight injection-token pattern useful for library design and tree-shaking.


87. DI and Circular Dependencies

Sometimes you accidentally create:

A → B
↑   ↓
└───┘
Enter fullscreen mode Exit fullscreen mode

For example:

ServiceA  ServiceB
ServiceB  ServiceA
Enter fullscreen mode Exit fullscreen mode

This can produce DI errors.

The correct solution is usually architectural:

  • remove the circular dependency
  • introduce an abstraction
  • move shared behavior to another service
  • restructure the dependency graph

Don't immediately reach for forwardRef().

forwardRef() solves declaration/reference problems; it is not a general solution for bad architecture.


88. Common DI Error: NullInjectorError

You may see:

NullInjectorError:
No provider for UserService!
Enter fullscreen mode Exit fullscreen mode

Usually this means:

inject(UserService)
Enter fullscreen mode Exit fullscreen mode

was called but Angular couldn't find a provider in the available injector hierarchy.

Possible solutions:

@Injectable({
  providedIn: 'root'
})
Enter fullscreen mode Exit fullscreen mode

or:

providers: [
  UserService
]
Enter fullscreen mode Exit fullscreen mode

or:

bootstrapApplication(AppComponent, {
  providers: [
    UserService
  ]
});
Enter fullscreen mode Exit fullscreen mode

But don't blindly add:

providedIn: 'root'
Enter fullscreen mode Exit fullscreen mode

just to make the error disappear.

First determine the intended scope.


89. Common DI Error: Wrong Scope

Suppose:

@Component({
  providers: [
    CartService
  ]
})
export class CheckoutComponent {}
Enter fullscreen mode Exit fullscreen mode

Then another unrelated parent tries:

inject(CartService)
Enter fullscreen mode Exit fullscreen mode

and fails.

Why?

Because the service exists inside the checkout component's injector, not globally.

The solution isn't necessarily moving it to root.

The real question is:

Where should this dependency live?

That's the architectural question DI forces you to answer.


90. Common Error: inject() Outside Context

This:

ngOnInit() {

  const service =
    inject(UserService);

}
Enter fullscreen mode Exit fullscreen mode

causes:

NG0203
Enter fullscreen mode Exit fullscreen mode

because ngOnInit() is not an injection context.

Fix:

private service =
  inject(UserService);
Enter fullscreen mode Exit fullscreen mode

Then:

ngOnInit() {

  this.service.load();

}
Enter fullscreen mode Exit fullscreen mode

Angular explicitly documents NG0203 as the error produced when inject() is used outside a valid injection context.


91. Common Error: Two InjectionTokens

Wrong:

const CONFIG =
  new InjectionToken<AppConfig>(
    'CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode

Provider file:

const CONFIG =
  new InjectionToken<AppConfig>(
    'CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode

These are different tokens.

Correct:

// config.token.ts

export const CONFIG =
  new InjectionToken<AppConfig>(
    'CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode

Then:

import { CONFIG }
  from './config.token';
Enter fullscreen mode Exit fullscreen mode

everywhere.


92. Common Error: Confusing useClass and useExisting

If you want:

same instance
Enter fullscreen mode Exit fullscreen mode

use:

useExisting
Enter fullscreen mode Exit fullscreen mode

If you want Angular to create an implementation from a class:

useClass
Enter fullscreen mode Exit fullscreen mode

Remember:

useClass    → implementation
useExisting → alias
Enter fullscreen mode Exit fullscreen mode

93. Common Error: Making Everything providedIn: 'root'

This is probably the most common architectural mistake.

Developers write:

@Injectable({
  providedIn: 'root'
})
Enter fullscreen mode Exit fullscreen mode

for every service.

But some services should be scoped:

@Component({
  providers: [
    FormStateService
  ]
})
Enter fullscreen mode Exit fullscreen mode

Others may belong to:

feature
route
environment
application
component
Enter fullscreen mode Exit fullscreen mode

A good Angular architecture chooses scope intentionally.


94. How to Decide Provider Scope

Ask:

Should every part of the application share one instance?

Use:

providedIn: 'root'
Enter fullscreen mode Exit fullscreen mode

Should every instance of a component have independent state?

Use:

@Component({
  providers: [...]
})
Enter fullscreen mode Exit fullscreen mode

Should a feature have its own dependency?

Consider route/environment providers.

Should the dependency be configurable?

Use:

InjectionToken
Enter fullscreen mode Exit fullscreen mode

Should different implementations be interchangeable?

Use:

abstract class / InjectionToken
+
useClass
Enter fullscreen mode Exit fullscreen mode

Should multiple implementations contribute?

Use:

multi: true
Enter fullscreen mode Exit fullscreen mode

Should an existing provider be exposed under another token?

Use:

useExisting
Enter fullscreen mode Exit fullscreen mode

Does creation require runtime logic?

Use:

useFactory
Enter fullscreen mode Exit fullscreen mode

95. DI in Modern Angular vs Old Angular

Concept Traditional Angular Modern Angular
Injection Constructor inject() + constructor
Application architecture NgModules Standalone
Global providers AppModule.providers ApplicationConfig / providedIn
Feature providers NgModules Environment/route providers
Guards Class-based Functional guards
Resolvers Class-based Functional resolvers
Configuration InjectionToken InjectionToken
Provider factories useFactory useFactory + inject()
Library APIs NgModule configuration provideX() / withX()
Scope Module/component Environment/element/route/component

The important point is that Angular did not throw away DI.

Instead, Angular evolved the way DI is configured and consumed.


96. What You Should Learn for an Angular Interview

If an interviewer asks:

"Explain Dependency Injection in Angular."

Don't answer only:

"Angular injects services into components."

A strong answer is:

Angular's Dependency Injection system manages the creation and delivery of dependencies using tokens, providers, and injectors. A provider defines how a token should be resolved, while an injector performs the resolution and controls scope and lifetime. Modern Angular supports both constructor injection and the inject() function. Providers can be configured through providedIn, application/environment providers, routes, components, and directives. Angular also supports provider strategies such as useClass, useValue, useFactory, useExisting, and multi, while hierarchical injectors allow dependencies to be scoped and overridden at different levels.

Then explain:

Token
Provider
Injector
Scope
Resolution
Enter fullscreen mode Exit fullscreen mode

That demonstrates real understanding.


97. The Mental Model You Should Remember

Whenever Angular sees:

inject(SomeDependency)
Enter fullscreen mode Exit fullscreen mode

think:

What token is being requested?
          ↓
Which injector is currently active?
          ↓
Where is the nearest provider?
          ↓
What provider recipe is configured?
          ↓
Does Angular create or reuse an instance?
          ↓
What is the dependency's scope/lifetime?
Enter fullscreen mode Exit fullscreen mode

For example:

inject(TaskRepository)
Enter fullscreen mode Exit fullscreen mode

becomes:

TaskRepository
      ↓
Current Injector
      ↓
Find Provider
      ↓
useClass: HttpTaskRepository
      ↓
Create/Reuse HttpTaskRepository
      ↓
Resolve its dependencies
      ↓
Return instance
Enter fullscreen mode Exit fullscreen mode

That is Angular DI.


98. The Complete Picture

Angular DI can be understood as five layers:

                    ┌──────────────────────┐
                    │       Consumer       │
                    │  Component/Service   │
                    └──────────┬───────────┘
                               │
                               │ inject(Token)
                               ↓
                    ┌──────────────────────┐
                    │       Injector       │
                    │   Resolve dependency │
                    └──────────┬───────────┘
                               │
                               ↓
                    ┌──────────────────────┐
                    │       Provider       │
                    │ useClass/useValue/   │
                    │ useFactory/useExisting│
                    └──────────┬───────────┘
                               │
                               ↓
                    ┌──────────────────────┐
                    │      Dependency      │
                    │ Service/Object/etc.  │
                    └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

And the injector itself exists within a hierarchy:

                 EnvironmentInjector
                         │
             ┌───────────┴───────────┐
             │                       │
        Application              Feature/Route
             │                       │
             └───────────┬───────────┘
                         │
                  ElementInjector
                         │
                    Component
                         │
                   Child Component
                         │
                  Grandchild Component
Enter fullscreen mode Exit fullscreen mode

99. Best Practices

1. Prefer providedIn: 'root' for true application-wide services

@Injectable({
  providedIn: 'root'
})
Enter fullscreen mode Exit fullscreen mode

2. Use component providers for isolated state

@Component({
  providers: [
    FormState
  ]
})
Enter fullscreen mode Exit fullscreen mode

3. Prefer inject() for modern Angular code

private service = inject(MyService);
Enter fullscreen mode Exit fullscreen mode

4. Keep constructor injection when it makes sense

You don't need to rewrite every existing Angular application.

5. Use InjectionToken for interfaces and configuration

new InjectionToken<AppConfig>()
Enter fullscreen mode Exit fullscreen mode

6. Use abstractions when implementations may change

abstract class PaymentGateway {}
Enter fullscreen mode Exit fullscreen mode

7. Use useExisting when you need an alias to the same instance

8. Use multi for extensible collections

9. Don't make every service global

Scope dependencies intentionally.

10. Don't use runInInjectionContext() unnecessarily

Normal DI should remain the default.

11. Don't create duplicate InjectionToken instances

Export one token and reuse it.

12. Use Angular DevTools when debugging injector problems

Angular DevTools can visualize injector hierarchies, provider lists, and resolution paths, which is extremely useful when debugging complex DI behavior.


100. Final Cheat Sheet

// Root service
@Injectable({
  providedIn: 'root'
})
export class UserService {}
Enter fullscreen mode Exit fullscreen mode
// Modern injection
private userService = inject(UserService);
Enter fullscreen mode Exit fullscreen mode
// Classic injection
constructor(
  private userService: UserService
) {}
Enter fullscreen mode Exit fullscreen mode
// Value
{
  provide: API_URL,
  useValue: 'https://api.example.com'
}
Enter fullscreen mode Exit fullscreen mode
// Class
{
  provide: Logger,
  useClass: ConsoleLogger
}
Enter fullscreen mode Exit fullscreen mode
// Alias
{
  provide: LegacyLogger,
  useExisting: Logger
}
Enter fullscreen mode Exit fullscreen mode
// Factory
{
  provide: ApiClient,
  useFactory: () => {

    const http = inject(HttpClient);

    return new ApiClient(http);

  }
}
Enter fullscreen mode Exit fullscreen mode
// Multiple providers
{
  provide: HANDLERS,
  useClass: ConsoleHandler,
  multi: true
}
Enter fullscreen mode Exit fullscreen mode
// Optional
inject(Service, {
  optional: true
});
Enter fullscreen mode Exit fullscreen mode
// Parent
inject(Service, {
  skipSelf: true
});
Enter fullscreen mode Exit fullscreen mode
// Current injector only
inject(Service, {
  self: true
});
Enter fullscreen mode Exit fullscreen mode
// Host boundary
inject(Service, {
  host: true
});
Enter fullscreen mode Exit fullscreen mode
// InjectionToken
const CONFIG =
  new InjectionToken<AppConfig>(
    'CONFIG'
  );
Enter fullscreen mode Exit fullscreen mode
// Component scope
@Component({
  providers: [
    LocalState
  ]
})
Enter fullscreen mode Exit fullscreen mode
// Component view-only scope
@Component({
  viewProviders: [
    InternalService
  ]
})
Enter fullscreen mode Exit fullscreen mode
// Modern application providers
bootstrapApplication(
  AppComponent,
  {
    providers: [
      provideRouter(routes),
      provideHttpClient()
    ]
  }
);
Enter fullscreen mode Exit fullscreen mode
// Custom provider API
provideAnalytics({
  trackingId: '123'
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Angular Dependency Injection is much more than:

constructor(private service: Service) {}
Enter fullscreen mode Exit fullscreen mode

That syntax is only the visible surface.

Underneath it is a complete dependency-resolution system built around:

Tokens
   ↓
Providers
   ↓
Injectors
   ↓
Hierarchies
   ↓
Scopes
   ↓
Lifetimes
   ↓
Implementations
Enter fullscreen mode Exit fullscreen mode

The evolution from:

constructor(...)
Enter fullscreen mode Exit fullscreen mode

to:

inject(...)
Enter fullscreen mode Exit fullscreen mode

didn't replace Angular DI.

It made DI usable in a much broader range of modern Angular APIs, including functional guards, resolvers, provider factories, standalone applications, environment providers, and library configuration.

The most important concepts to master are:

Dependency Injection
       ↓
Token
       ↓
Provider
       ↓
Injector
       ↓
EnvironmentInjector
       ↓
ElementInjector
       ↓
Hierarchical Resolution
       ↓
Provider Scope
       ↓
useClass
useValue
useFactory
useExisting
multi
       ↓
InjectionToken
       ↓
inject()
       ↓
Injection Context
       ↓
Self / SkipSelf / Host / Optional
       ↓
Standalone + provideX()
       ↓
Testing + Architecture
Enter fullscreen mode Exit fullscreen mode

Once you understand these concepts, Angular DI stops being a collection of decorators and APIs.

You start seeing it as what it really is:

A runtime dependency-resolution and scoping system that allows Angular applications to separate what a component needs from how that dependency is created.

And that separation is one of the foundations of maintainable, testable, scalable Angular architecture.


References

Top comments (0)