Skip to main content

🔌 Services and Dependency Injection

Components paint the screen; services do the thinking. In this lesson you'll learn to move data access, business logic, and shared state out of components and into reusable services — and how Angular's dependency-injection system delivers those services wherever they're needed, making your code cleaner and dramatically easier to test.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why services exist and what belongs in them versus a component
  • Describe how Angular's dependency-injection container resolves a dependency
  • Choose the right provider scope — root, component, or via InjectionToken
  • Build a stateful service with RxJS BehaviorSubject for cross-component state
  • Wrap API calls in a service with HttpClient and unit-test it

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a shopping-cart state service and test it with the Angular testing utilities.

In This Lesson

What Are Services?

A service is a plain TypeScript class that holds non-UI concerns: fetching data, business rules, shared state, logging, authentication. Components stay lean and focused on the view; everything else lives in services that any component can share.

💡 A useful analogy: If components are the front-desk staff of a hotel — the people guests actually talk to — services are the back office that manages reservations, payments, and records. The front desk doesn't need to know how billing works; it just calls the billing service when it needs a total.
One service shared by many components Three components each depend on a single shared service instance. Service Component A Component B Component C
Figure 1 — A root-provided service is a singleton: all three components share the same instance and the same data.

✅ Why use services?

  • Separation of concerns — components handle the view, services handle logic.
  • Reusability — the same logic serves many components.
  • Shared state — keep data outside the component tree.
  • Testability — services test without any DOM.

How Dependency Injection Works

Dependency injection (DI) is a design pattern where a class declares what it needs instead of creating it. Angular's injector builds the dependency and hands it over. You never write new ProductService(); you just ask for one.

💡 The car-factory analogy: When you build a car you don't manufacture your own engine and wheels — you specify what you need and the factory supplies ready-made parts. DI is that factory for your classes.

Angular's DI has three moving parts:

  • Provider — the recipe telling Angular how to create the dependency.
  • Injector — the container that holds instances and creates them on demand.
  • Consumer — the class that declares the dependency (via constructor or inject()).
flowchart TD A[Consumer asks for a dependency] --> B[Injector looks up provider] B --> C{Provider found?} C -->|Yes| D[Create or reuse instance] C -->|No| E[Error: No provider] D --> F[Inject into consumer]

Declaring a service and consuming it is a two-line affair:

// logger.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' }) // registers a root-level singleton
export class LoggerService {
  log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

// any-component.ts — consume it with inject()
import { Component, inject } from '@angular/core';
import { LoggerService } from './logger.service';

@Component({ /* ... */ })
export class AnyComponent {
  private logger = inject(LoggerService); // Angular supplies the instance
}

📖 Key Terms

Singleton: a single shared instance used everywhere it's injected.

@Injectable: the decorator marking a class as available for DI.

providedIn: 'root': registers the service once, app-wide, and enables tree-shaking of unused services.

Provider Scopes & Hierarchy

Angular's injectors form a tree that mirrors your component tree. Where you provide a service decides how many instances exist and who shares them.

ScopeHow to declareResult
RootprovidedIn: 'root'One app-wide singleton (the usual choice)
Componentproviders: [Svc] in @ComponentA fresh instance per component + its children
Routeproviders on a lazy routeScoped to that route's subtree

Resolution rules

When a component requests a service, Angular searches outward: the component's own injector first, then up through parent components, and finally the root injector. The first provider it finds wins. If none exists, Angular throws a "No provider" error.

Component-level provider example

Providing at the component level gives each component subtree its own isolated instance — handy for per-widget state:

// counter.service.ts — note: NO providedIn
import { Injectable } from '@angular/core';

@Injectable()
export class CounterService {
  private count = 0;
  increment(): void { this.count++; }
  get value(): number { return this.count; }
}

// parent.component.ts
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  providers: [CounterService], // one instance shared by parent + children
  template: `
    <p>Total: {{ counter.value }}</p>
    <app-child></app-child>
    <app-child></app-child>
  `,
})
export class ParentComponent {
  counter = inject(CounterService);
}

Because the service is provided on the parent, both child components share the parent's single counter. Move the providers array down to ChildComponent and each child gets its own independent counter instead.

Provider Types & Injection Tokens

