Skip to main content
Published / updated

Services and Dependency Injection

Before you start

You need: directives and pipes (Article 03).

Time: about 45 minutes, plus the practice.

Learning objective

Structure an application so components render and services do the work, and choose the correct provider scope for each service.

Topics

  • Why services
  • Creating and injecting a service
  • inject() versus constructor injection
  • Provider scopes
  • Injection tokens
  • Sharing state between components
  • Signal-based state services
  • Testing with fakes
  • Diagnosing injection errors

Why services

// Everything in the component — untestable, unreusable
export class StudentListComponent {
students: Student[] = [];

async ngOnInit(): Promise<void> {
const token = localStorage.getItem('authToken');
const response = await fetch('https://localhost:7099/api/students', {
headers: { Authorization: `Bearer ${token}` }
});
this.students = await response.json();
}
}

The URL, the token handling, the error handling and the mapping all live in a component that exists to render a list. Nothing here is reusable, and testing it requires a browser and a running API.

export class StudentListComponent {
private readonly studentService = inject(StudentService);

students = signal<Student[]>([]);

ngOnInit(): void {
this.studentService.getStudents().subscribe(students => this.students.set(students));
}
}

Components render. Services do the work. That single rule produces testable code, and it is the same layering as a controller calling a service in ASP.NET Core.

Creating a service

ng generate service core/services/student
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

import { environment } from '../../../environments/environment';
import { Student, PagedResult, StudentQuery } from '../models/student';

@Injectable({ providedIn: 'root' })
export class StudentService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/students`;

search(query: StudentQuery): Observable<PagedResult<Student>> {
let params = new HttpParams()
.set('page', query.page)
.set('pageSize', query.pageSize);

if (query.term) {
params = params.set('term', query.term);
}

return this.http.get<PagedResult<Student>>(this.baseUrl, { params });
}

getByPublicId(publicId: string): Observable<Student> {
return this.http.get<Student>(`${this.baseUrl}/${publicId}`);
}

create(request: StudentCreateRequest): Observable<Student> {
return this.http.post<Student>(this.baseUrl, request);
}

update(publicId: string, request: StudentUpdateRequest): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${publicId}`, request);
}

