I’ve been the person on call at 2 a.m. when an Angular app starts failing because a service suddenly can’t talk to an API, and the root cause is hidden in how dependencies were wired. When dependency injection (DI) is clear and intentional, Angular apps are stable, testable, and fast to change. When DI is sloppy, it’s the opposite: tight coupling, brittle tests, and a maze of object creation. If you want an Angular codebase that scales with your team, DI is the foundation you can’t ignore.
In this post, I’ll show you how Angular’s DI system actually works, how I set up providers in real projects, and what patterns I use to keep dependencies clean and easy to test. You’ll see runnable examples, learn how to scope services properly, and understand why the injector hierarchy is one of Angular’s biggest strengths. I’ll also point out mistakes I see in code reviews and how to avoid them, plus a modern 2026 workflow that includes AI-assisted refactors without breaking DI boundaries.
Why DI matters to your Angular app
Angular is built around the idea that components shouldn’t create their own dependencies. If a component reaches out and manually instantiates a service, it becomes tightly coupled to that service. This makes it harder to test, harder to replace implementations, and harder to reason about the app when you have dozens of features.
With DI, Angular does the object creation for you. You declare what you need in the constructor, and Angular provides it. That shift sounds small, but it changes everything: you can swap in mock services in tests, replace implementations without changing consumer code, and control scope in a predictable way.
I like to explain DI using a kitchen analogy. A chef shouldn’t be building an oven every time they cook; they should expect a working oven to be available. DI is the kitchen manager: it decides which ovens exist, how they’re shared, and who gets access. Angular’s injector is that manager, and providers are the rules the manager follows.
How Angular’s injector really works
Angular’s DI system is hierarchical. Every component has its own injector that inherits from its parent, and at the top of the tree sits the root injector. This hierarchy gives you control over scope: put a provider at the root to make it a singleton across the app, or provide it at a component level to isolate it to a specific feature.
When Angular creates a component, it:
1) examines the constructor parameters,
2) finds tokens (classes or injection tokens),
3) looks up those tokens in the injector tree,
4) creates or reuses instances as needed.
If you’ve ever seen an error like “NullInjectorError: No provider for X,” that means Angular climbed the injector tree and didn’t find a provider for the token it needed. The fix is always about registering the provider in the right place.
The three main injector scopes I use
- Root scope: app-wide singletons. Use
providedIn: ‘root‘or providers inbootstrapApplicationorAppModule. - Feature scope: service instance per feature module or standalone route. Use providers in a route configuration or a feature module.
- Component scope: instance per component tree. Use the
providersarray in a component decorator.
When I want shared state across multiple routes, I default to root. When I want isolation or multiple instances, I keep it close to the component.
Providers: the rules of the DI system
Providers tell Angular how to create a dependency. In practice, that means you define a token and a provider configuration. The simplest provider is a class itself, but Angular supports several provider shapes.
Class provider (most common)
import { Injectable } from ‘@angular/core‘;
@Injectable({ providedIn: ‘root‘ })
export class AuditLogService {
log(message: string) {
console.log([audit] ${message});
}
}
Here, the token is AuditLogService and Angular knows to instantiate it with new when needed.
useClass: swap implementations
import { Injectable } from ‘@angular/core‘;
export abstract class StorageService {
abstract get(key: string): string | null;
abstract set(key: string, value: string): void;
}
@Injectable()
export class BrowserStorageService extends StorageService {
get(key: string) { return localStorage.getItem(key); }
set(key: string, value: string) { localStorage.setItem(key, value); }
}
@Injectable()
export class MemoryStorageService extends StorageService {
private store = new Map();
get(key: string) { return this.store.get(key) ?? null; }
set(key: string, value: string) { this.store.set(key, value); }
}
providers: [
{ provide: StorageService, useClass: BrowserStorageService }
]
I use this pattern to swap implementations per environment or per test.
useValue: constants and configuration
import { InjectionToken } from ‘@angular/core‘;
export const APIBASEURL = new InjectionToken(‘APIBASEURL‘);
providers: [
{ provide: APIBASEURL, useValue: ‘https://api.example.com‘ }
]
Use useValue for config, feature flags, and frozen settings. It’s simple and reliable.
useFactory: dynamic creation
import { inject } from ‘@angular/core‘;
export function authClientFactory() {
const baseUrl = inject(APIBASEURL);
return new AuthClient(baseUrl);
}
providers: [
{ provide: AuthClient, useFactory: authClientFactory }
]
Factories are perfect when you need to build something with logic or when you want to combine multiple dependencies.
useExisting: aliasing tokens
providers: [
{ provide: StorageService, useExisting: BrowserStorageService }
]
This keeps multiple tokens pointing to the same instance, which is helpful during migrations.
Injection tokens: the keys to the system
Angular uses tokens to identify what you want to inject. For class-based services, the class itself is a token. For primitive values or interfaces, you need an InjectionToken.
Here’s a clean configuration pattern I use in production:
import { InjectionToken } from ‘@angular/core‘;
export interface AppConfig {
featureFlags: { realtimeCharts: boolean; };
apiBaseUrl: string;
}
export const APPCONFIG = new InjectionToken(‘APPCONFIG‘);
providers: [
{ provide: APP_CONFIG, useValue: {
featureFlags: { realtimeCharts: true },
apiBaseUrl: ‘https://api.example.com‘
}
}
]
import { Inject, Injectable } from ‘@angular/core‘;
@Injectable({ providedIn: ‘root‘ })
export class FeatureFlagService {
constructor(@Inject(APP_CONFIG) private config: AppConfig) {}
get realtimeChartsEnabled() { return this.config.featureFlags.realtimeCharts; }
}
I avoid raw strings as tokens because they’re easy to misspell and hard to refactor. InjectionToken gives you type safety and a single source of truth.
A complete, runnable example
Below is a full, minimal example you can run in a standard Angular setup. It shows a service that fetches users, injected into a component. I’ve kept it simple but realistic.
users.service.ts
import { Injectable } from ‘@angular/core‘;
import { HttpClient } from ‘@angular/common/http‘;
import { map } from ‘rxjs/operators‘;
import { Observable } from ‘rxjs‘;
export interface UserDto {
id: string;
name: string;
country: string;
}
@Injectable({ providedIn: ‘root‘ })
export class UsersService {
private baseUrl = ‘http://localhost:8080/‘;
constructor(private http: HttpClient) {}
getUsers(): Observable {
return this.http.get(${this.baseUrl}getusers).pipe(
map((response) => response ?? [])
);
}
}
app.component.ts
import { Component } from ‘@angular/core‘;
import { UsersService, UserDto } from ‘./users.service‘;
@Component({
selector: ‘app-root‘,
templateUrl: ‘./app.component.html‘
})
export class AppComponent {
users: UserDto[] = [];
loading = false;
error: string | null = null;
constructor(private usersService: UsersService) {}
loadUsers() {
this.loading = true;
this.error = null;
this.usersService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.loading = false;
},
error: () => {
this.error = ‘Failed to load users‘;
this.loading = false;
}
});
}
}
app.component.html
Loading...
{{ error }}
0">
Name
Country
{{ user.name }}
{{ user.country }}
That example looks simple, but the DI system is doing the heavy lifting: it creates UsersService and injects HttpClient into it. You only need to ensure HttpClientModule is imported once at the root.
Hierarchical DI: scoping dependencies with intent
I’ve seen teams accidentally create multiple instances of a service by putting it in both @Injectable({ providedIn: ‘root‘ }) and a component providers array. Angular will prefer the closest provider, so you end up with shadow instances that don’t share state.
Here’s how I decide where to provide a service:
- Global state or shared utility: root provider. Example: feature flags, analytics logger, authentication state.
- Per-feature state: route-level provider. Example: a wizard flow that should reset when you leave the route.
- Per-component state: component providers. Example: a complex table that should manage its own pagination state independently.
Example: route-level provider
import { Routes } from ‘@angular/router‘;
import { OrdersComponent } from ‘./orders.component‘;
import { OrdersStore } from ‘./orders.store‘;
export const routes: Routes = [
{
path: ‘orders‘,
component: OrdersComponent,
providers: [OrdersStore]
}
];
Now every time the orders route is activated, it gets a new OrdersStore, and the state is scoped to that feature. I find this pattern clean and predictable in modern Angular.
Common mistakes I see in DI code reviews
These show up again and again, and each one can lead to hard-to-debug behavior.
1) Injecting concrete classes when an abstraction is better
If you have a PaymentService that is specific to Stripe or PayPal, inject an abstract base or interface via token and choose the concrete class at the provider level. That gives you freedom to swap providers without rewriting consumers.
2) Putting data access in components
Components should be about presentation and orchestration, not data access. A component that calls HttpClient directly is almost always a code smell. You’re mixing network logic with UI logic. Extract that to a service.
3) Over-scoping services
I see services put in a component providers array when they should be root singletons. The result: multiple instances, duplicate network calls, and inconsistent state.
4) Creating new instances manually
If you see new SomeService() inside Angular code, that’s a warning sign. You’re bypassing DI, which means you also bypass Angular’s lifecycle and any dependencies that service might need.
5) Forgetting to provide a token
If you’re using InjectionToken, register it once in the right injector. If you don’t, Angular won’t be able to resolve it, and you’ll see an injector error.
When to use DI — and when not to
I’m a strong advocate of DI, but I don’t use it for everything.
Use DI when:
- A class depends on external resources (HTTP, storage, logging, feature flags).
- You want to mock or swap the implementation in tests.
- You need to share state across multiple consumers.
- You want to scope lifetimes cleanly.
Avoid DI when:
- A value is purely local and doesn’t need to be shared or mocked.
- You can use a simple pure function.
- You’re building a tiny component with no external dependencies.
DI is a tool. If you apply it everywhere, you create extra indirection. I use it where it adds clarity, not where it adds ceremony.
Performance considerations in DI-heavy apps
DI itself is fast, but patterns around it can have performance impact.
- Provider trees: Large component-level provider trees can increase creation time for each component instance. In practice, I see small but measurable differences, typically in the 10–15ms range during initial render on mid-tier devices when providers are overused.
- Factory providers: If you use a heavy
useFactory, it will run each time a new provider instance is created. Cache wisely or scope appropriately. - Eager services: Services created at root are instantiated lazily by default, but if you inject them in the app root component, they become eager. Keep expensive services off root injection unless needed.
If performance is tight, I use Angular’s profiler and component tree analysis to locate hotspots, then adjust provider scopes to reduce unnecessary instantiation.
Testing with DI: my preferred patterns
DI makes testing clean and predictable. I almost always do two things: use TestBed and override providers when needed.
Example: swap a service for a mock
import { TestBed } from ‘@angular/core/testing‘;
import { UsersService } from ‘./users.service‘;
import { AppComponent } from ‘./app.component‘;
import { of } from ‘rxjs‘;
class MockUsersService {
getUsers() {
return of([{ id: ‘1‘, name: ‘Ava Patel‘, country: ‘India‘ }]);
}
}
describe(‘AppComponent‘, () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
providers: [
{ provide: UsersService, useClass: MockUsersService }
]
});
});
it(‘loads users from the service‘, () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.componentInstance.loadUsers();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain(‘Ava Patel‘);
});
});
This is where DI shines: I can replace the real service with a test double without touching component code.
Traditional vs modern Angular DI workflows
Here’s how I explain the shift to teams moving into 2026 tooling and practices.
Traditional approach
—
providers arrays in modules
providedIn Hardcoded values
InjectionToken configs Manual mocks and spies
Root-only services
Manual updates
AI tooling in 2026 can refactor services and update provider trees quickly, but I still keep a tight loop of human review. DI boundaries are architectural boundaries, and automation needs guardrails to respect them.
Constructor injection vs inject() in 2026 Angular
Constructor injection is still the default and the most readable option. But the inject() function gives you flexibility, especially in factories, standalone functions, or composable utilities. I treat them as complementary tools.
Constructor injection (default)
@Injectable({ providedIn: ‘root‘ })
export class OrdersService {
constructor(private http: HttpClient, private auth: AuthService) {}
}
inject() inside a standalone function
import { inject } from ‘@angular/core‘;
export function createOrdersClient() {
const http = inject(HttpClient);
const auth = inject(AuthService);
return new OrdersClient(http, auth.token);
}
When I choose inject()
- Factories (
useFactory) and dynamic providers. - Utility functions that need a dependency but shouldn’t be classes.
- Rare cases where a function is easier to test than a class.
When I avoid inject()
- Regular services: constructor injection is cleaner and clearer.
- Anything that would hide dependencies, making the API less explicit.
Multi-provider tokens: plugin-style DI
Multi-providers let you attach multiple values to the same token. I use them for things like menu extensions, analytics hooks, or feature contributions from different modules.
Example: multi-provider for plugins
import { InjectionToken } from ‘@angular/core‘;
export interface Exporter {
id: string;
label: string;
export: () => Promise;
}
export const EXPORTERS = new InjectionToken(‘EXPORTERS‘);
providers: [
{ provide: EXPORTERS, useValue: { id: ‘csv‘, label: ‘CSV‘, export: () => Promise.resolve() }, multi: true },
{ provide: EXPORTERS, useValue: { id: ‘xlsx‘, label: ‘Excel‘, export: () => Promise.resolve() }, multi: true }
]
import { Inject, Injectable } from ‘@angular/core‘;
@Injectable({ providedIn: ‘root‘ })
export class ExportService {
constructor(@Inject(EXPORTERS) private exporters: Exporter[]) {}
list() { return this.exporters; }
}
This pattern keeps plugin-style features clean without resorting to manual registries.
Scoped providers in standalone route configs
Route-level providers are one of my favorite tools for state isolation. They let you create a service per route, and Angular will dispose it when you navigate away.
Example: a wizard that resets on exit
import { Routes } from ‘@angular/router‘;
export const routes: Routes = [
{
path: ‘onboarding‘,
loadComponent: () => import(‘./onboarding.component‘).then(m => m.OnboardingComponent),
providers: [OnboardingStateService]
}
];
This keeps wizard state local and avoids tricky “stale state” bugs when a user revisits the flow.
Advanced provider patterns I rely on
These are patterns that consistently save time in larger codebases.
Pattern 1: A single config token + environment mapping
export const APPENV = new InjectionToken<'dev' ‘staging‘ ‘prod‘>(‘APPENV‘);
providers: [
{ provide: APP_ENV, useValue: ‘prod‘ },
{
provide: APP_CONFIG,
useFactory: () => {
const env = inject(APP_ENV);
return env === ‘prod‘
? { apiBaseUrl: ‘https://api.example.com‘, featureFlags: { realtimeCharts: true } }
: { apiBaseUrl: ‘https://staging-api.example.com‘, featureFlags: { realtimeCharts: false } };
}
}
]
This keeps environment logic centralized and avoids scattering conditionals through the app.
Pattern 2: A facade that wraps multiple services
@Injectable({ providedIn: ‘root‘ })
export class CheckoutFacade {
constructor(
private cart: CartService,
private payments: PaymentService,
private analytics: AnalyticsService
) {}
placeOrder() {
this.analytics.track(‘checkout:start‘);
return this.payments.charge(this.cart.total());
}
}
Facades simplify component code and make it easy to test complex flows with a single mock.
Pattern 3: Lazy optional dependencies
import { Optional } from ‘@angular/core‘;
@Injectable({ providedIn: ‘root‘ })
export class AuditTrailService {
constructor(@Optional() private analytics?: AnalyticsService) {}
record(event: string) {
if (this.analytics) {
this.analytics.track(event);
}
}
}
This is perfect for optional integrations that are only enabled in some builds.
Edge cases: where DI breaks down and how I fix it
Real projects always find the edge cases. Here are the ones I run into most often.
Edge case 1: Circular dependency chains
If ServiceA depends on ServiceB and ServiceB depends on ServiceA, Angular will throw a circular dependency error. The fix is usually to move shared logic into a third service or use a facade to break the cycle.
Edge case 2: Token collisions
If multiple libraries register the same string token, they will collide. I avoid this entirely by using InjectionToken and exporting it from a single module or file.
Edge case 3: Static instantiation in constants
If you see const client = new HttpClient(...) outside of Angular, it won’t be managed by DI. Move instantiation into a provider factory and let Angular handle it.
Edge case 4: Providers that depend on async values
Providers are created synchronously. If you need async config (for example, reading from remote), you can:
- use an
APP_INITIALIZERto load config before bootstrap, or - design the service to lazy-load data when first called.
Edge case 5: Multiple copies of a library
If a dependency is bundled twice, you can get duplicate injection tokens. This is a build issue, not a DI issue, but it shows up as DI errors. Keep your package versions aligned.
Practical scenario: a real API client with DI
Here’s a deeper example I use to teach teams how to structure a real client with DI, configuration, and testing in mind.
tokens.ts
import { InjectionToken } from ‘@angular/core‘;
export interface ApiConfig {
baseUrl: string;
timeoutMs: number;
}
export const APICONFIG = new InjectionToken(‘APICONFIG‘);
api-client.ts
import { Injectable, Inject } from ‘@angular/core‘;
import { HttpClient } from ‘@angular/common/http‘;
import { API_CONFIG, ApiConfig } from ‘./tokens‘;
import { Observable } from ‘rxjs‘;
@Injectable()
export class ApiClient {
constructor(
private http: HttpClient,
@Inject(API_CONFIG) private config: ApiConfig
) {}
get(path: string): Observable {
return this.http.get(${this.config.baseUrl}${path});
}
}
app.config.ts (standalone bootstrap)
import { ApplicationConfig } from ‘@angular/core‘;
import { provideHttpClient } from ‘@angular/common/http‘;
import { API_CONFIG } from ‘./tokens‘;
import { ApiClient } from ‘./api-client‘;
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{ provide: API_CONFIG, useValue: { baseUrl: ‘https://api.example.com‘, timeoutMs: 5000 } },
ApiClient
]
};
feature.service.ts
@Injectable({ providedIn: ‘root‘ })
export class OrdersService {
constructor(private api: ApiClient) {}
listOrders() {
return this.api.get(‘/orders‘);
}
}
In this setup:
API_CONFIGis typed and centralized.ApiClientis a normal injectable service.OrdersServicedepends onApiClient, which depends on config.- You can easily swap
API_CONFIGin tests.
Practical scenario: using useFactory for feature flags
Feature flags are a classic DI use case. I like to treat them as configuration rather than business logic.
export const FEATUREFLAGS = new InjectionToken<Record>(‘FEATUREFLAGS‘);
export function featureFlagsFactory() {
const config = inject(APP_CONFIG);
return config.featureFlags;
}
providers: [
{ provide: FEATURE_FLAGS, useFactory: featureFlagsFactory }
]
This separates where flags live (config) from how they’re used (injected token).
Error diagnosis: how I debug DI issues fast
When DI fails, the error messages can look scary. Here’s my quick triage checklist:
1) Look at the error chain: Angular usually tells you which provider was missing and which service tried to use it.
2) Find the token: Search for the token class or injection token in the codebase.
3) Trace provider registration: Ensure the token is provided in the right injector level.
4) Check for duplicates: Make sure the same token isn’t registered in multiple scopes unless that is intentional.
5) Confirm module imports: Missing providers often come from missing modules (for example, HttpClientModule).
If you do this consistently, DI errors go from scary to routine.
DI and state management: when to keep state in services
Angular doesn’t mandate a single state pattern, but DI makes it easy to store state in services. I use this when state needs to be shared across components without a full state library.
Example: small state store
@Injectable({ providedIn: ‘root‘ })
export class CartStore {
private items: CartItem[] = [];
add(item: CartItem) { this.items.push(item); }
remove(id: string) { this.items = this.items.filter(i => i.id !== id); }
list() { return [...this.items]; }
}
This is simple, but note the scope: providedIn: ‘root‘ means one cart per app session. If you want multiple carts (for example, in a multi-tenant admin view), move it to a route provider.
DI with HTTP interceptors: a powerful use case
HTTP interceptors are a standard DI feature that shows the system’s flexibility. They use multi-provider tokens under the hood.
Example: auth header interceptor
import { Injectable } from ‘@angular/core‘;
import { HttpInterceptor, HttpRequest, HttpHandler } from ‘@angular/common/http‘;
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest, next: HttpHandler) {
const token = localStorage.getItem(‘token‘);
const authReq = token ? req.clone({ setHeaders: { Authorization: Bearer ${token} } }) : req;
return next.handle(authReq);
}
}
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
This is a great example of DI in action: the token is a multi-provider, the implementation is swappable, and every HTTP request goes through the chain.
DI in libraries: how to keep public APIs stable
If you build Angular libraries, DI is part of your public API. That means tokens and provider shapes should be stable across versions.
Here’s how I keep it clean:
- Export all tokens from a single
tokens.tsso consumers have one import path. - Avoid breaking changes in token names or types.
- Prefer
InjectionTokenwith a descriptive name rather than string tokens. - Document which tokens are intended for override.
A stable DI API is as important as a stable component API.
Alternative approaches: when DI is not the best tool
DI is powerful, but it’s not always the best solution for every problem.
Alternative 1: Pure functions for business logic
If a function has no external dependencies, keep it simple and pure. It’s easier to test and easier to reuse.
Alternative 2: Local component state
If state is used in only one component and has no external side effects, keep it local. Don’t create a service just to hold a single boolean.
Alternative 3: Explicit factory functions
If you need a quick instance that doesn’t belong to Angular’s lifecycle, a plain factory function might be more appropriate.
I think of DI as a default, but not a requirement. The best code is often the simplest code that can still evolve.
Modern 2026 DI workflow: AI-assisted refactors with guardrails
In 2026, most teams I work with use AI tools to refactor services, extract providers, and optimize injector trees. It’s powerful, but it needs rules.
Here’s the workflow I recommend:
1) Define DI boundaries explicitly: Make sure tokens and providers are centralized before refactoring.
2) Automate the changes: Use AI to update constructors, provider arrays, and injection tokens consistently.
3) Run structural checks: Use lint rules or custom scripts to verify no new new SomeService() appears.
4) Review scopes: Ensure root vs route vs component providers remain intentional.
5) Test with overrides: Swap in mocks to validate that DI remains flexible after refactor.
AI can speed up the mechanical work, but human review keeps the architecture coherent.
Common pitfalls with standalone APIs
Standalone APIs are great, but I see a few new mistakes with them.
Pitfall 1: Duplicating providers across lazy routes
If you declare the same root-style provider in multiple lazy routes, you’ll get multiple instances. Use root providers for app-wide services.
Pitfall 2: Forgetting to register global providers
With bootstrapApplication, it’s easy to forget global providers like provideHttpClient(). If DI errors show up, check your bootstrap config first.
Pitfall 3: Mixing module-based and standalone assumptions
If half your app is standalone and half is module-based, watch out for provider duplication or missing providers. Be intentional about migration order.
A mental model for provider scope decisions
When I’m deciding scope, I ask three questions:
1) How long should this instance live?
2) Who should share it?
3) What breaks if it’s duplicated?
If the answer is “the entire app should share it,” I use root. If the answer is “only this feature should share it,” I use route or feature scope. If the answer is “only this component needs it,” I use component scope.
This simple model prevents a lot of mistakes.
A deeper testing example with tokens and factories
Here’s a test setup that uses tokens, factories, and overrides in a clean way.
import { TestBed } from ‘@angular/core/testing‘;
import { API_CONFIG, ApiConfig } from ‘./tokens‘;
import { ApiClient } from ‘./api-client‘;
import { of } from ‘rxjs‘;
const testConfig: ApiConfig = {
baseUrl: ‘http://test-api‘,
timeoutMs: 100
};
class MockApiClient {
get() { return of([{ id: ‘1‘, name: ‘Mock Order‘ }]); }
}
describe(‘OrdersService‘, () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: API_CONFIG, useValue: testConfig },
{ provide: ApiClient, useClass: MockApiClient },
OrdersService
]
});
});
it(‘loads orders‘, () => {
const service = TestBed.inject(OrdersService);
service.listOrders().subscribe(orders => {
expect(orders.length).toBe(1);
});
});
});
The key is that the service never knows it’s being tested; DI handles the swap.
Security and DI: handling secrets and tokens
DI is not a secure vault. If you inject API keys into the client, they’re still in the bundle. Here’s how I handle this safely:
- Client-side DI only holds non-sensitive configuration.
- Secrets live on the server or in secure runtime providers.
- If a token must exist client-side, treat it as public.
DI helps you structure configuration, but it doesn’t replace security best practices.
Observability: how DI makes logging and monitoring easier
One underrated DI benefit is observability. You can inject a logging or telemetry service everywhere without wiring it manually.
@Injectable({ providedIn: ‘root‘ })
export class TelemetryService {
track(event: string, data?: unknown) {
console.log(‘[telemetry]‘, event, data);
}
}
Now any service can depend on telemetry without explicit setup. This makes it easy to add or remove observability tools later.
Migration tips: refactoring a large legacy codebase
If you’re modernizing a legacy Angular app, DI is often the biggest challenge. Here’s how I do it safely:
1) Identify manual instantiation: Replace new with injected providers.
2) Centralize config tokens: Move ad hoc config values into tokens.
3) Move to providedIn: Gradually reduce module providers where possible.
4) Add route-level scopes: For feature state, move providers into routes.
5) Add tests around tokens: Use overrides to validate behavior.
This staged approach prevents massive breakage and keeps the team moving.
A quick checklist for DI hygiene
This is my go‑to checklist for code reviews:
- Services never call
newfor dependencies. - Tokens are defined with
InjectionToken, not strings. - Providers are scoped intentionally (root/route/component).
- Configuration is centralized and typed.
- Tests override providers cleanly.
- No duplicate providers unless that’s the design.
If you hit most of these, your DI setup is usually healthy.
Final thoughts
Dependency injection is not just an Angular feature; it’s the architecture of your app. When you treat DI as a first-class design tool, you get clarity, flexibility, and long-term stability. When you treat it as boilerplate, you get hidden bugs and brittle code.
My advice is simple: keep dependencies explicit, scope providers with intent, and use tokens for anything that isn’t a class. Do that, and your Angular apps will be easier to test, easier to scale, and a lot less likely to wake you up at 2 a.m.
If you want to go deeper, try refactoring one feature to route-level providers and write a single test that overrides a token. That small exercise usually reveals where DI boundaries are unclear—and fixing them is often the biggest win you can get in an Angular codebase.


