π§© Services and Dependency Injection
Components should be about the view β what the user sees and clicks. Everything else β fetching data, holding shared state, business logic β belongs in services. This lesson shows you how to build injectable services and how Angular's dependency-injection system hands them to whoever needs them.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Create an injectable service with
@Injectable({ providedIn: 'root' }) - Consume a service in a component using the modern
inject()function - Explain dependency injection and Angular's hierarchical injector
- Choose the right provider scope β root, route, or component
- Share reactive state across unrelated components with a signal-based state service
- Structure a layered data / state / facade service architecture
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a signal-based CartService that any component can read and mutate.
In This Lesson
What Services Are For
A service is a plain TypeScript class that holds logic not tied to any one view: fetching from an API, authentication, logging, business rules, and β crucially β shared state that several components need. In the last lesson, inputs and outputs let parents and children talk. But two components on opposite sides of the tree can't reach each other that way. A service is the shared cabinet they both open.
π‘ A useful analogy: If components are the stations on a factory floor, services are the utilities β electricity, water, compressed air β piped to every station. No station owns the water supply; they all just tap into it. That's exactly how a singleton service works.
Typical jobs for a service: data fetching, authentication, logging, form validation rules, and application-wide state like the current user or shopping cart.
Creating & Injecting a Service
Generate a service with the Angular CLI:
ng generate service services/data
# shorthand
ng g s services/data
The generated class carries the @Injectable decorator. The providedIn: 'root' option registers it as an application-wide singleton β one instance shared everywhere, and tree-shakable if nothing uses it.
// data.service.ts
import { Injectable } from '@angular/core';
export interface Item { id: number; name: string; }
@Injectable({ providedIn: 'root' })
export class DataService {
getData(): Item[] {
return [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
];
}
}
To use it in a component, prefer the modern inject() function. It reads cleaner than constructor parameters, works in field initializers, and plays nicely with inheritance:
// item-list.component.ts
import { Component, inject, signal } from '@angular/core';
import { DataService, Item } from '../services/data.service';
@Component({
selector: 'app-item-list',
standalone: true,
template: `
<h2>Items</h2>
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class ItemListComponent {
private dataService = inject(DataService);
items = signal<Item[]>(this.dataService.getData());
}
β Why this is a win
- The component doesn't know or care where the data comes from.
- The fetching logic lives in one place and can be reused by many components.
- In tests you can swap the real service for a mock β no DOM required.
π‘ inject() vs the constructor
The classic constructor(private dataService: DataService) {} still works and is perfectly valid. But inject() is now the recommended style β especially because it can be called from functions (like route guards and resolvers) where there is no constructor at all.
How Dependency Injection Works
Dependency injection (DI) is a design pattern where a class receives its dependencies from the outside instead of creating them itself. Your component says "I need a DataService," and Angular's injector supplies one.
π‘ Analogy: In a professional kitchen, a chef who needs a knife doesn't forge one β the kitchen manager hands over the right knife from inventory. DI lets your components focus on cooking (view logic) instead of tool-making (constructing services).
When something requests a dependency, Angular walks its injector hierarchy, which mirrors the component tree:
a service"] --> B["Angular checks the
injector hierarchy"] B --> C{"Instance
already exists?"} C -->|Yes| D["Return the
existing instance"] C -->|No| E["Create a
new instance"] E --> F["Cache it in
the injector"] F --> D D --> G["Inject it into
the component"]
The search runs from the most specific injector outward: the element (component) injector, then any route injectors, and finally the root injector. The first match wins, which is exactly what makes provider scope (next section) so powerful.
providedIn: 'root'"] --> B["Route Injector
route providers"] B --> C["Element Injector
component providers"] C --> D["Child component
element injector"]
Provider Scopes
Where you register a service decides how many instances exist and how long they live. There are three common scopes:
1. Root scope β one app-wide singleton
@Injectable({ providedIn: 'root' })
export class DataService { }
The default and the right choice ~90% of the time. Created lazily on first use, lives for the whole app, shared by everyone.
2. Route scope β one instance per lazy route
// In a lazy route's providers array
export const routes: Routes = [{
path: 'admin',
providers: [AdminService], // fresh instance for this route subtree
loadComponent: () => import('./admin.component')
}];
3. Component scope β one instance per component
@Component({
selector: 'app-editor',
standalone: true,
providers: [DraftService], // each <app-editor> gets its own DraftService
template: `...`
})
export class EditorComponent { }
| Scope | Instances | Lifetime | Good for |
|---|---|---|---|
| Root | Exactly one | Whole application | Auth, logging, cart, most services |
| Route | One per lazy route | While the route is active | Feature-specific state |
| Component | One per component instance | While the component lives | Per-instance state (e.g. a draft editor) |
π Provider recipes
When you register a provider explicitly, you can control exactly what gets injected:
providers: [
DataService, // shorthand: useClass itself
{ provide: DataService, useClass: MockDataService }, // swap the implementation
{ provide: API_URL, useValue: 'https://api.example.com' }, // a plain value
{ provide: DataService, useFactory: dataFactory, deps: [ConfigService] } // a factory
]
Sharing State with Signals
The classic pattern for shared state used RxJS BehaviorSubject. It still works, but modern Angular makes reactive state dramatically simpler with signals: a writable signal for the state, and computed for anything derived. No subscribing, no unsubscribing, no memory leaks.
// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface CartItem { id: number; name: string; price: number; quantity: number; }
@Injectable({ providedIn: 'root' })
export class CartService {
// Private writable signal β only the service mutates it
private itemsSignal = signal<CartItem[]>([]);
// Public read-only view + derived values
readonly items = this.itemsSignal.asReadonly();
readonly totalItems = computed(() =>
this.items().reduce((sum, i) => sum + i.quantity, 0));
readonly totalPrice = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
addToCart(item: CartItem) {
this.itemsSignal.update(items => {
const existing = items.find(i => i.id === item.id);
if (existing) {
return items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i);
}
return [...items, item];
});
}
removeFromCart(id: number) {
this.itemsSignal.update(items => items.filter(i => i.id !== id));
}
clear() {
this.itemsSignal.set([]);
}
}
Any component injects it and binds directly β the template updates automatically when the signal changes:
// cart.component.ts
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartService } from '../services/cart.service';
@Component({
selector: 'app-cart',
standalone: true,
imports: [CurrencyPipe],
template: `
<h2>Your Cart</h2>
@if (cart.items().length === 0) {
<p class="empty-cart">Your cart is empty</p>
} @else {
@for (item of cart.items(); track item.id) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} Γ {{ item.price | currency }}</span>
<button (click)="cart.removeFromCart(item.id)">Remove</button>
</div>
}
<div class="cart-total">
<strong>Total: {{ cart.totalPrice() | currency }}</strong>
</div>
<button (click)="cart.clear()">Clear Cart</button>
}
`
})
export class CartComponent {
cart = inject(CartService);
}
β Signals vs BehaviorSubject
With signals there is no subscribe, no async pipe, and no ngOnDestroy cleanup. The computed totals recalculate only when the item list actually changes. For local shared UI state, signals are now the go-to; RxJS still shines for streams of asynchronous events (see the HTTP section).
Data Services & HTTP
Talking to a server is the classic service job. Inject HttpClient (registered app-wide via provideHttpClient() in your bootstrap) and return the Observable it produces:
// product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, throwError } from 'rxjs';
export interface Product { id: number; name: string; price: number; }
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private apiUrl = 'https://api.example.com/products';
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl).pipe(
catchError(err => {
console.error('Failed to load products', err);
return throwError(() => err);
})
);
}
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`${this.apiUrl}/${id}`);
}
createProduct(product: Product): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
}
A component can consume that Observable and drop the emitted value straight into a signal with toSignal(), which handles subscription and cleanup for you:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ProductService } from '../services/product.service';
@Component({
selector: 'app-product-list',
standalone: true,
template: `
@for (p of products(); track p.id) {
<div>{{ p.name }}</div>
}
`
})
export class ProductListComponent {
private productService = inject(ProductService);
// Auto-subscribes, auto-unsubscribes; starts as []
products = toSignal(this.productService.getProducts(), { initialValue: [] });
}
π‘ When to still reach for RxJS
HTTP calls, debounced search inputs, WebSocket streams, and anything that is a stream of events over time are RxJS's home turf. Use toSignal() at the boundary to bring the latest value into the signal world your templates prefer.
A Layered Service Architecture
As an app grows, cramming everything into one service gets messy. A clean convention splits responsibilities into three layers:
HTTP] B --> D[State Service
signals] C --> E[(Backend API)]
| Layer | Responsibility |
|---|---|
| Data service | Talks to the API. Pure HTTP in, Observables out. No UI knowledge. |
| State service | Holds the source of truth in signals: the list, the selection, loading, errors. |
| Facade service | The single API components use. Orchestrates data + state so components stay dumb. |
Here is a compact facade wiring the data and state layers together:
// product-facade.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { ProductService, Product } from './product.service';
@Injectable({ providedIn: 'root' })
export class ProductFacade {
private data = inject(ProductService);
// State lives here as signals
readonly products = signal<Product[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadProducts() {
this.loading.set(true);
this.error.set(null);
this.data.getProducts().subscribe({
next: products => this.products.set(products),
error: () => this.error.set('Failed to load products.'),
complete: () => this.loading.set(false)
});
}
}
// product-list.component.ts
import { Component, inject } from '@angular/core';
import { ProductFacade } from '../services/product-facade.service';
@Component({
selector: 'app-product-list',
standalone: true,
template: `
@if (facade.loading()) { <p>Loadingβ¦</p> }
@if (facade.error(); as err) { <p class="error">{{ err }}</p> }
@for (p of facade.products(); track p.id) {
<div class="product-card">{{ p.name }}</div>
}
`
})
export class ProductListComponent {
facade = inject(ProductFacade);
constructor() { this.facade.loadProducts(); }
}
The component knows nothing about HTTP, error handling, or how state is stored β it just reads signals and calls facade methods. That separation is what keeps large Angular codebases testable and easy to change.
Hands-on Exercise
ποΈ Build a Signal-based CartService
Objective: Create a shared service two unrelated components can both use β proving state lives in the service, not the component.
Requirements:
- Create
CartServicewithprovidedIn: 'root'holding a privatesignal<CartItem[]>. - Expose
addToCart(),removeFromCart(), and acomputedtotalItems. - Inject it into a
ProductCardcomponent (callsaddToCart) and a separateCartBadgecomponent (readstotalItems). - Stretch: persist the cart to
localStorageusing aneffect()that runs whenever the items signal changes.
π‘ Hint
Because the service is a root singleton, both components receive the same instance β add from one, and the badge in the other updates automatically, no inputs or outputs needed. For the stretch, put effect(() => localStorage.setItem('cart', JSON.stringify(this.items()))) in the constructor.
β Sample solution
// cart.service.ts
import { Injectable, signal, computed, effect } from '@angular/core';
export interface CartItem { id: number; name: string; price: number; quantity: number; }
@Injectable({ providedIn: 'root' })
export class CartService {
private itemsSignal = signal<CartItem[]>(this.load());
readonly items = this.itemsSignal.asReadonly();
readonly totalItems = computed(() =>
this.items().reduce((n, i) => n + i.quantity, 0));
constructor() {
// Stretch goal: persist on every change
effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
}
addToCart(item: CartItem) {
this.itemsSignal.update(items => {
const found = items.find(i => i.id === item.id);
return found
? items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)
: [...items, { ...item, quantity: 1 }];
});
}
removeFromCart(id: number) {
this.itemsSignal.update(items => items.filter(i => i.id !== id));
}
private load(): CartItem[] {
return JSON.parse(localStorage.getItem('cart') ?? '[]');
}
}
// cart-badge.component.ts
import { Component, inject } from '@angular/core';
import { CartService } from './cart.service';
@Component({
selector: 'app-cart-badge',
standalone: true,
template: `π {{ cart.totalItems() }}`
})
export class CartBadgeComponent {
cart = inject(CartService);
}
π― Quick Quiz
Question 1: What does @Injectable({ providedIn: 'root' }) give you?
Question 2: Two sibling components on opposite sides of the tree need to share the same data. What's the cleanest tool?
Question 3: Why prefer a signal over a BehaviorSubject for shared UI state in modern Angular?
Best Practices
β Do
- Default to
providedIn: 'root'unless you specifically need a scoped instance. - Keep view logic in components, everything else in services β the "how it looks" vs "what it does" line.
- Expose read-only state (
asReadonly()) and mutate only inside the service. - Use
inject()for a clean, function-friendly injection style. - Split large service logic into data / state / facade layers.
β οΈ Avoid
- Fat components that fetch, transform, and store data themselves.
- Leaking writable signals β hand out
asReadonly()so components can't bypass your logic. - Component-scoped services by accident β putting a service in a component's
providersarray quietly breaks singleton sharing. - Forgetting cleanup when you do subscribe manually to an Observable β prefer
toSignal()or theasyncpipe.
Summary & Quiz
π Key Takeaways
- Services hold non-view logic and shared state; components stay focused on the UI.
- DI hands your class the dependencies it declares β request with
inject(). providedIn: 'root'creates a tree-shakable, app-wide singleton β the default choice.- Angular's hierarchical injector searches from the element outward; provider scope controls how many instances exist.
- Signals make shared reactive state trivial β no subscribe/unsubscribe boilerplate.
- Layer big features into data / state / facade services for a clean, testable architecture.
π Further Reading
π What's Next?
Services often produce raw data β dates, numbers, currencies β that needs formatting before it reaches the screen. Next up: pipes, Angular's clean, reusable way to transform data right in the template.
π Well done!
Your components now have a shared brain. Let's make their output look great with pipes.