~3 min de lecture
Ports & Adapters en Angular
But : te montrer que l'inversion de contrôle et l'injection de dépendance, c'est plus difficile à dire qu'à faire.
1) Créer le Port (le contrat)
Un Port décrit ce que l’appli veut faire (pas comment). Ici : lister et créer des users.
// src/users/users.port.ts
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
}
export interface UsersPort {
getAll(): Observable<User[]>;
create(input: Omit<User, 'id'>): Observable<User>;
}
2) Adapter InMemory (démo/tests)
Implémentation simple en mémoire pour travailler sans backend.
// src/users/inmemory-users.adapter.ts
import { UsersPort, User } from './users.port';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class InMemoryUsersAdapter implements UsersPort {
private readonly _data: User[] = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Linus' },
];
private readonly _nextId = 3;
getAll(): Observable<User[]> {
return of(this._data.slice());
}
create(input: Omit<User, 'id'>): Observable<User> {
const created = { id: this._nextId++, ...input };
this._data = [...this._data, created];
return of(created);
}
}
3) Adapter HTTP (prod)
Implémentation de production branchée sur une API.
// src/users/http-users.adapter.ts
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { UsersPort, User } from './users.port';
@Injectable({
providedIn: 'root',
})
export class HttpUsersAdapter implements UsersPort {
private http = inject(HttpClient);
private baseUrl = '/api/users'; // à adapter à ton backend
getAll(): Observable<User[]> {
return this.http.get<User[]>(this.baseUrl);
}
create(input: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.baseUrl, input);
}
}
4) Brancher l’IoC (Inversion de Contrôle) avec les providers
a) Phase de dév
Pendant toute ta phase développement, tu peux utiliser la version InMemory pour tester.
// src/main.ts
import { bootstrapApplication } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app.component';
import { UsersPort } from './users/users.port';
import { InMemoryUsersAdapter } from './users/inmemory-users.adapter';
// Le token Angular qui identifie ce Port dans l'injecteur
export const USERS_PORT = new InjectionToken<UsersPort>('USERS_PORT');
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
{ provide: USERS_PORT, useClass: InMemoryUsersAdapter },
],
});
b) Pour la mise en production
Avant de mettre en production, quand tout est prêt, tu peux juste switcher sur la version HTTP.
// src/main.ts
import { bootstrapApplication } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app.component';
import { UsersPort } from './users/users.port';
import { HttpUsersAdapter } from './users/http-users.adapter';
// Le token Angular qui identifie ce Port dans l'injecteur
export const USERS_PORT = new InjectionToken<UsersPort>('USERS_PORT');
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
{ provide: USERS_PORT, useClass: HttpUserAdapter },
],
});
5) Le composant UI : signals + nouveau control flow
Le composant ne dépend que du Port. Aucune référence à HTTP ou InMemory ici ✨.
// src/users/users.page.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { USERS_PORT, UsersPort, User } from './users.port';
import { firstValueFrom } from 'rxjs/src';
@Component({
selector: 'users-page',
template: `
<h2>Users</h2>
@if (users()) {
<ul>
@for (u of users(); track u.id) {
<li>{{ u.name }} (id: {{ u.id }})</li>
}
</ul>
} @else {
<p>Chargement…</p>
}
<form (ngSubmit)="add()">
<input [(ngModel)]="name" name="name" placeholder="New user name" />
<button type="submit">Add</button>
</form>
`,
imports: [FormsModule],
})
export class UsersPage {
// 👉🏼 Le composant n'a aucun idée de ce qu'il y a derrière et surtout il s'en moque !
private readonly userPort = inject(USERS_PORT);
protected readonly users = toSignal(this.port.getAll(), { initialValue: null as User[] | null });
name = '';
async add(): Promise<void> {
const value = this.name.trim();
if (!value) return;
await firstValueFrom(this.port.create({ name: value }));
this.name = '';
}
}
6) Vérification rapide (checklist)
- Un Port (
UsersPort+USERS_PORT) - Deux Adapters (
HttpUsersAdapter,InMemoryUsersAdapter) - Un composant qui injecte le Port (
inject(USERS_PORT))
7) Tous les avantages de cette approche
- Découplage fort : ton composant n’a aucune idée si les données viennent d’un backend réel, d’un mock ou d’une base locale.
- Testabilité accrue : tu peux brancher un
InMemoryAdapterou un mock très facilement. - Flexibilité : remplacer l’implémentation selon la route, l’environnement (dev/prod/test) ou même dynamiquement.
- Lisibilité : l’intention métier (
UsersPort) est claire et documentée par le code. - Évolution simplifiée : demain, tu veux utiliser IndexedDB, un cache ou une API GraphQL → tu n’as qu’à créer un nouvel Adapter.
- Maintenabilité : le code métier et l’infrastructure sont bien séparés, ce qui réduit les risques de régressions.
Ça, ce sont les avantages que tu trouveras partout et ils sont vrais.
Si on doit vulgariser un peu, voici ce que cela permet :
- Tu peux coder en offline dans le train, l'avion ou quand ta box ne fonctionne pas.
- Ton homologue backend peut partir en vacances sans que tu doives l'attendre.
- Tu peux très rapidement faire une démo produit à tes clients sans développer toute la stack.
- Quand tu switches d'Adapter, tu es serein, car tes tests ne dépendent pas de ça.
8) Deux approches pour les Ports : Interface + Token VS Classe abstraite
🔹 Approche 1 : Interface + InjectionToken (classique)
L'approche que l'on vient de montrer est la suivante :
export interface UsersPort {
getAll(): Observable<User[]>;
}
export const USERS_PORT = new InjectionToken<UsersPort>('USERS_PORT');
bootstrapApplication(AppComponent, {
providers: [
{ provide: USERS_PORT, useClass: HttpUsersAdapter },
],
});
Usage :
class UsersPage {
private readonly _port = inject(USERS_PORT);
}
🔹 Approche 2 : Classe abstraite + implements (alternative plus simple)
export abstract class UsersPort {
abstract getAll(): Promise<User[]>;
}
// 👉🏼 Attention on garde le `implements` !
export class InMemoryUsersAdapter implements UsersPort {
async getAll(): Promise<User[]> {
return Promise.resolve([]);
}
}
bootstrapApplication(AppComponent, {
providers: [
{ provide: UsersPort, useClass: InMemoryUsersAdapter },
],
});
Usage :
class UsersPage {
private readonly _port = inject(USERS_PORT);
}
La seconde approche est discutable dans le sens où on détourne l'usage d'une classe abstraite pour s'en servir comme d'une interface.
Mais Typescript et Angular ne permettent pas d'injecter directement des interfaces.
Cette approche permet d'éliminer la gestion du token.
De mon côté, j'utilise la seconde approche sans problème.
Conclusion
Pourquoi parler de ce sujet ?
Parce que je le vois encore trop peu utiliser sur les projets sur lesquels j'interviens. De ma fenêtre, il apporte énormément d'avantages sans pour autant être couteux / difficile à mettre en place. Avec Angular, on a quand même la chance d'avoir toute la mécanique d'injection de dépendances de faites. Autant l'exploiter !
L'autre pourquoi, c'est qu'il y a peu, j'ai lu un livre sur comment travailler avec un projet legacy. Notamment ça évoquait les refactoring que personne n'ose, car très difficile, peu documenté et pas testé. Le livre propose un grand nombre de méthodes. Mais celui qu'il te demande toujours d'essayer en premier, c'est l'inversion de contrôle.
👉🏼 Pour en apprendre davantage et mettre en pratique, viens découvrir **EasyAngularKit ** : https://pim.ms/C31g7p2