Skip to main content

🧩 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.
Multiple components sharing one service Three separate components all depend on a single shared service instance in the root injector. Header Product List Cart Badge CartService one shared instance (singleton)
Figure 1 β€” Unrelated components can't use inputs/outputs to share data, but they can all inject the same singleton service and read the same state.

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:

flowchart TD A["Component requests
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.

flowchart TD A["Root Injector
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 { }
ScopeInstancesLifetimeGood for
RootExactly oneWhole applicationAuth, logging, cart, most services
RouteOne per lazy routeWhile the route is activeFeature-specific state
ComponentOne per component instanceWhile the component livesPer-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:

flowchart TD A[Components] --> B[Facade Service] B --> C[Data Service
HTTP] B --> D[State Service
signals] C --> E[(Backend API)]
LayerResponsibility
Data serviceTalks to the API. Pure HTTP in, Observables out. No UI knowledge.
State serviceHolds the source of truth in signals: the list, the selection, loading, errors.
Facade serviceThe 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:

  1. Create CartService with providedIn: 'root' holding a private signal<CartItem[]>.
  2. Expose addToCart(), removeFromCart(), and a computed totalItems.
  3. Inject it into a ProductCard component (calls addToCart) and a separate CartBadge component (reads totalItems).
  4. Stretch: persist the cart to localStorage using an effect() 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 providers array quietly breaks singleton sharing.
  • Forgetting cleanup when you do subscribe manually to an Observable β€” prefer toSignal() or the async pipe.

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.