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
| Scope | Instances | Use for |
|---|---|---|
providedIn: 'root' | One, application-wide | The default — API services, auth, config |
Component providers | One per component instance | Per-component state |
Route providers | One per routed feature | Feature-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
| Error | Cause |
|---|---|
NG0201: No provider for StudentService | Not providedIn: 'root', and not in any providers array |
NG0203: inject() must be called from an injection context | inject() called inside a method |
NullInjectorError: No provider for HttpClient | provideHttpClient() missing from app.config.ts |
| Circular dependency | A injects B injects A |
| Two components see different state | The service is component-scoped, not root |
| State survives navigation unexpectedly | A 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
| Message | Cause | Fix |
|---|---|---|
NullInjectorError: No provider for StudentService | Not provided anywhere | @Injectable({ providedIn: 'root' }) |
| Two components see different state | Service provided per component | Provide it in root for shared state |
inject() must be called from an injection context | Called outside a constructor or field initialiser | Move it |
| Circular dependency warning | Two services injecting each other | Extract the shared part |
| State resets on navigation | Service provided in a lazy-loaded route | Provide 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
schoolIdpassed from the clientinject()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
- Move the HTTP call from a component into
StudentServicewithprovidedIn: 'root'. Confirm the component shrinks. - Inject it with
inject(), then with the constructor form. Compare. - Call
inject()inside a method. RecordNG0203. - Remove
provideHttpClient()fromapp.config.ts. Record the error. - Remove
providedIn: 'root'from the service. RecordNG0201, then fix it two ways — the decorator, and aprovidersarray. - Build
StudentStateServicewith signals,asReadonly()andcomputed. Render all four states. - Expose the writable signal instead. Write to it from a component, then reason about how you would find that change in a large application.
- Create two sibling components injecting the root-scoped state service. Change the search term in one and confirm both update.
- Move the service to component
providers. Confirm they no longer share. - Build a
StudentDraftServiceprovided at component level. Open two editors and confirm independent drafts. - Create an
APP_CONFIGinjection token and inject it into the service. - Swap
StudentServicefor a fake withuseClassin a test and confirm the component is unchanged. - Rewrite the state service with
BehaviorSubjectand compare with the signal version. - 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
- What does
providedIn: 'root'give you beyond a singleton? - Where can
inject()be called, and where not? - Why expose
asReadonly()rather than the writable signal? - When should a service be provided at component level rather than root?
Next: Routing and navigation