A class is the default recipe, but Angular can provide values, factories, and aliases too. This flexibility lets you inject configuration, swap implementations, and keep everything type-safe.

ProviderPurpose
useClassCreate an instance of a class (the default)
useValueProvide a fixed value or object (e.g. config)
useFactoryRun a function to build the dependency
useExistingAlias one token to another existing provider

Injecting configuration with an InjectionToken

You can't inject an interface (types vanish at runtime), so for non-class values use an InjectionToken — a unique, typed key:

// app.config.ts
import { InjectionToken } from '@angular/core';

export interface AppConfig {
  apiUrl: string;
  theme: 'light' | 'dark';
}

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

// providing the value (in main.ts providers or a component)
export const configProvider = {
  provide: APP_CONFIG,
  useValue: { apiUrl: 'https://api.example.com', theme: 'light' } as AppConfig,
};

// consuming it — fully typed
import { inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ApiService {
  private config = inject(APP_CONFIG);
  readonly baseUrl = this.config.apiUrl;
}

💡 Optional dependencies

Wrap an injection in inject(LoggerService, { optional: true }) to get null instead of an error when no provider exists — useful for optional plugins or feature flags.

Stateful Services for State

A root-provided service is a natural home for shared application state. Pair it with an RxJS BehaviorSubject — a stream that remembers its latest value — and any component can subscribe and stay in sync.

// cart.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, map } from 'rxjs';
import { Product } from '../models/product';

export interface CartItem { product: Product; quantity: number; }

@Injectable({ providedIn: 'root' })
export class CartService {
  // private writable source of truth
  private readonly items = new BehaviorSubject<CartItem[]>([]);

  // public read-only streams for components to subscribe to
  readonly items$ = this.items.asObservable();

  readonly totalItems$: Observable<number> = this.items$.pipe(
    map(items => items.reduce((sum, i) => sum + i.quantity, 0))
  );

  readonly totalPrice$: Observable<number> = this.items$.pipe(
    map(items => items.reduce((sum, i) => sum + i.product.price * i.quantity, 0))
  );

  addToCart(product: Product, quantity = 1): void {
    const current = this.items.value;
    const existing = current.find(i => i.product.id === product.id);

    if (existing) {
      // immutable update: build a new array
      this.items.next(current.map(i =>
        i.product.id === product.id
          ? { ...i, quantity: i.quantity + quantity }
          : i
      ));
    } else {
      this.items.next([...current, { product, quantity }]);
    }
  }

  removeFromCart(productId: number): void {
    this.items.next(this.items.value.filter(i => i.product.id !== productId));
  }

  clear(): void {
    this.items.next([]);
  }
}

Consuming it is effortless with the async pipe, which subscribes and unsubscribes for you:

@Component({
  selector: 'app-cart-summary',
  standalone: true,
  imports: [CommonModule],
  template: `
    <span>{{ cart.totalItems$ | async }} items</span>
    <strong>{{ cart.totalPrice$ | async | currency }}</strong>
  `,
})
export class CartSummaryComponent {
  cart = inject(CartService);
}

💡 When to reach for NgRx or signals

A BehaviorSubject service handles small-to-medium apps beautifully with zero extra libraries. Consider NgRx (or Angular signals for local reactivity) when you need centralized debugging, complex side effects, time-travel debugging, or strict immutability across many interdependent slices of state.

Services with HttpClient

API communication belongs in a service so components never touch URLs directly. Angular's HttpClient returns Observables and is enabled with provideHttpClient() at bootstrap.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { authInterceptor } from './app/auth.interceptor';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
});
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, retry, throwError } from 'rxjs';
import { User } from './user.model';

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private apiUrl = '/api/users';

  getUsers(page = 1, limit = 10): Observable<User[]> {
    const params = new HttpParams()
      .set('page', page)
      .set('limit', limit);
    return this.http.get<User[]>(this.apiUrl, { params }).pipe(
      retry(2),
      catchError(this.handleError)
    );
  }

  createUser(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(this.apiUrl, user).pipe(catchError(this.handleError));
  }

  private handleError(error: any): Observable<never> {
    const message = error.error instanceof ErrorEvent
      ? `Client error: ${error.error.message}`
      : `Server error ${error.status}: ${error.message}`;
    console.error(message);
    return throwError(() => new Error(message));
  }
}

