07 · Services & Dependency Injection¶
Components should be about presentation: what to show and how to react to the user. Code
that fetches data, holds state shared by several screens, talks to localStorage, formats
money or logs errors belongs in services — plain classes that Angular creates for you
and hands to whoever asks. The mechanism that does the handing out is dependency
injection (DI).
A first service¶
import { Service } from '@angular/core';
@Service()
export class Logger {
readonly lines: string[] = [];
log(msg: string) {
this.lines.push(msg);
console.log(msg);
}
}
Use it from any component, directive, pipe or other service with inject():
import { Component, inject } from '@angular/core';
import { Logger } from './logger';
@Component({
selector: 'app-save-button',
template: `<button type="button" (click)="save()">Save</button>`,
})
export class SaveButton {
private readonly logger = inject(Logger);
protected save() {
this.logger.log('saved');
}
}
That's the whole pattern. You never write new Logger(). The first time anything injects
Logger, Angular creates one instance; everyone after that gets the same instance for
the lifetime of the app.
@Service and @Injectable¶
Angular 22 introduced @Service(), and it is what ng generate service now produces.
It is a streamlined version of the decorator you will see in almost all existing Angular
code:
// Angular 22 CLI default
@Service()
export class Logger {}
// Equivalent, and still fully supported
@Injectable({ providedIn: 'root' })
export class Logger {}
Both make the class automatically available application-wide as a singleton, and both are tree-shakable — if nothing injects the service, it is dropped from the bundle. Differences worth knowing:
@Service()requiresinject(); it does not support constructor parameter injection. Trying it fails the build:
✘ [ERROR] NG2028: @Service class cannot use constructor dependency injection.
Use the `inject` function instead.
@Injectable supports both styles:
@Injectable({ providedIn: 'root' })
export class Legacy {
constructor(private http: HttpClient) {} // works
}
@Service({ autoProvided: false })creates a service that is not registered anywhere automatically; you must list it in someprovidersarray (below). This is the equivalent of a bare@Injectable().@Service({ factory: () => ... })lets you supply the value from a function.
The core team ships an ng generate @angular/core:service migration that converts
@Injectable to @Service where applicable, but there's no urgency: @Injectable is not
deprecated in 22. This course uses @Service() for new code and @Injectable when
showing patterns you will meet in existing apps. Everything else in this lesson applies
to both.
inject() rules¶
inject() works only in an injection context: a field initialiser or constructor of
a class Angular is creating (components, directives, pipes, services), a provider
factory, or a function run with runInInjectionContext. Calling it later — inside a
click handler or a setTimeout — throws NG0203. So inject at the top of the class and
keep the reference:
export class BookList {
private readonly store = inject(BookStore); // ✅ field initialiser
protected onClick() {
inject(BookStore); // ❌ NG0203 at runtime
}
}
Constructor injection (constructor(private store: BookStore)) is the older style.
ng generate @angular/core:inject migrates it to inject().
Scoping a service to a component¶
Sometimes you want one instance per component, not one per app — a wizard's state, a
form's draft, an editor's undo history. Register the service in the component's
providers:
import { Component, Service, inject, signal } from '@angular/core';
import { Logger } from './logger';
@Service({ autoProvided: false })
export class Counter {
private readonly logger = inject(Logger);
readonly value = signal(0);
inc() {
this.value.update((v) => v + 1);
this.logger.log(`inc -> ${this.value()}`);
}
}
@Component({
selector: 'app-counter-panel',
providers: [Counter],
template: `<button type="button" (click)="counter.inc()">{{ counter.value() }}</button>`,
})
export class CounterPanel {
protected readonly counter = inject(Counter);
}
We rendered two <app-counter-panel /> elements side by side, clicked the first button
twice and the second once. Result: the buttons read 2 and 1 — two separate Counter
instances — while the shared root Logger recorded all three calls:
A component-scoped service is created with the component and destroyed with it, and it is visible to that component's children too (Level 2, lesson 03 covers the injector hierarchy in full).
Services that hold state¶
Services are the natural home for state shared across components. Combine them with signals: keep a private writable signal and expose read-only views and methods.
import { Service, computed, signal } from '@angular/core';
export interface CartItem { sku: string; name: string; price: number; qty: number }
@Service()
export class Cart {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().reduce((n, i) => n + i.qty, 0));
readonly total = computed(() => this._items().reduce((s, i) => s + i.price * i.qty, 0));
add(item: Omit<CartItem, 'qty'>) {
this._items.update((items) => {
const existing = items.find((i) => i.sku === item.sku);
return existing
? items.map((i) => (i.sku === item.sku ? { ...i, qty: i.qty + 1 } : i))
: [...items, { ...item, qty: 1 }];
});
}
clear() {
this._items.set([]);
}
}
A header component can show cart.count() and a checkout page cart.total(); when a
product page calls cart.add(...), both update, because both templates read signals owned
by the same instance. Only Cart can write, so every change goes through a method you can
test, log or validate.
How It Actually Works¶
An injector is a map from tokens (usually a class) to providers (recipes for making a value), plus a cache of values already made. Angular builds a tree of them:
- The root environment injector, created by
bootstrapApplicationfrom theprovidersinapp.config.ts. - Element injectors for components and directives that declare
providers. - Route-level environment injectors for routes with
providers(Level 2).
When inject(Counter) runs inside CounterPanel, Angular first asks the element injector
of that CounterPanel instance. It has a provider (from providers: [Counter]), so it
creates a Counter — running Counter's field initialisers inside that same injection
context, which is how Counter itself can inject(Logger) — and caches it on that
element. For Logger, the element injectors up the tree have no provider, so the lookup
reaches the root injector.
What does "automatically provided" mean for a class that isn't in any providers array?
The compiler attaches an injectable definition to the class (a static ɵprov
property) that says "if nobody else provides me, the root injector may create me with
this factory." The root injector checks that definition on a cache miss. Because the
registration lives on the class instead of in a central list, a service nobody imports is
never referenced and the bundler can drop it.
inject() works only during creation because Angular sets a module-level "current
injector" pointer just before running a class's factory and clears it afterwards. Inside
a click handler that pointer is empty — hence NG0203.
If no injector in the chain can supply the token, you get:
— which is exactly what happened in our tests when we injected an
@Service({ autoProvided: false }) class without listing it anywhere.
Common mistakes¶
- Instantiating services with
new. You get an instance outside DI: its owninject()calls throw, and it's not the instance everyone else shares. - Adding a root service to a component's
providers"to be safe." That creates a second, private instance for that component tree — a classic source of "my data isn't updating" bugs. - Constructor injection with
@Service. Useinject()(NG2028), or stay on@Injectablefor that class. - Storing state in plain mutable fields and expecting templates to update. With
OnPushand zoneless defaults, templates react to signals. Keep shared state in signals. - Circular dependencies (
AinjectsB,BinjectsA) causeNG0200. Extract the shared part into a third service.
Exercise¶
- Create a
Notificationsservice with@Service()that holds a signal of{ id: number; text: string }[], and methodspush(text)anddismiss(id). Makepushauto-dismiss after 4 seconds. - Create a
NotificationListcomponent that shows all notifications with a dismiss button, and aNotifyButtoncomponent that pushes "Hello!". Put both on the page. - Add
providers: [Notifications]toNotifyButton(using anautoProvided: falsevariant of the service) and observe that clicking no longer shows anything in the list. Explain why in a comment, then remove it. - Try converting
Notificationsto constructor injection of some other service and read the NG2028 error.