
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) {}
Then Angular introduced:
private userService = inject(UserService);
Modern Angular goes even further with:
EnvironmentInjectorInjectionTokenuseValueuseClassuseFactoryuseExistingmultiprovidedInprovidersviewProvidersOptionalSelfSkipSelfHostforwardRefrunInInjectionContext- functional guards and resolvers
- provider functions such as
provideRouter()andprovideHttpClient() - 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'
};
}
}
Now a component needs it.
A naive approach is:
export class UserComponent {
private userService = new UserService();
}
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) {}
}
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) {}
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>;
}
Then:
@Injectable()
export class StripePaymentGateway implements PaymentGateway {
async pay(amount: number) {
// Stripe implementation
}
}
Angular DI can connect them:
providers: [
{
provide: PaymentGateway,
useClass: StripePaymentGateway
}
]
Now the application depends on:
PaymentGateway
rather than:
StripePaymentGateway
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
or:
object
or:
string
or:
number
or:
function
or:
configuration
For example:
class UserService {
constructor(
private http: HttpClient,
private config: AppConfig
) {}
}
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
For example:
providers: [
{
provide: UserService,
useClass: UserService
}
]
Here:
Token
UserService
The token identifies what we want.
Provider
{
provide: UserService,
useClass: UserService
}
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
5. The Old Angular DI Model
Before modern Angular patterns became common, you typically saw:
@Injectable({
providedIn: 'root'
})
export class UserService {}
and:
@Component({
selector: 'app-user',
template: `...`
})
export class UserComponent {
constructor(
private userService: UserService
) {}
}
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'
};
}
}
Then:
@Component({
selector: 'app-user',
template: `
<h1>{{ user.name }}</h1>
`
})
export class UserComponent {
constructor(
private userService: UserService
) {}
user = this.userService.getUser();
}
Angular sees:
UserService
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
) {}
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);
}
This is dependency injection too.
Angular resolves:
UserService
from the currently active injector.
The important thing is:
inject(UserService)
does not mean:
new UserService()
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
) {}
}
Modern style
export class DashboardComponent {
private userService = inject(UserService);
private router = inject(Router);
}
Both are valid.
A practical modern Angular style is:
private userService = inject(UserService);
private router = inject(Router);
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)
anywhere.
This is invalid:
export class UserComponent {
ngOnInit() {
const service = inject(UserService); // ❌
}
}
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);
}
Constructor
export class UserComponent {
constructor() {
const service = inject(UserService);
}
}
Provider factory
{
provide: UserService,
useFactory: () => {
const logger = inject(LoggerService);
return new UserService(logger);
}
}
InjectionToken factory
const API_URL = new InjectionToken<string>(
'API_URL',
{
providedIn: 'root',
factory: () => {
const config = inject(AppConfig);
return config.apiUrl;
}
}
);
Functional route guard
export const authGuard = () => {
const auth = inject(AuthService);
return auth.isAuthenticated();
};
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); // ❌
}
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();
}
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()
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');
}
);
}
}
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);
You can then resolve dependencies manually:
const service = this.injector.get(UserService);
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);
}
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 {}
However, this alone doesn't necessarily make the service globally available.
You need a provider.
For example:
@Component({
providers: [
UserService
]
})
export class UserComponent {}
Or:
bootstrapApplication(AppComponent, {
providers: [
UserService
]
});
15. providedIn
The most common modern approach is:
@Injectable({
providedIn: 'root'
})
export class UserService {}
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'
})
is the default choice.
16. providedIn: 'root'
@Injectable({
providedIn: 'root'
})
export class AuthService {}
This normally gives the application a single instance associated with the root environment injector.
Every component requesting:
inject(AuthService)
will resolve the same root-scoped instance unless a closer provider overrides it.
17. providedIn: 'platform'
Historically Angular also supported:
@Injectable({
providedIn: 'platform'
})
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'
This had special behavior with lazy-loaded injectors.
Modern Angular documentation marks 'any' as deprecated.
So if you see:
providedIn: 'any'
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
})
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 {}
you can manually provide:
providers: [
UserService
]
For example:
@Component({
selector: 'app-profile',
providers: [
UserService
],
template: `...`
})
export class ProfileComponent {}
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);
}
Every instance of CounterComponent gets its own CounterService.
For example:
<app-counter></app-counter>
<app-counter></app-counter>
Conceptually:
CounterComponent #1
|
└── CounterService #1
CounterComponent #2
|
└── CounterService #2
This is completely different from:
@Injectable({
providedIn: 'root'
})
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
]
is effectively a shorthand for:
providers: [
{
provide: UserService,
useClass: UserService
}
]
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
}
]
Whenever something asks for:
LoggerService
Angular creates:
ConsoleLoggerService
This is extremely useful for abstraction.
For example:
abstract class PaymentGateway {
abstract pay(amount: number): Promise<void>;
}
Production:
providers: [
{
provide: PaymentGateway,
useClass: StripePaymentGateway
}
]
Testing:
providers: [
{
provide: PaymentGateway,
useClass: FakePaymentGateway
}
]
The consumer doesn't change.
24. useValue
Sometimes you don't need Angular to create an object.
You already have the value.
Use:
useValue
Example:
export const API_URL =
new InjectionToken<string>('API_URL');
bootstrapApplication(AppComponent, {
providers: [
{
provide: API_URL,
useValue: 'https://api.example.com'
}
]
});
Then:
export class ApiService {
private apiUrl = inject(API_URL);
}
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;
}
and:
constructor(private config: AppConfig) {}
Why?
Because interfaces disappear during TypeScript compilation.
At runtime:
AppConfig
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');
Provide:
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
production: false
}
}
]
Inject:
private config = inject(APP_CONFIG);
Now:
this.config.apiUrl
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');
in one file and:
const API_URL =
new InjectionToken<string>('API_URL');
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');
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
);
}
};
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
]
}
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
);
}
}
30. useExisting
This is one of the most misunderstood provider types.
Suppose:
class NewLogger {}
class OldLogger {}
You can alias:
providers: [
NewLogger,
{
provide: OldLogger,
useExisting: NewLogger
}
]
Now:
inject(NewLogger)
and:
inject(OldLogger)
return the same instance.
31. useExisting vs useClass
This distinction matters.
useClass
{
provide: Logger,
useClass: ConsoleLogger
}
Angular creates an instance of:
ConsoleLogger
useExisting
{
provide: LegacyLogger,
useExisting: ConsoleLogger
}
Angular returns the already-existing ConsoleLogger instance.
Conceptually:
useClass
Logger ───────> ConsoleLogger #1
useExisting
Logger ───────┐
├──> ConsoleLogger #1
LegacyLogger ─┘
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'
);
Then:
providers: [
{
provide: LOG_HANDLERS,
useClass: ConsoleLogHandler,
multi: true
},
{
provide: LOG_HANDLERS,
useClass: RemoteLogHandler,
multi: true
},
{
provide: LOG_HANDLERS,
useClass: AnalyticsLogHandler,
multi: true
}
]
Now:
private handlers = inject(LOG_HANDLERS);
returns an array.
LOG_HANDLERS
|
├── ConsoleLogHandler
├── RemoteLogHandler
└── AnalyticsLogHandler
This pattern is heavily used in extensible Angular architecture.
33. providers vs viewProviders
These look similar:
providers
and:
viewProviders
but they have an important difference.
Consider:
<app-parent>
<app-child />
</app-parent>
and:
<app-parent>
<ng-content />
</app-parent>
providers can be visible to projected content.
viewProviders restricts the provider to the component's own view.
Example:
@Component({
providers: [
ThemeService
]
})
The service can be available to projected content.
But:
@Component({
viewProviders: [
ThemeService
]
})
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
and:
ElementInjector hierarchy
Component
|
Child Component
|
Grandchild Component
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
]
});
Or:
@Injectable({
providedIn: 'root'
})
export class AuthService {}
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 {}
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);
}
Angular roughly searches:
Child ElementInjector
↓
Parent ElementInjector
↓
Ancestor ElementInjectors
↓
EnvironmentInjector hierarchy
↓
Root
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';
}
Then:
@Component({
providers: [
{
provide: LoggerService,
useValue: {
name: 'Local Logger'
}
}
]
})
export class FeatureComponent {}
Inside:
FeatureComponent
you get:
Local Logger
while another component outside that subtree still gets:
Global Logger
This is dependency scoping.
39. Self
Sometimes you want Angular to search only the current injector.
With inject():
inject(UserService, {
self: true
});
Conceptually:
Current Injector
|
X
Parent
|
X
Root
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
});
Angular skips the current injector and starts searching from the parent.
Conceptually:
Current Injector ← skipped
Parent Injector ← start here
↓
Grandparent
↓
Root
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);
use:
inject(AnalyticsService, {
optional: true
});
Now Angular can return:
null
if the provider doesn't exist.
Example:
private analytics =
inject(AnalyticsService, {
optional: true
});
track() {
this.analytics?.track('click');
}
42. Host
host limits the DI search according to the host boundary.
inject(UserService, {
host: true
});
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
});
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
});
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
) {}
or:
constructor(
@Self()
private service: LocalService
) {}
or:
constructor(
@SkipSelf()
private service: ParentService
) {}
or:
constructor(
@Host()
private service: HostService
) {}
You may also see:
@Inject(TOKEN)
These are older constructor-oriented APIs.
Modern inject() expresses the same concepts through options:
inject(TOKEN, {
optional: true
});
45. @Inject()
Suppose the dependency isn't represented by a class:
export const API_URL =
new InjectionToken<string>('API_URL');
Old style:
constructor(
@Inject(API_URL)
private apiUrl: string
) {}
Modern:
private apiUrl = inject(API_URL);
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 {}
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 {}
The application started with:
platformBrowserDynamic()
.bootstrapModule(AppModule);
This architecture is still supported.
But modern Angular applications increasingly use:
bootstrapApplication()
with standalone components and environment providers.
48. Modern Standalone DI
Modern Angular:
bootstrapApplication(
AppComponent,
{
providers: [
UserService
]
}
);
You can also use Angular's provider functions:
bootstrapApplication(
AppComponent,
{
providers: [
provideRouter(routes),
provideHttpClient()
]
}
);
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(...)
and:
provideHttpClient(...)
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
];
}
Consumer:
bootstrapApplication(AppComponent, {
providers: [
provideAnalytics({
trackingId: 'GA-123'
})
]
});
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()
)
You can implement the same architecture.
Example:
export function withLogging(): AnalyticsFeature {
return {
providers: [
LoggingAnalyticsHandler
]
};
}
Then:
provideAnalytics(
config,
withLogging()
);
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';
}
}
);
Now you don't need:
providers: [
{
provide: API_URL,
useValue: ...
}
]
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();
}
Testing becomes difficult.
With DI:
class UserService {
private api =
inject(ApiClient);
}
Test:
TestBed.configureTestingModule({
providers: [
UserService,
{
provide: ApiClient,
useClass: FakeApiClient
}
]
});
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>;
}
Production:
{
provide: PaymentGateway,
useClass: StripePaymentGateway
}
Testing:
{
provide: PaymentGateway,
useClass: FakePaymentGateway
}
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 {}
This is normally application-wide.
But:
@Component({
providers: [
CartService
]
})
export class CheckoutComponent {}
creates a different scoped instance.
You can therefore have:
Root CartService
|
├── Component A → root instance
|
└── Checkout → local instance
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'
Usually lives for the application lifetime.
Component provider
@Component({
providers: [CartService]
})
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++;
}
}
Then:
@Component({
selector: 'app-checkout-wizard',
providers: [
WizardState
],
template: `...`
})
export class CheckoutWizard {
state = inject(WizardState);
}
Now every checkout wizard gets independent state.
You don't need:
WizardState #1
WizardState #2
WizardState #3
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)
}
];
This is powerful because you can scope dependencies to a feature.
For example:
Application
|
├── Public area
|
└── Admin route
|
└── AdminService
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();
}
}
Modern Angular can use:
export const authGuard: CanActivateFn = () => {
const auth =
inject(AuthService);
return auth.isAuthenticated();
};
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();
};
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);
}
Angular can inject framework-provided tokens such as:
ElementRef
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}`;
}
}
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}`
}
})
);
};
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
APP_NAME
FEATURE_FLAGS
DEFAULT_PAGE_SIZE
Instead of hardcoding these everywhere, define tokens.
export interface AppConfig {
apiUrl: string;
appName: string;
features: {
analytics: boolean;
darkMode: boolean;
};
}
Then:
export const APP_CONFIG =
new InjectionToken<AppConfig>(
'APP_CONFIG'
);
Provide it once:
bootstrapApplication(AppComponent, {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
appName: 'Task Manager',
features: {
analytics: true,
darkMode: true
}
}
}
]
});
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
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
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'
);
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
}
}
}
]
};
69. Logger Abstraction
Create:
// core/logging/logger.ts
export abstract class Logger {
abstract info(
message: string
): void;
abstract error(
message: string
): void;
}
Now the application depends on:
Logger
not:
ConsoleLogger
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}`
);
}
}
71. Connect the Abstraction
In app.config.ts:
providers: [
{
provide: Logger,
useClass: ConsoleLogger
}
]
Now:
inject(Logger)
returns:
ConsoleLogger
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;
}
}
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}`
);
}
}
Now this service demonstrates DI with:
ApiClient
|
├── HttpClient
|
└── APP_CONFIG
Angular constructs the dependency graph.
74. Task Repository
Define:
export abstract class TaskRepository {
abstract getTasks():
Observable<Task[]>;
}
Production implementation:
@Injectable()
export class HttpTaskRepository
implements TaskRepository {
private api =
inject(ApiClient);
getTasks() {
return this.api.get<Task[]>(
'/tasks'
);
}
}
75. Connect Repository to DI
providers: [
{
provide: TaskRepository,
useClass: HttpTaskRepository
}
]
Now:
inject(TaskRepository)
returns:
HttpTaskRepository
This is dependency inversion in practice.
76. Task Service
@Injectable({
providedIn: 'root'
})
export class TaskService {
private repository =
inject(TaskRepository);
getTasks() {
return this.repository.getTasks();
}
}
Notice the architecture:
TaskService
|
↓
TaskRepository
|
↓
HttpTaskRepository
|
↓
ApiClient
|
↓
HttpClient
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);
});
}
}
Now the component doesn't know anything about:
- HTTP
- URLs
- authentication
- repositories
- configuration
It only knows:
TaskService
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
Angular's DI system resolves this graph.
You don't manually write:
new ApiClient(...)
new HttpTaskRepository(...)
new TaskService(...)
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
}
}
Change the provider:
{
provide: TaskRepository,
useClass:
GraphQLTaskRepository
}
The following code doesn't change:
TaskService
or:
TaskListComponent
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'
}
]);
}
}
Test configuration:
providers: [
{
provide: TaskRepository,
useClass:
FakeTaskRepository
}
]
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
);
}
};
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'
);
Provide:
providers: [
{
provide: LOG_HANDLERS,
useClass: ConsoleLogHandler,
multi: true
},
{
provide: LOG_HANDLERS,
useClass: RemoteLogHandler,
multi: true
}
]
Then:
@Injectable({
providedIn: 'root'
})
export class LoggerService {
private handlers =
inject(LOG_HANDLERS);
info(message: string) {
for (const handler
of this.handlers) {
handler.info(message);
}
}
}
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
];
}
Then:
bootstrapApplication(
AppComponent,
{
providers: [
provideTasks()
]
}
);
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()
)
Internally:
export function provideTasks(
...features: TaskFeature[]
): Provider[] {
return [
TaskService,
...features.flatMap(
feature => feature.providers
)
];
}
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 {}
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;
}
Then:
@Component({
selector: 'app-header',
providers: [
{
provide: HeaderToken,
useExisting: HeaderComponent
}
]
})
export class HeaderComponent
extends HeaderToken {
close() {
// ...
}
}
Consumers depend on:
HeaderToken
instead of:
HeaderComponent
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
↑ ↓
└───┘
For example:
ServiceA → ServiceB
ServiceB → ServiceA
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!
Usually this means:
inject(UserService)
was called but Angular couldn't find a provider in the available injector hierarchy.
Possible solutions:
@Injectable({
providedIn: 'root'
})
or:
providers: [
UserService
]
or:
bootstrapApplication(AppComponent, {
providers: [
UserService
]
});
But don't blindly add:
providedIn: 'root'
just to make the error disappear.
First determine the intended scope.
89. Common DI Error: Wrong Scope
Suppose:
@Component({
providers: [
CartService
]
})
export class CheckoutComponent {}
Then another unrelated parent tries:
inject(CartService)
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);
}
causes:
NG0203
because ngOnInit() is not an injection context.
Fix:
private service =
inject(UserService);
Then:
ngOnInit() {
this.service.load();
}
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'
);
Provider file:
const CONFIG =
new InjectionToken<AppConfig>(
'CONFIG'
);
These are different tokens.
Correct:
// config.token.ts
export const CONFIG =
new InjectionToken<AppConfig>(
'CONFIG'
);
Then:
import { CONFIG }
from './config.token';
everywhere.
92. Common Error: Confusing useClass and useExisting
If you want:
same instance
use:
useExisting
If you want Angular to create an implementation from a class:
useClass
Remember:
useClass → implementation
useExisting → alias
93. Common Error: Making Everything providedIn: 'root'
This is probably the most common architectural mistake.
Developers write:
@Injectable({
providedIn: 'root'
})
for every service.
But some services should be scoped:
@Component({
providers: [
FormStateService
]
})
Others may belong to:
feature
route
environment
application
component
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'
Should every instance of a component have independent state?
Use:
@Component({
providers: [...]
})
Should a feature have its own dependency?
Consider route/environment providers.
Should the dependency be configurable?
Use:
InjectionToken
Should different implementations be interchangeable?
Use:
abstract class / InjectionToken
+
useClass
Should multiple implementations contribute?
Use:
multi: true
Should an existing provider be exposed under another token?
Use:
useExisting
Does creation require runtime logic?
Use:
useFactory
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 throughprovidedIn, application/environment providers, routes, components, and directives. Angular also supports provider strategies such asuseClass,useValue,useFactory,useExisting, andmulti, while hierarchical injectors allow dependencies to be scoped and overridden at different levels.
Then explain:
Token
Provider
Injector
Scope
Resolution
That demonstrates real understanding.
97. The Mental Model You Should Remember
Whenever Angular sees:
inject(SomeDependency)
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?
For example:
inject(TaskRepository)
becomes:
TaskRepository
↓
Current Injector
↓
Find Provider
↓
useClass: HttpTaskRepository
↓
Create/Reuse HttpTaskRepository
↓
Resolve its dependencies
↓
Return instance
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. │
└──────────────────────┘
And the injector itself exists within a hierarchy:
EnvironmentInjector
│
┌───────────┴───────────┐
│ │
Application Feature/Route
│ │
└───────────┬───────────┘
│
ElementInjector
│
Component
│
Child Component
│
Grandchild Component
99. Best Practices
1. Prefer providedIn: 'root' for true application-wide services
@Injectable({
providedIn: 'root'
})
2. Use component providers for isolated state
@Component({
providers: [
FormState
]
})
3. Prefer inject() for modern Angular code
private service = inject(MyService);
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>()
6. Use abstractions when implementations may change
abstract class PaymentGateway {}
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 {}
// Modern injection
private userService = inject(UserService);
// Classic injection
constructor(
private userService: UserService
) {}
// Value
{
provide: API_URL,
useValue: 'https://api.example.com'
}
// Class
{
provide: Logger,
useClass: ConsoleLogger
}
// Alias
{
provide: LegacyLogger,
useExisting: Logger
}
// Factory
{
provide: ApiClient,
useFactory: () => {
const http = inject(HttpClient);
return new ApiClient(http);
}
}
// Multiple providers
{
provide: HANDLERS,
useClass: ConsoleHandler,
multi: true
}
// Optional
inject(Service, {
optional: true
});
// Parent
inject(Service, {
skipSelf: true
});
// Current injector only
inject(Service, {
self: true
});
// Host boundary
inject(Service, {
host: true
});
// InjectionToken
const CONFIG =
new InjectionToken<AppConfig>(
'CONFIG'
);
// Component scope
@Component({
providers: [
LocalState
]
})
// Component view-only scope
@Component({
viewProviders: [
InternalService
]
})
// Modern application providers
bootstrapApplication(
AppComponent,
{
providers: [
provideRouter(routes),
provideHttpClient()
]
}
);
// Custom provider API
provideAnalytics({
trackingId: '123'
});
Conclusion
Angular Dependency Injection is much more than:
constructor(private service: Service) {}
That syntax is only the visible surface.
Underneath it is a complete dependency-resolution system built around:
Tokens
↓
Providers
↓
Injectors
↓
Hierarchies
↓
Scopes
↓
Lifetimes
↓
Implementations
The evolution from:
constructor(...)
to:
inject(...)
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
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
- Angular Dependency Injection Guide — Angular DI Documentation
- Angular Hierarchical Injectors — Hierarchical Dependency Injection
- Angular Provider Configuration — Defining Dependency Providers
- Angular Injection Context — Injection Context
- Angular
inject()API — inject() API - Angular DevTools Injector Tree — Angular DevTools Injectors
Top comments (0)