Week 9: Dependency Injection & Services Deep Dive

Welcome to Phase 3 — this is where you stop building isolated components and start architecting an actual application. It begins with dependency injection: the system that decides what a class depends on, who creates that dependency, and how far its reach extends.

Phase 3 of 7 Week 9 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design a service layer with clear, single responsibilities
  • Use Angular's DI system beyond a basic @Injectable()
  • Test a service in isolation, with its dependencies swapped for fakes

1. Providers & providedIn Strategies

@Injectable({ providedIn: 'root' }) is the default you've been using all along — it registers a service with the application's root injector, and Angular is smart enough to tree-shake it entirely out of your production bundle if nothing ever actually injects it.

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

@Injectable({ providedIn: 'root' })
export class NotificationService {
  notify(message: string) {
    console.log('[notify]', message);
  }
}

You can also provide a service to a single component's subtree instead of the whole app — every instance of that component (and its children) gets its own instance of the service, isolated from every other instance:

tab-group.ts
import { Component } from '@angular/core';
import { TabStateService } from './tab-state.service';

@Component({
  selector: 'app-tab-group',
  templateUrl: './tab-group.html',
  providers: [TabStateService], // a fresh instance per <app-tab-group>
})
export class TabGroup {}

This is the right call whenever a service holds state that belongs to one specific instance of a component, not the whole app — two <app-tab-group> elements on the same page shouldn't share which tab is selected.

2. inject() vs. Constructor Injection

Constructor injection still works exactly as you'd expect, and remains completely valid:

constructor injection
export class OrderSummary {
  constructor(private notifications: NotificationService) {}
}

inject() gets the same dependency without a constructor parameter — and, critically, works in places a constructor can't: field initializers, functional route guards, functional interceptors, and anywhere else that runs inside Angular's injection context.

inject() function
import { inject } from '@angular/core';

export class OrderSummary {
  private notifications = inject(NotificationService);
}
Why this matters for functional guards (Week 11)

A route guard written as a plain function has no constructor at all — inject() is the only way for it to get a service. This is exactly why Angular's router, interceptors, and resolvers all moved to a function-based API: inject() made dependency access possible without requiring a class.

3. Injection Tokens & Multi-Providers

Classes aren't the only thing you can inject. An InjectionToken lets you register and inject plain values — configuration objects, primitives, anything — with the same type safety as a class dependency.

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

export interface ApiConfig {
  baseUrl: string;
  timeoutMs: number;
}

export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
app.config.ts
import { API_CONFIG } from './api-config';

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: API_CONFIG, useValue: { baseUrl: '/api', timeoutMs: 5000 } },
  ],
};

multi: true takes this further — instead of one provider "winning," Angular collects every provider registered against the same token into an array. This is exactly how you'll register a set of pluggable validators or transports (this week's exercise) without a giant hardcoded if/else:

multi-provider.ts
export const NOTIFICATION_TRANSPORT = new InjectionToken<NotificationTransport>('NOTIFICATION_TRANSPORT');

// In app.config.ts:
providers: [
  { provide: NOTIFICATION_TRANSPORT, useClass: ToastTransport, multi: true },
  { provide: NOTIFICATION_TRANSPORT, useClass: ConsoleTransport, multi: true },
]

// Injecting it anywhere gives you the full array:
private transports = inject(NOTIFICATION_TRANSPORT); // NotificationTransport[]

4. Hierarchical Injectors & Component-Level Providers

Angular's injectors form a tree that mirrors your component tree. When a component asks for a dependency, Angular looks for a provider starting at that component, then walks up through its ancestors until it finds one — the root injector is the final fallback.

conceptual resolution order
Root injector (providedIn: 'root' services)
  └── DashboardComponent injector (its own `providers: [...]`, if any)
        └── WidgetComponent injector
              └── WidgetComponent asks for a service --
                  Angular checks here first, then Widget's parent,
                  then Dashboard, then root -- first match wins.

This is why re-providing a service on a component (Section 1's TabGroup example) works: every descendant of that component resolves to the local instance, not the app-wide singleton, because the local injector is checked first.

@Optional() and @Self()
import { Optional, Self } from '@angular/core';

export class WidgetComponent {
  constructor(
    @Optional() private analytics?: AnalyticsService, // won't throw if nothing provides it
    @Self() private tabState?: TabStateService,          // must be provided on THIS component exactly
  ) {}
}

You'll rarely need these two decorators, but they're worth recognizing: @Optional() makes a dependency non-fatal if missing, and @Self() restricts resolution to the component's own injector, refusing to walk up the tree at all.

5. Environment-Based Configuration & Feature Flags

Combine what you learned in Week 2 (environment files) with this week's InjectionToken pattern, and you get a clean, testable way to configure an app per environment:

app.config.ts
import { environment } from '../environments/environment';
import { API_CONFIG } from './api-config';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: API_CONFIG,
      useValue: { baseUrl: environment.apiUrl, timeoutMs: 5000 },
    },
  ],
};