Functional interceptors

Interceptors modify every request or response globally — perfect for attaching auth tokens. Modern Angular uses lightweight functional interceptors:

// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();
  if (token) {
    req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
  }
  return next(req);
};

Testing Services

Services are the easiest part of Angular to test because there's no DOM involved. Angular's TestBed gives you a real injector, and HttpClientTestingModule lets you assert on outgoing requests without a server.

Testing a stateful service

// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';
import { Product } from '../models/product';

describe('CartService', () => {
  let service: CartService;
  const product: Product = { id: 1, name: 'Widget', price: 100 } as Product;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it('adds an item to the cart', (done) => {
    service.addToCart(product, 2);
    service.items$.subscribe(items => {
      expect(items.length).toBe(1);
      expect(items[0].quantity).toBe(2);
      done();
    });
  });

  it('computes the total price', (done) => {
    service.addToCart(product, 3); // 3 × $100
    service.totalPrice$.subscribe(total => {
      expect(total).toBe(300);
      done();
    });
  });
});

Testing an HTTP service

// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => httpMock.verify()); // no unexpected requests

  it('fetches users', () => {
    const mock = [{ id: 1, name: 'Ada', email: 'ada@example.com' }];
    service.getUsers().subscribe(users => expect(users).toEqual(mock as any));

    const req = httpMock.expectOne(r => r.url === '/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mock); // supply the fake response
  });
});

✅ Mock services for component tests

When testing a component, provide a fake service so the test never hits the network: { provide: ProductService, useClass: MockProductService }. DI makes this swap trivial — the component can't tell the difference.

Hands-on Exercise

🏋️ Build and Test a Shopping-Cart Service

Objective: Create a root-provided CartService backed by a BehaviorSubject, then prove it works with a unit test.

Instructions:

  1. Generate the service: ng g s services/cart.
  2. Add a private BehaviorSubject<CartItem[]> and expose items$, plus a computed totalPrice$.
  3. Implement addToCart, updateQuantity, removeFromCart, and clear — all with immutable updates.
  4. Write a spec that adds an item twice and asserts the quantity merges to the sum.
  5. Challenge: persist the cart to localStorage and rehydrate it in the constructor.
💡 Hint

To merge quantities, look for an existing item by product.id before pushing a new one. Always emit a brand-new array with this.items.next([...]) rather than mutating the current one — subscribers (and OnPush components) rely on the reference changing.

✅ Example solution (updateQuantity + persistence)
updateQuantity(productId: number, quantity: number): void {
  if (quantity <= 0) { this.removeFromCart(productId); return; }
  this.items.next(
    this.items.value.map(i =>
      i.product.id === productId ? { ...i, quantity } : i
    )
  );
  this.persist();
}

private persist(): void {
  localStorage.setItem('cart', JSON.stringify(this.items.value));
}

// in the constructor:
constructor() {
  const saved = localStorage.getItem('cart');
  if (saved) this.items.next(JSON.parse(saved));
}
// cart.service.spec.ts — merge test
it('merges quantity for a repeated product', (done) => {
  service.addToCart(product, 1);
  service.addToCart(product, 2);
  service.items$.subscribe(items => {
    expect(items.length).toBe(1);
    expect(items[0].quantity).toBe(3);
    done();
  });
});

🎯 Quick Quiz

Question 1: What does providedIn: 'root' do?

Question 2: Why can't you inject a plain TypeScript interface as a dependency?

Question 3: In a stateful service, why expose items$ as an Observable instead of the BehaviorSubject directly?

Summary & Quiz

🎉 Key Takeaways

  • Services hold data access, business logic, and shared state so components stay focused on the view.
  • Dependency injection supplies dependencies automatically — you declare, Angular creates.
  • Provider scope (root vs component) decides how many instances exist and who shares them.
  • A BehaviorSubject service with the async pipe is a clean, library-free way to manage shared state.
  • Services are the easiest thing to test — use TestBed and HttpClientTesting, and swap in mocks via DI.

📚 Further Reading

🚀 What's Next?

You've covered Angular's core — components, communication, and services. Next the module shifts gears to Progressive Web Apps: how to make a web app installable, offline-capable, and app-like.

🎉 You've mastered the Angular core!

Components render, services think, and DI wires them together. Onward to PWAs.