When I join a large Angular codebase, the first pain I notice is not the UI — it’s the hidden tangle of constructors, shared utilities, and “helper” singletons that silently depend on each other. The app might still run, but every change feels risky because the wiring is informal and brittle. Dependency Injection (DI) is the cure for that wiring problem. It gives you a formal, testable, and predictable way to supply dependencies, which means you can change one piece without pulling the rest apart. In my experience, DI is the difference between an Angular app that scales gracefully and one that slowly collapses into a pile of circular imports and hard‑to‑mock globals.
Here’s what you’ll learn: how Angular’s injector really resolves dependencies, how to scope providers the right way, how to design services that are actually reusable, and how to avoid the classic traps that slow teams down. I’ll walk through practical patterns I use in 2026 projects, show you complete runnable examples, and explain why some DI setups make performance better while others accidentally make it worse. If you’ve ever wondered why a service instance isn’t the one you expected, or why a seemingly simple refactor broke half the tests, this is for you.
Dependency Injection, explained like I teach it to new hires
Dependency Injection is a design pattern where a class receives the objects it needs from the outside rather than creating them internally. I describe it with a simple analogy: instead of each chef buying their own ingredients, the kitchen has a pantry system that supplies what they need. The chef focuses on cooking; the pantry handles sourcing. In Angular, the “pantry system” is the injector, and the “ingredients” are services, values, and factories.
Why it matters: when a class builds its own dependencies, it becomes hard to test and hard to replace. When the injector provides those dependencies, you can swap implementations, scope them, and mock them during tests with minimal friction. That is the foundation of modular architecture in Angular.
The three things DI always gives you
- Loose coupling: The class depends on an interface or token, not a concrete implementation.
- Lifecycle control: Angular decides when to create and destroy instances, so you don’t have to.
- Testability: You can provide fake implementations in unit tests without changing production code.
How Angular’s injector resolves dependencies (the mental model I rely on)
Angular’s DI system is hierarchical. Each component has access to an injector that can resolve tokens locally or walk up to parent injectors. This hierarchy enables scoping: a service can be app‑wide, feature‑specific, or even tied to a single component instance.
When Angular creates a class that requests dependencies in its constructor, it follows this flow:
- Read the constructor parameter types (or explicit injection tokens).
- Start at the current injector.
- If a provider exists for the token, use it.
- If not, walk up to the parent injector.
- If still not found, error with
NullInjectorError.
You can think of the injector tree as a series of nested shelves. The nearest shelf wins. This is why provider scope matters so much: if you accidentally register a provider at a component level, you might get multiple instances without realizing it.
Practical example of the resolution order
If you provide the same token at different levels, the closest one wins. Here’s a small, runnable example that shows that behavior:
// app.module.ts
import { NgModule } from ‘@angular/core‘;
import { BrowserModule } from ‘@angular/platform-browser‘;
import { AppComponent } from ‘./app.component‘;
import { FeatureComponent } from ‘./feature.component‘;
export class ApiConfig {
constructor(public baseUrl: string) {}
}
@NgModule({
declarations: [AppComponent, FeatureComponent],
imports: [BrowserModule],
providers: [
{ provide: ApiConfig, useValue: new ApiConfig(‘https://api.example.com‘) }
],
bootstrap: [AppComponent]
})
export class AppModule {}
// feature.component.ts
import { Component } from ‘@angular/core‘;
import { ApiConfig } from ‘./app.module‘;
@Component({
selector: ‘app-feature‘,
template: Base URL: {{ config.baseUrl }},
providers: [
{ provide: ApiConfig, useValue: new ApiConfig(‘https://api.feature.local‘) }
]
})
export class FeatureComponent {
constructor(public config: ApiConfig) {}
}
In the component, you get https://api.feature.local because the component-level provider overrides the module-level provider. That’s not just a neat trick — it’s a crucial tool for isolating features or tests.
The invisible rule: constructor parameters are just tokens
The constructor type metadata is the default token. That means constructor(private http: HttpClient) {} is shorthand for “inject me the provider registered for the HttpClient token.” When you see DI issues, the first thing I do is identify what token Angular is actually looking for. If it’s not in the provider registry, Angular can’t help you.
Providers, tokens, and the DI registry
The provider is how you register a dependency with the injector. A provider tells Angular how to create a value for a specific token. A token is the key used to look up that value. Tokens can be classes, InjectionTokens, or strings (though strings are rarely a good idea in modern code).
I recommend thinking in this simple mapping:
- Token: The thing you request (
MyService,API_URL,LOGGER). - Provider: The recipe for how Angular creates it.
- Instance: The object you actually receive.
Common provider types in real projects
useClass: Create a new instance of a class.useValue: Use a constant value or object literal.useFactory: Create via a factory function (for runtime configuration).useExisting: Alias one token to another.
Here is a complete example that shows each provider type with realistic names:
// tokens.ts
import { InjectionToken } from ‘@angular/core‘;
export interface Logger {
log(message: string): void;
}
export const APIURL = new InjectionToken(‘APIURL‘);
export const LOGGER = new InjectionToken(‘LOGGER‘);
// logger.service.ts
import { Injectable } from ‘@angular/core‘;
import { Logger } from ‘./tokens‘;
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) {
// Simple console logging for demo purposes
console.log([app] ${message});
}
}
// app.module.ts
import { NgModule } from ‘@angular/core‘;
import { BrowserModule } from ‘@angular/platform-browser‘;
import { AppComponent } from ‘./app.component‘;
import { API_URL, LOGGER } from ‘./tokens‘;
import { ConsoleLogger } from ‘./logger.service‘;
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [
{ provide: API_URL, useValue: ‘https://api.example.com‘ },
{ provide: LOGGER, useClass: ConsoleLogger }
],
bootstrap: [AppComponent]
})
export class AppModule {}
// app.component.ts
import { Component, Inject } from ‘@angular/core‘;
import { API_URL, LOGGER, Logger } from ‘./tokens‘;
@Component({
selector: ‘app-root‘,
template: Check the console for logs
})
export class AppComponent {
constructor(
@Inject(API_URL) private apiUrl: string,
@Inject(LOGGER) private logger: Logger
) {
this.logger.log(API URL = ${this.apiUrl});
}
}
This pattern is especially useful when you want to provide different implementations in different environments (dev vs prod) without rewriting your application code.
Building a service the “2026 way”: DI + typed interfaces + AI-assisted stubs
I still teach the basics with ng generate service, but in 2026 projects I pair that with typed interfaces and AI-assisted scaffolding. The goal is the same: define a clean contract and inject it. The difference is that I often generate mock implementations to power tests and local development.
Here’s a realistic example of a User directory service that hits an API and is injected into a component:
// user.models.ts
export interface User {
id: number;
name: string;
country: string;
}
// user.service.ts
import { Injectable } from ‘@angular/core‘;
import { HttpClient } from ‘@angular/common/http‘;
import { Observable, map } from ‘rxjs‘;
import { User } from ‘./user.models‘;
@Injectable({ providedIn: ‘root‘ })
export class UserService {
private readonly baseUrl = ‘http://localhost:8080‘;
constructor(private http: HttpClient) {}
getUsers(): Observable {
return this.http.get(${this.baseUrl}/getusers).pipe(
map(users => users ?? []) // Defensive fallback for unexpected nulls
);
}
}
// app.component.ts
import { Component } from ‘@angular/core‘;
import { UserService } from ‘./user.service‘;
import { User } from ‘./user.models‘;
@Component({
selector: ‘app-root‘,
templateUrl: ‘./app.component.html‘
})
export class AppComponent {
users: User[] = [];
status = false;
constructor(private userService: UserService) {}
loadUsers() {
this.userService.getUsers().subscribe(users => {
this.users = users;
this.status = true;
});
}
}
Name
Country
{{ user.name }}
{{ user.country }}
This is still classic Angular DI, but with stronger typing and a defensive map. If you’re serious about long‑term maintainability, always make your service return typed Observables and keep side effects (like toggling status) inside components.
Scoping strategies: when to provide where
Most DI issues I see are not about syntax — they’re about scope. You should decide scope intentionally:
providedIn: ‘root‘: App-wide singleton. Ideal for stateless services, caching, and shared API access.- Module-level providers: Good for feature modules where you want isolation (especially lazy-loaded modules).
- Component-level providers: Useful when you want a fresh instance per component, such as form state managers.
Here is a quick guide I use when reviewing code:
When to use app-wide providers
- You need a single cache shared across the app.
- You want to keep login state in one place.
- You have app-level configuration values.
When to use module-level providers
- You build a feature that can be lazy-loaded and should remain isolated.
- The service should not be visible outside the feature.
- You want multiple independent instances of the same service in different features.
When to use component-level providers
- The service is tightly coupled to a component’s lifecycle (like a wizard state).
- You need separate instances for each component instance.
- You’re building reusable UI components that should not share state.
If you choose scope intentionally, you avoid subtle bugs such as shared state leaking between components. I’ve seen this happen with modal services more often than I’d like to admit.
Hierarchical injectors in real apps: a concrete scenario
Imagine you’re building an admin dashboard and a public site in the same Angular app. Both need a Logger, but the admin logger should include extra metadata. The simplest solution is to override the logger in the admin module.
// admin-logger.service.ts
import { Injectable } from ‘@angular/core‘;
import { Logger } from ‘../tokens‘;
@Injectable()
export class AdminLogger implements Logger {
log(message: string) {
const time = new Date().toISOString();
console.log([admin][${time}] ${message});
}
}
// admin.module.ts
import { NgModule } from ‘@angular/core‘;
import { CommonModule } from ‘@angular/common‘;
import { AdminComponent } from ‘./admin.component‘;
import { LOGGER } from ‘../tokens‘;
import { AdminLogger } from ‘./admin-logger.service‘;
@NgModule({
declarations: [AdminComponent],
imports: [CommonModule],
providers: [
{ provide: LOGGER, useClass: AdminLogger }
]
})
export class AdminModule {}
Now the admin feature gets a specialized logger, while the rest of the app continues to use the default one. This is a clean, testable pattern that avoids global conditionals.
DI and testing: the fastest way to keep velocity high
If you want faster tests, DI is your best ally. I rarely test services in isolation without providing their dependencies explicitly. The goal is to replace heavy dependencies (HTTP, router, store) with fakes or mocks.
Here’s a complete unit test that uses Angular’s TestBed and a mock service:
// user.service.spec.ts
import { TestBed } from ‘@angular/core/testing‘;
import { HttpClientTestingModule, HttpTestingController } from ‘@angular/common/http/testing‘;
import { UserService } from ‘./user.service‘;
import { User } from ‘./user.models‘;
describe(‘UserService‘, () => {
let service: UserService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
http = TestBed.inject(HttpTestingController);
});
it(‘fetches users from the API‘, () => {
const mockUsers: User[] = [
{ id: 1, name: ‘Ava Patel‘, country: ‘India‘ },
{ id: 2, name: ‘Liam Chen‘, country: ‘Canada‘ }
];
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
expect(users[0].name).toBe(‘Ava Patel‘);
});
const req = http.expectOne(‘http://localhost:8080/getusers‘);
req.flush(mockUsers);
});
});
This test never touches the real network and still verifies behavior accurately. DI makes this possible by letting Angular inject a testing HTTP client instead of the live one.
Common mistakes I still see (and how I avoid them)
Even experienced teams fall into a few DI pitfalls. Here are the big ones I call out during reviews:
1) Multiple instances created accidentally
If you put a provider in a component and forget that it exists in a module too, you can end up with two instances that diverge. The fix is to clearly scope providers and document exceptions.
2) Overusing @Injectable({ providedIn: ‘root‘ })
Root-level singletons are great, but not every service should be global. If the service has component-specific state, keep it scoped to the component or module.
3) Injecting by concrete class when an interface makes more sense
Classes are fine, but for anything that might change implementations — logging, storage, feature flags — use InjectionToken + interface. This makes replacement trivial.
4) Forgetting to provide dependencies for standalone components
Standalone components have their own providers array. If you migrate incrementally, double-check that providers are still reachable.
5) Side effects in service constructors
If your service does work in the constructor (like calling the API), you make tests brittle. Do setup explicitly via methods instead. I only allow constructors to assign dependencies and set up cheap defaults.
The real difference between useClass, useValue, useFactory, and useExisting
Most tutorials list provider types, but the real question is: when do you choose each one in a production app?
useClassis for the default path: create a service instance the Angular way. Use this when a class has dependencies of its own and you want DI to construct it.useValueis for constants, configuration, and tiny stateless helpers. Use it for API URLs, feature flags, or small objects with no dependencies.useFactoryis for dynamic creation, especially when runtime conditions matter. Use it when you need to read environment configuration, user locale, or per-request settings.useExistingis for aliasing. Use it to re-export a service under a different token or to keep legacy injection tokens working after a refactor.
A realistic factory example (runtime config)
This shows how I wire environment-based URLs without hardcoding them in services:
// app-config.ts
import { InjectionToken } from ‘@angular/core‘;
export interface AppConfig {
apiBaseUrl: string;
featureFlags: Record;
}
export const APPCONFIG = new InjectionToken(‘APPCONFIG‘);
export function configFactory(): AppConfig {
const isProd = window.location.hostname !== ‘localhost‘;
return {
apiBaseUrl: isProd ? ‘https://api.example.com‘ : ‘http://localhost:8080‘,
featureFlags: {
newNav: !isProd
}
};
}
// app.module.ts
import { NgModule } from ‘@angular/core‘;
import { BrowserModule } from ‘@angular/platform-browser‘;
import { AppComponent } from ‘./app.component‘;
import { APP_CONFIG, configFactory } from ‘./app-config‘;
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [
{ provide: APP_CONFIG, useFactory: configFactory }
],
bootstrap: [AppComponent]
})
export class AppModule {}
The key insight is that DI becomes your configuration layer. It keeps config separate from business logic and makes changes safer.
Advanced tokens: when InjectionToken beats classes
I use classes as tokens for most services because it’s straightforward. But when you want multiple implementations for the same interface, a class token is limiting. InjectionToken solves that with explicit typing and names.
Example: local storage vs session storage
The goal is to abstract storage so you can swap it for tests or different environments:
// storage.tokens.ts
import { InjectionToken } from ‘@angular/core‘;
export interface StorageDriver {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
export const STORAGE = new InjectionToken(‘STORAGE‘);
// storage.providers.ts
import { STORAGE, StorageDriver } from ‘./storage.tokens‘;
export const LocalStorageProvider = {
provide: STORAGE,
useValue: {
get: (k: string) => localStorage.getItem(k),
set: (k: string, v: string) => localStorage.setItem(k, v),
remove: (k: string) => localStorage.removeItem(k)
} as StorageDriver
};
// app.module.ts
import { LocalStorageProvider } from ‘./storage.providers‘;
providers: [LocalStorageProvider]
The interface keeps your application logic stable, even if the storage backend changes later.
Optional dependencies and defensive injection
Sometimes a dependency might or might not exist. You could force it to exist, but that makes modularity harder. Angular gives you two tools that I use often:
@Optional()tells Angular to injectnullif the token isn’t found.@Self()tells Angular to only search the current injector, not parents.
Here’s a pattern I use for optional logging:
import { Injectable, Optional, Inject } from ‘@angular/core‘;
import { LOGGER, Logger } from ‘./tokens‘;
@Injectable()
export class AuditService {
constructor(@Optional() @Inject(LOGGER) private logger?: Logger) {}
track(event: string) {
if (this.logger) {
this.logger.log([audit] ${event});
}
}
}
This keeps the service flexible while still supporting richer behavior when a logger is provided.
The standalone component era: DI without NgModules
Standalone components changed how I structure DI. The key is that standalone components can own their providers directly, which makes feature isolation simpler but also easier to break if you forget to include required providers.
Here’s a minimal standalone example:
import { Component, inject } from ‘@angular/core‘;
import { HttpClient, provideHttpClient } from ‘@angular/common/http‘;
import { bootstrapApplication } from ‘@angular/platform-browser‘;
@Component({
selector: ‘app-root‘,
standalone: true,
providers: [provideHttpClient()],
template: Standalone app
})
export class AppComponent {
private http = inject(HttpClient);
}
bootstrapApplication(AppComponent);
My rule for standalone providers
If the provider is truly app‑wide, I put it in the bootstrapApplication call. If it’s feature‑specific, I put it in the feature’s standalone component or route config. That keeps provider scope obvious and prevents accidental duplication.
DI and performance: why provider scope changes load behavior
People underestimate how provider scope affects performance. It’s not only about memory — it’s about how much work happens at startup and how often services get created.
- App‑wide singleton: created once and reused. Good for expensive initialization (like loading configuration or establishing a WebSocket).
- Feature‑level provider: created when that feature loads. This can reduce initial load but may add work during navigation.
- Component‑level provider: created each time the component renders. Good for isolation, but can be expensive if the service does heavy setup.
Practical performance guidance
- If a service creates caches or in-memory indexes, prefer root or feature scope.
- If a service is lightweight and tied to UI state, component scope is fine.
- If a service triggers background tasks on creation, never provide it at component scope.
I’ve seen measurable improvements in startup time (in the “noticeably faster” range) just by moving heavyweight services out of component providers and into lazy-loaded feature modules.
DI with HttpClient: interceptors and multi providers
DI becomes even more powerful when you use multi providers, which let you register multiple implementations for a single token. Interceptors are the classic example:
import { HTTP_INTERCEPTORS, HttpInterceptor, HttpRequest, HttpHandler } from ‘@angular/common/http‘;
import { Injectable } from ‘@angular/core‘;
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest, next: HttpHandler) {
const authReq = req.clone({
setHeaders: { Authorization: ‘Bearer token‘ }
});
return next.handle(authReq);
}
}
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
The multi: true flag tells Angular to collect multiple providers into an array. This pattern isn’t just for interceptors. You can use it for validators, plugins, or feature extensions that need to compose.
Avoiding circular dependencies: the silent DI killer
Circular dependencies often appear after teams grow. One service depends on another, and the second depends on the first. With DI, Angular can sometimes resolve cycles, but you’re still left with brittle architecture.
My prevention checklist
- Avoid services that handle both data and UI concerns. Split them.
- Use interfaces and tokens for cross‑feature dependencies.
- Move shared utilities into stateless functions or helper classes.
- If two services need each other, create a third service that represents their shared responsibility.
A practical refactor example
If UserService depends on AuditService, and AuditService depends on UserService for user context, that’s a cycle. The fix is to create UserContextService that holds just the context. Both services depend on it, and the cycle disappears.
DI and state management: where services end and stores begin
In 2026 apps, teams are split between service-driven state and store-driven state (NgRx, Signals, or custom stores). DI still matters, because stores are injected too. The question is where you draw the line.
My rule: if state needs to be shared across many unrelated components, put it in a store. If state is tied to a feature workflow and only a few components care, a service with DI is enough.
A hybrid pattern I use
- Store for global state (auth, theme, routing).
- Service for feature workflows (signup wizard, onboarding steps).
- Keep services thin: they orchestrate, not store long‑lived data.
This keeps DI useful without bloating services into “god objects.”
Practical scenario: feature flags with DI
Feature flags are a perfect DI use case. You can provide one implementation in production and another in development, without touching the components.
// feature-flags.ts
import { InjectionToken } from ‘@angular/core‘;
export interface FeatureFlags {
isEnabled(key: string): boolean;
}
export const FEATUREFLAGS = new InjectionToken(‘FEATUREFLAGS‘);
// feature-flags.providers.ts
import { FEATURE_FLAGS, FeatureFlags } from ‘./feature-flags‘;
export const DevFeatureFlagsProvider = {
provide: FEATURE_FLAGS,
useValue: {
isEnabled: (key: string) => key === ‘newNav‘
} as FeatureFlags
};
// app.component.ts
import { Component, Inject } from ‘@angular/core‘;
import { FEATURE_FLAGS, FeatureFlags } from ‘./feature-flags‘;
@Component({
selector: ‘app-root‘,
template: New Nav Enabled
})
export class AppComponent {
constructor(@Inject(FEATURE_FLAGS) public flags: FeatureFlags) {}
}
Feature flags become a dependency, not a global variable. That keeps your UI clean and your tests easy to configure.
DI in lazy-loaded modules: the subtle isolation trap
Lazy-loaded modules have their own injector. That’s great for isolation, but it can surprise you if you expect a root singleton. If you provide a service in a lazy module, you’re creating a new instance when that module loads.
The rule I enforce
- If you want a single instance for the whole app, provide it in root.
- If you want a per-lazy-module instance, provide it in the module.
- If you want per-component instances, provide it in the component.
Example: per-module caching
If you want a cache that’s isolated per feature (like a report module), put the cache provider in that module. You get a fresh cache each time that module is loaded, and you avoid cross-feature contamination.
Patterns for designing reusable services
A reusable service isn’t just “a service you can inject.” It’s a service with a stable contract and minimal assumptions about context. Here’s how I build them:
1) Define interfaces for anything that may change (logging, storage, environment).
2) Inject collaborators rather than importing them directly.
3) Avoid global state inside the service.
4) Keep side effects explicit via methods.
5) Expose Observables for async data, not raw promises.
Example: reusable analytics service
// analytics.tokens.ts
import { InjectionToken } from ‘@angular/core‘;
export interface AnalyticsSink {
track(event: string, payload?: Record): void;
}
export const ANALYTICSSINK = new InjectionToken(‘ANALYTICSSINK‘);
// analytics.service.ts
import { Injectable, Inject } from ‘@angular/core‘;
import { ANALYTICS_SINK, AnalyticsSink } from ‘./analytics.tokens‘;
@Injectable({ providedIn: ‘root‘ })
export class AnalyticsService {
constructor(@Inject(ANALYTICS_SINK) private sink: AnalyticsSink) {}
track(event: string, payload?: Record) {
this.sink.track(event, payload);
}
}
The analytics service is now independent of the actual analytics backend. You can switch vendors or stub it in tests without touching the core code.
Debugging DI: how I find the real cause fast
When DI errors show up, the exception messages are usually correct but not always helpful. Here’s my step‑by‑step debugging flow:
1) Read the token in the error. Angular tells you exactly what it couldn’t resolve.
2) Search for the provider. Is it defined? Is it in the right module or component?
3) Check scope. Is the provider defined in a lazy module but being requested outside it?
4) Verify injection token. If you used InjectionToken, make sure you injected the same instance, not a new one.
5) Confirm standalone providers. With standalone components, missing provider imports are common.
Quick trick: log the injector tree
If you have a component injection issue, I sometimes temporarily add a simple console.log in the constructor to see whether I’m getting the expected instance. Then I trace where that provider is coming from.
DI and SSR: avoid server‑side pitfalls
Server-side rendering adds one more dimension: your services might be created on the server for each request. That means they must be stateless or request‑scoped.
SSR‑safe service rules
- Do not rely on browser globals (
window,document) in root services. - If you need browser-specific behavior, use
isPlatformBrowserand provide a server-friendly fallback. - Avoid long‑lived caches in services that run on the server, unless they are safe to share across requests.
DI helps here by letting you provide different implementations on server vs client. If you think in tokens and providers, SSR becomes much less scary.
Modern workflows: AI-assisted DI scaffolding without chaos
In 2026, I often use AI to scaffold services, mocks, and test doubles. But the key is to keep the DI design intentional. I’ll ask for a service interface, a default provider, and a test mock. Then I review for scope and tokens.
My workflow pattern
- Define
interfaceortypefirst. - Create
InjectionTokennext. - Provide a default implementation.
- Add a mock implementation for tests.
- Wire it in
TestBedusinguseValue.
This workflow makes DI consistent across the team, and it reduces the “but which instance are we using?” confusion that often crops up during refactors.
A comparison table I use to teach DI decisions
Here’s a quick “traditional vs modern” comparison I share with new team members:
- Traditional: Services create dependencies internally. Modern: Dependencies injected via tokens.
- Traditional: One global singleton. Modern: Scoped providers based on feature and lifecycle.
- Traditional: Hardcoded URLs and config. Modern: Config injected via factories.
- Traditional: Difficult to test. Modern: Swappable mocks and fakes.
- Traditional: Hidden coupling. Modern: Explicit dependency graph.
It’s not about fashion. It’s about making the codebase easier to change under pressure.
Edge cases you should anticipate
DI is powerful, but it can surprise you if you don’t plan for edge cases:
1) Duplicate providers from shared modules
If a shared module provides a service and is imported in multiple lazy modules, you may get multiple instances. Fix by moving the provider to root or a core module imported once.
2) Token collisions with string tokens
String tokens are easy to collide across modules. Use InjectionToken to keep names unique and typed.
3) Injection in base classes
If you use inheritance and inject in a base class constructor, remember that Angular only injects parameters in the derived class. You might need @Inject in the base or to pass dependencies down manually.
4) Provider ordering with multi providers
Multi providers preserve order based on registration. If you rely on order (like interceptors), document it and add tests.
5) Tree‑shakable providers and dead code
providedIn: ‘root‘ can enable tree shaking, but only if the service is unused. If you accidentally import the service in a side-effectful way, you can prevent tree shaking and bloat your bundle.
Practical scenario: isolating a wizard state per component
A classic case for component-level providers is a multi‑step wizard. Each instance should have its own state, even if multiple wizards are open.
// wizard-state.service.ts
import { Injectable } from ‘@angular/core‘;
@Injectable()
export class WizardStateService {
step = 1;
data: Record = {};
next() {
this.step += 1;
}
update(partial: Record) {
this.data = { ...this.data, ...partial };
}
}
// wizard.component.ts
import { Component } from ‘@angular/core‘;
import { WizardStateService } from ‘./wizard-state.service‘;
@Component({
selector: ‘app-wizard‘,
template: Step {{ state.step }}
,
providers: [WizardStateService]
})
export class WizardComponent {
constructor(public state: WizardStateService) {}
}
Each wizard gets its own state, which is exactly what you want.
Practical scenario: shared cache with root provider
Now contrast that with a cache service:
@Injectable({ providedIn: ‘root‘ })
export class CacheService {
private store = new Map();
get(key: string): T | undefined {
return this.store.get(key) as T | undefined;
}
set(key: string, value: T) {
this.store.set(key, value);
}
}
Because the cache should be shared across the app, providedIn: ‘root‘ is correct. If you accidentally provided it at component scope, every component would see its own cache and you’d lose the benefits.
DI in routing: per-route providers for cleaner isolation
Modern Angular lets you attach providers to routes. This is a clean way to scope services to a feature without creating extra modules.
// app.routes.ts
import { Routes } from ‘@angular/router‘;
import { provideHttpClient } from ‘@angular/common/http‘;
import { ReportsComponent } from ‘./reports.component‘;
export const routes: Routes = [
{
path: ‘reports‘,
component: ReportsComponent,
providers: [provideHttpClient()]
}
];
Route providers give you feature isolation without module ceremony, and I expect teams to use this more and more in 2026.
Alternatives to DI (and why they usually lose)
Occasionally someone suggests skipping DI and just importing a singleton or using a global module. It feels simpler at first, but it’s a trade you pay for later.
- Direct imports: easy, but hard to test and impossible to swap.
- Global singletons: fast to create, but often leak state and are hard to reset in tests.
- Service locators: can centralize access, but hide dependencies and hurt clarity.
DI keeps dependencies explicit and testable. In an app with more than a few developers, that clarity is worth the small upfront cost.
Practical guardrails I use in code reviews
When I review DI in a codebase, I look for these:
- Providers are scoped intentionally, not by accident.
- Services don’t do heavy work in constructors.
- Interfaces and tokens are used for swappable dependencies.
- Root services are stateless or safe to share.
- Lazy modules don’t accidentally re‑provide core services.
If those pass, the DI design is usually solid.
How DI interacts with Signals and the next Angular wave
Signals introduce a new way to manage state, but they don’t replace DI. I think of DI as “how you get the tools” and signals as “how you store and react to state.” You’ll still inject services that expose signals, and you’ll still choose where those services are provided.
My recommendation: keep DI patterns stable and use signals inside services or components as needed. DI is your foundation; signals are a layer on top.
A complete, realistic DI setup (mini app blueprint)
Here’s a stripped-down blueprint that shows how I wire DI in a medium‑size app:
1) Tokens for configuration and optional services.
2) Root providers for shared services.
3) Feature providers for isolated functionality.
4) Component providers for UI state.
Tokens
export const APIURL = new InjectionToken(‘APIURL‘);
export const LOGGER = new InjectionToken(‘LOGGER‘);
Root providers
providers: [
{ provide: API_URL, useValue: ‘https://api.example.com‘ },
{ provide: LOGGER, useClass: ConsoleLogger }
]
Feature provider (reports)
providers: [
{ provide: REPORTS_CACHE, useClass: ReportsCacheService }
]
Component provider (wizard)
providers: [WizardStateService]
This layered approach keeps DI predictable. Anyone joining the team can quickly tell where an instance is coming from.
Production considerations: monitoring and runtime overrides
In real deployments, DI isn’t just architecture; it’s operations. I’ve used DI to:
- Inject different loggers for dev vs prod.
- Swap analytics implementations for privacy‑sensitive regions.
- Override API URLs at runtime without rebuilding.
- Provide mock services in preview environments.
Because DI is configuration-driven, these changes don’t require code forks. You can control them by environment and keep the same codebase.
The “when not to use DI” checklist
DI is powerful, but not everything needs it. I avoid DI when:
- A helper function is pure and stateless (just export it).
- A utility is used in exactly one place (keep it local).
- The dependency is trivial and unlikely to change.
Using DI everywhere adds overhead. The goal is to use it where it actually buys you flexibility and testability.
Summary: the mental model I want you to keep
- DI is a dependency wiring system, not just a syntax feature.
- Tokens are the keys, providers are the recipes, injectors are the pantry shelves.
- Scope is your most important decision: root, feature, or component.
- DI makes testing fast by letting you swap dependencies easily.
- The best DI setups are the ones your future teammates can understand in five minutes.
If you take one thing away, let it be this: Angular DI is not just how you get services — it’s how you design a system that stays flexible as it grows. Use it with intention, and it becomes one of the strongest advantages in the Angular ecosystem.