Feature flags follow the same shape — a small injectable service backed by whatever source makes sense (a config file, a remote flag service, localStorage for local overrides during development):

feature-flags.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class FeatureFlags {
  private flags = signal(new Set(['new-checkout']));

  isEnabled(flag: string): boolean {
    return this.flags().has(flag);
  }
}

Because it's a normal injectable service, swapping the real implementation for a fake one in tests (Section 6) is exactly as easy as swapping any other dependency.

6. Testing Services in Isolation

A service with constructor-injected dependencies can often be tested with zero Angular testing machinery at all — just instantiate the class directly with fakes:

notification.service.spec.ts — plain instantiation
import { NotificationService } from './notification.service';

describe('NotificationService', () => {
  it('logs a message', () => {
    const logSpy = jasmine.createSpy('log');
    const service = new NotificationService({ log: logSpy } as any);

    service.notify('Order placed');

    expect(logSpy).toHaveBeenCalledWith('Order placed');
  });
});

When a service relies on inject() internally, it needs an injection context to run at all — that's what TestBed provides, letting you override specific dependencies with fakes while leaving the rest wired normally:

notification.service.spec.ts — TestBed
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification.service';
import { NOTIFICATION_TRANSPORT } from './notification-transport.token';

describe('NotificationService', () => {
  it('sends to every registered transport', () => {
    const fakeTransport = { send: jasmine.createSpy('send') };

    TestBed.configureTestingModule({
      providers: [
        NotificationService,
        { provide: NOTIFICATION_TRANSPORT, useValue: [fakeTransport], multi: false },
      ],
    });

    const service = TestBed.inject(NotificationService);
    service.notify('Order placed');

    expect(fakeTransport.send).toHaveBeenCalledWith('Order placed');
  });
});

You'll build on this exact pattern in Week 18, when unit testing gets a full lesson — for now, the goal is simply recognizing that a well-designed service, with its dependencies injected rather than hardcoded, is naturally easy to test.

7. Hands-on Exercise

Hands-on

Build a notification service with a swappable transport

Put Sections 3 and 4 together into a working, pluggable notification system.

Requirements:

  1. Define a NotificationTransport interface with one method: send(message: string): void.
  2. Create a NOTIFICATION_TRANSPORT injection token, and two implementations: ToastTransport (render a temporary on-screen element) and ConsoleTransport (log to the console).
  3. Register both as multi: true providers for the token in app.config.ts.
  4. Build NotificationService, injecting the token (so it receives the full array) and looping through every registered transport in its notify() method.
  5. Add an environment-based API_CONFIG-style token that controls whether ToastTransport is included at all — e.g. disabled in a "quiet mode" configuration.
  6. Write at least one isolated test (Section 6's pattern) proving notify() calls send() on every registered transport.
Hint

If injecting NOTIFICATION_TRANSPORT gives you a single instance instead of an array, double-check every provider registered against that token includes multi: true — forgetting it on even one provider changes the injected shape.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does "tree-shakable" mean for a providedIn: 'root' service, concretely?

If nothing in your app ever injects that service, the build tool can detect it's unreachable and drop it from the production bundle entirely — you pay zero bytes for a service you registered but never actually used. This works because providedIn: 'root' ties the provider registration directly to the class itself, rather than to a separate providers list a bundler would have to assume is fully used.

Q2

Why can a functional route guard use inject() but not constructor injection?

A functional guard is a plain function, not a class — there's no constructor to inject into. inject() works because Angular runs the guard inside an active injection context, and inject() reads from whatever injection context is currently active, regardless of whether that context is a class constructor or not.

Q3

If three providers are registered against the same multi: true token, in what order does the injected array contain them?

In registration order — the order the providers appear in the providers array(s) Angular resolves, from higher up the injector tree to lower. It's worth keeping in mind if execution order matters for what you're building (interceptors are the clearest example, later in Week 14) — don't assume a particular transport or handler runs first without checking where it's actually registered.

Q4

Component A provides TabStateService. Its child, component B, injects it. Its sibling, component C (not inside A), also injects it. Same instance, or different?

Different — assuming this is the only place the service is provided below root. B, being inside A's subtree, resolves to the instance A provided. C, being outside that subtree, keeps walking up past where A would be and either finds a different provider higher up or falls back to the root singleton — it never sees A's local instance at all, because injector resolution only walks up an element's own ancestor chain, never sideways.