delete(publicId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${publicId}`);
}
}

@Injectable({ providedIn: 'root' }) registers the service application-wide as a singleton, and makes it tree-shakeable — if nothing injects it, the bundler removes it from the build. That is the default to use.

schoolId is absent from every method. The API reads it from the JWT claim. A client-supplied tenant id is a request to choose whose data to read, and no amount of validation makes that safe — the same rule as the ASP.NET Core track, seen from the other side.

inject() versus constructor

// Modern
export class StudentListComponent {
private readonly studentService = inject(StudentService);
private readonly router = inject(Router);
}

// Traditional — still fully supported
export class StudentListComponent {
constructor(
private readonly studentService: StudentService,
private readonly router: Router
) { }
}

inject() is shorter, works in field initialisers, and is the only option inside a functional guard or interceptor. Use it in new code; you will meet the constructor form everywhere.

inject() only works in an injection context — a field initialiser, a constructor, or a factory function. Calling it inside a method throws:

// Wrong
onSave(): void {
const service = inject(StudentService); // NG0203
}

Provider scopes

ScopeInstancesUse for
providedIn: 'root'One, application-wideThe default — API services, auth, config
Component providersOne per component instancePer-component state
Route providersOne per routed featureFeature-scoped state
// One per instance of this component and its children
@Component({
selector: 'app-student-editor',
providers: [StudentDraftService],
// ...
})

Two editors open side by side then have independent drafts. With providedIn: 'root' they would share one — and typing in the second would overwrite the first.

// Feature-scoped: created when the route loads, destroyed when it unloads
export const routes: Routes = [
{
path: 'students',
providers: [StudentFilterStateService],
loadChildren: () => import('./features/students/routes')
}
];

Choose the scope by asking who should share the state. Everyone → root. One component → component providers. One feature area → route providers.

A root-scoped service holding user-specific mutable state is a bug waiting to happen — it survives navigation, so stale data from a previous screen reappears.

Injection tokens

For values that are not classes.

import { InjectionToken } from '@angular/core';

export interface AppConfig {
apiUrl: string;
pageSize: number;
academicYear: string;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// app.config.ts
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: environment.apiUrl,
pageSize: 20,
academicYear: '2024-25'
}
}
]
export class StudentService {
private readonly config = inject(APP_CONFIG);
}

Other provider forms:

providers: [
{ provide: StudentService, useClass: StudentService },
{ provide: StudentService, useExisting: CachedStudentService },
{ provide: APP_CONFIG, useValue: { /* ... */ } },
{
provide: StudentService,
useFactory: (http: HttpClient, config: AppConfig) => new StudentService(http, config),
deps: [HttpClient, APP_CONFIG]
}
]

useClass swaps the implementation — which is how a test or a demo mode substitutes a fake without touching a single component:

providers: [
{ provide: StudentService, useClass: environment.useMocks ? MockStudentService : StudentService }
]

Sharing state

Two unrelated components needing the same data is the problem services solve.

import { Injectable, computed, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StudentStateService {
private readonly studentsSignal = signal<Student[]>([]);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
private readonly searchTermSignal = signal('');

// Read-only views — callers cannot write
readonly students = this.studentsSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
readonly searchTerm = this.searchTermSignal.asReadonly();

readonly filtered = computed(() => {
const term = this.searchTermSignal().toLowerCase();

if (!term) {
return this.studentsSignal();
}

return this.studentsSignal().filter(s =>
s.name.toLowerCase().includes(term) ||
s.rollNumber.toLowerCase().includes(term));
});

readonly count = computed(() => this.filtered().length);

private readonly studentService = inject(StudentService);

load(): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);

this.studentService.search({ page: 1, pageSize: 100 }).subscribe({
next: result => {
this.studentsSignal.set(result.items);
this.loadingSignal.set(false);
},
error: () => {
this.errorSignal.set('Could not load students.');
this.loadingSignal.set(false);
}
});
}

setSearchTerm(term: string): void {
this.searchTermSignal.set(term);
}

remove(publicId: string): void {
this.studentsSignal.update(list => list.filter(s => s.publicId !== publicId));
}
}
export class StudentListComponent {
private readonly state = inject(StudentStateService);

readonly students = this.state.filtered;
readonly loading = this.state.loading;
readonly error = this.state.error;

ngOnInit(): void {
this.state.load();
}
}
@if (loading()) {
<p role="status">Loading students…</p>
} @else if (error()) {
<p class="error" role="alert">{{ error() }}</p>
} @else {
@for (student of students(); track student.publicId) {
<app-student-card [student]="student" />
} @empty {
<p class="muted">No students match this search.</p>
}
}

asReadonly() is the important detail. Components read the signal and cannot write it — every change goes through a method on the service, so there is one place to look when the state is wrong.

Exposing the writable signal directly means any component can set it, and finding what changed becomes a search through the whole application.

Four states — loading, error, empty, loaded — all handled. Most student projects handle one.

The RxJS equivalent

@Injectable({ providedIn: 'root' })
export class StudentStateService {
private readonly studentsSubject = new BehaviorSubject<Student[]>([]);
readonly students$ = this.studentsSubject.asObservable();

setStudents(students: Student[]): void {
this.studentsSubject.next(students);
}
}

BehaviorSubject holds a current value and emits it to every new subscriber. Same pattern, and it is what every project written before signals uses.

asObservable() plays the role asReadonly() does — callers can subscribe but cannot call next().

Signals for new state; BehaviorSubject when working in an existing codebase. They interoperate: toSignal() and toObservable() convert between them.

Testing with fakes

export class FakeStudentService {
students: Student[] = [
{ publicId: 'a1', name: 'Ravi Kumar', rollNumber: 'NCA-2024-0012',
className: '10th', section: 'A' }
];

search(): Observable<PagedResult<Student>> {
return of({ items: this.students, totalCount: 1, page: 1, pageSize: 20 });
}
}
TestBed.configureTestingModule({
imports: [StudentListComponent],
providers: [
{ provide: StudentService, useClass: FakeStudentService }
]
});

The component under test is unchanged. That substitutability is what dependency injection is for — not indirection for its own sake.

For HTTP specifically, HttpTestingController lets you assert the request and control the response:

TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()]
});

Diagnosing injection errors

ErrorCause
NG0201: No provider for StudentServiceNot providedIn: 'root', and not in any providers array
NG0203: inject() must be called from an injection contextinject() called inside a method
NullInjectorError: No provider for HttpClientprovideHttpClient() missing from app.config.ts
Circular dependencyA injects B injects A
Two components see different stateThe service is component-scoped, not root
State survives navigation unexpectedlyA root-scoped service holding per-screen state

NG0201 names both the missing service and the component that needed it, so the fix is always one line — either an @Injectable({ providedIn: 'root' }) or a providers entry.

For a circular dependency the fix is design, not configuration: extract the shared behaviour into a third service both depend on.

Errors you will hit

MessageCauseFix
NullInjectorError: No provider for StudentServiceNot provided anywhere@Injectable({ providedIn: 'root' })
Two components see different stateService provided per componentProvide it in root for shared state
inject() must be called from an injection contextCalled outside a constructor or field initialiserMove it
Circular dependency warningTwo services injecting each otherExtract the shared part
State resets on navigationService provided in a lazy-loaded routeProvide it in root

providedIn: 'root' gives one instance for the whole application. Providing a service on a component gives each instance its own — which is occasionally what you want and usually a bug.

Common mistakes

  • HTTP calls and business logic in components
  • schoolId passed from the client
  • inject() called inside a method
  • Missing provideHttpClient()
  • Exposing writable signals or subjects from a service
  • Root scope for state that should be per-component
  • Component scope for state that should be shared
  • A root-scoped service holding stale per-screen state
  • No loading, error or empty state
  • providedIn: 'root' omitted, then wondering why the provider is missing
  • Circular service dependencies

Practice

  1. Move the HTTP call from a component into StudentService with providedIn: 'root'. Confirm the component shrinks.
  2. Inject it with inject(), then with the constructor form. Compare.
  3. Call inject() inside a method. Record NG0203.
  4. Remove provideHttpClient() from app.config.ts. Record the error.
  5. Remove providedIn: 'root' from the service. Record NG0201, then fix it two ways — the decorator, and a providers array.
  6. Build StudentStateService with signals, asReadonly() and computed. Render all four states.
  7. Expose the writable signal instead. Write to it from a component, then reason about how you would find that change in a large application.
  8. Create two sibling components injecting the root-scoped state service. Change the search term in one and confirm both update.
  9. Move the service to component providers. Confirm they no longer share.
  10. Build a StudentDraftService provided at component level. Open two editors and confirm independent drafts.
  11. Create an APP_CONFIG injection token and inject it into the service.
  12. Swap StudentService for a fake with useClass in a test and confirm the component is unchanged.
  13. Rewrite the state service with BehaviorSubject and compare with the signal version.
  14. Create a circular dependency between two services. Record the error, then fix it by extracting a third.

Exercises 8 and 9 make provider scope concrete better than any explanation.

You can now

  • Move logic out of components into services
  • Choose the right provider scope
  • Inject with inject() or the constructor
  • Say why two components saw different state
  • Keep HTTP calls out of components

Review questions

  1. What does providedIn: 'root' give you beyond a singleton?
  2. Where can inject() be called, and where not?
  3. Why expose asReadonly() rather than the writable signal?
  4. When should a service be provided at component level rather than root?

Next: Routing and navigation