L’architecture hexagonale (ou ports & adaptateurs) part d’une idée simple : les règles de l’application n’ont pas besoin de savoir d’où viennent les données ni comment elles sont affichées. Le domaine est au centre, écrit en TypeScript pur. Autour, les cas d’usage ne parlent au monde extérieur qu’à travers des ports, c’est-à-dire des contrats. En périphérie se trouvent les adaptateurs : HTTP, localStorage, composants Angular. Vous pouvez les remplacer sans toucher au centre.
Dans Angular, l’idée s’accorde très bien avec la DI : une classe abstraite est à la fois le type du port et le token d’injection. { provide: TaskRepository, useClass: HttpTaskRepository } est tout le « câblage » dont vous avez besoin.
HttpClient. Et la logique métier ne se disperse plus dans les composants, où elle est difficile à trouver et à réutiliser.Les couches et la règle de dépendance
Il y a quatre couches, et les imports ne pointent que vers l’intérieur. Le domaine n’importe rien d’extérieur. L’application connaît le domaine et les ports, mais pas les adaptateurs. Les adaptateurs implémentent les ports. Le seul endroit qui lie un port à un adaptateur concret est la composition root : app.config.ts, ou les providers d’une route.
┌──────────────────────── extérieur : adaptateurs ────────────────────┐
│ presentation (driving) infrastructure (driven) │
utilisateur ─▶ │ TasksPage, TaskItem HttpTaskRepository, TaskDto │ ──▶ server
│ │ ┌──────────── application ─────────────┐ ▲ │
│ └──▶│ TaskBoard / AddTask ──▶ TaskRepository│───┘ implémente │
│ │ │ ┌──── domain ────┐ │ │
│ │ └─────▶│ Task, createTask│ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Règle : les flèches d’import pointent UNIQUEMENT vers l’intérieur. Le domaine n’importe rien de l’extérieur.
app.config.ts (composition root) est le seul endroit qui lie TaskRepository à un adaptateur.| Couche | Contient | Peut importer |
|---|---|---|
| domain | entités, règles, erreurs du domaine | rien (uniquement TypeScript) |
| application | ports (classes abstraites), cas d’usage, état pour l’UI | domain, @angular/core (DI, Signals) |
| infrastructure | adaptateurs : HTTP, stockage, DTO, mapping | application (les ports), domain, HttpClient |
| presentation | composants smart et dumb | application, domain (types) — jamais infrastructure |
1. Choisissez le fichier qui fait l’import.
Domain : TypeScript pur
Le domaine contient les entités et les règles : « un titre a au moins 3 caractères », « deux tâches ouvertes ne peuvent pas avoir le même titre ». Il n’importe ni Angular, ni RxJS, ni les API du navigateur. Même l’horloge et la génération d’id arrivent en paramètres, pour que les règles restent déterministes. Le gain principal : le domaine se teste avec de simples fonctions, sans TestBed et sans mocks.
// tasks/domain/task.ts — TypeScript pur : aucun import d’Angular, de RxJS ni du navigateur
export type TaskId = string & { readonly __brand: 'TaskId' };
export interface Task {
readonly id: TaskId;
readonly title: string;
readonly done: boolean;
readonly createdAt: Date;
}
export class DomainError extends Error {
override readonly name = 'DomainError';
}
export function normalizeTitle(raw: string): string {
return raw.trim().replace(/\s+/g, ' ');
}
// `now` et `id` arrivent en paramètres : le domaine ne lit pas l’horloge et ne génère pas d’ids lui-même
export function createTask(raw: string, existing: readonly Task[], now: Date, id: TaskId): Task {
const title = normalizeTitle(raw);
if (title.length < 3) throw new DomainError('Le titre doit contenir au moins 3 caractères.');
if (existing.some((t) => !t.done && t.title.toLowerCase() === title.toLowerCase())) {
throw new DomainError('Il existe déjà une tâche ouverte avec ce titre.');
}
return { id, title, done: false, createdAt: now };
}
export function toggleTask(task: Task): Task {
return { ...task, done: !task.done };
}Ports : des classes abstraites comme tokens
Un port décrit ce dont l’application a besoin (« lister, enregistrer, supprimer »), pas comment. Vous l’écrivez comme une classe abstraite : elle existe à l’exécution, donc inject(TaskRepository) fonctionne directement. Une interface disparaîtrait à la compilation, et il vous faudrait un InjectionToken séparé (voir InjectionToken). Les erreurs font partie du contrat : les adaptateurs traduisent leurs erreurs techniques (timeout, 503) en RepositoryUnavailableError, pour que l’UI ne dépende pas de HttpErrorResponse.
// tasks/application/task-repository.ts — le PORT
import { Task, TaskId } from '../domain/task';
// Classe abstraite, pas une interface : elle existe à l’exécution, c’est donc directement un token de DI.
// Elle n’a pas de @Injectable et ne connaît aucune implémentation.
export abstract class TaskRepository {
abstract list(): Promise<readonly Task[]>;
abstract save(task: Task): Promise<void>; // upsert
abstract remove(id: TaskId): Promise<void>;
}
// Fait partie du contrat : les adaptateurs y traduisent leurs erreurs techniques.
export class RepositoryUnavailableError extends Error {
override readonly name = 'RepositoryUnavailableError';
}Cas d’usage
Un cas d’usage orchestre : il lit via un port, applique les règles du domaine, puis enregistre via un port. Dans Angular, c’est un service ordinaire qui injecte le port, pas l’adaptateur. Pour l’UI, un « board » qui garde l’état dans des Signals (tasks, error, open) est pratique. Les composants ne font que le lire, exactement comme dans State Management.
// tasks/application/add-task.ts — un cas d’usage = une classe avec une seule méthode publique
import { Injectable, inject } from '@angular/core';
import { Task, TaskId, createTask } from '../domain/task';
import { TaskRepository } from './task-repository';
@Injectable({ providedIn: 'root' })
export class AddTask {
private readonly repo = inject(TaskRepository); // le port — ne sait pas quel adaptateur répond
async execute(title: string): Promise<Task> {
const existing = await this.repo.list();
const task = createTask(title, existing, new Date(), crypto.randomUUID() as TaskId); // la règle : dans le domaine
await this.repo.save(task); // E/S : via le port
return task;
}
}Adaptateurs
Un adaptateur extends TaskRepository et fait toutes les E/S : HTTP, localStorage, mémoire. Le mapping DTO → domaine vit ici aussi (is_done: 1 devient done: true, la chaîne ISO devient une Date ; voir Interfaces et modèles). Le format du serveur ne dépasse pas l’adaptateur.
// tasks/infrastructure/http-task-repository.ts — l’ADAPTATEUR HTTP
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, firstValueFrom } from 'rxjs';
import { Task, TaskId } from '../domain/task';
import { RepositoryUnavailableError, TaskRepository } from '../application/task-repository';
import { TaskDto, taskFromDto, taskToDto } from './task-dto';
@Injectable()
export class HttpTaskRepository extends TaskRepository {
private readonly http = inject(HttpClient);
private readonly url = '/api/tasks';
async list(): Promise<readonly Task[]> {
const dtos = await this.call(this.http.get<TaskDto[]>(this.url));
return dtos.map(taskFromDto); // DTO → domaine, ici et nulle part ailleurs
}
async save(task: Task): Promise<void> {
await this.call(this.http.put<TaskDto>(`${this.url}/${task.id}`, taskToDto(task)));
}
async remove(id: TaskId): Promise<void> {
await this.call(this.http.delete<void>(`${this.url}/${id}`));
}
// HttpErrorResponse ne quitte pas l’adaptateur : le cœur reçoit l’erreur du port
private async call<T>(request: Observable<T>): Promise<T> {
try {
return await firstValueFrom(request);
} catch (e) {
if (e instanceof HttpErrorResponse) throw new RepositoryUnavailableError(`HTTP ${e.status}`);
throw e;
}
}
}{ provide: TaskRepository, useClass: InMemoryTaskRepository } — Une Map en mémoire — les données disparaissent quand l’injecteur est détruit.
TasksPanel → TaskBoard → TaskRepository- Aucune tâche.
- 11:33.2DI TaskRepository → InMemoryTaskRepository
- 11:33.2InMemoryTaskRepository list() → 0 (Map)
Composition root : là où vous choisissez l’adaptateur
La liaison port → adaptateur se trouve à un seul endroit. Globalement, dans app.config.ts. Localement, dans les providers d’une route, où l’adaptateur vit aussi longtemps que la route (voir Providers et hiérarchie des injecteurs). Pour garder privés les détails de la feature, celle-ci expose une fonction provideTasks(). La composition root choisit seulement « http » ou « local ».
// app.config.ts — COMPOSITION ROOT : le seul fichier qui sait quel adaptateur on utilise
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: TaskRepository, useClass: HttpTaskRepository },
],
};Tests : c’est ici que le gain se voit
Vous avez trois niveaux de tests, tous peu coûteux :
- le domaine, avec des fonctions pures ;
- les cas d’usage, avec l’adaptateur en mémoire à la place de HTTP ;
- les adaptateurs, avec un test de contrat : la même suite exécutée sur chaque implémentation du port.
Le test de contrat attrape exactement les bugs qui passeraient sinon : un adaptateur qui renvoie des DTO au lieu d’entités, ou un autre qui duplique au lieu de mettre à jour.
// Test de cas d’usage : l’adaptateur en mémoire à la place de HTTP
import { TestBed } from '@angular/core/testing';
describe('AddTask', () => {
let addTask: AddTask;
let repo: InMemoryTaskRepository;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }],
});
addTask = TestBed.inject(AddTask);
repo = TestBed.inject(TaskRepository) as InMemoryTaskRepository;
});
it('enregistre via le port', async () => {
await addTask.execute('Écrire des tests');
expect((await repo.list()).map((t) => t.title)).toEqual(['Écrire des tests']);
});
it('n’enregistre rien si une règle du domaine est violée', async () => {
await expect(addTask.execute('ab')).rejects.toThrow(DomainError);
expect(await repo.list()).toEqual([]);
});
});| Test (le même pour tous) | En mémoire | localStorage | HTTP | HTTP sans mapping |
|---|---|---|---|---|
| un nouveau repository est vide | · | · | · | · |
| save() puis list() renvoie la tâche | · | · | · | · |
| save() avec le même id met à jour, sans dupliquer | · | · | · | · |
| remove() ne supprime que la tâche demandée | · | · | · | · |
| done est un booléen, createdAt est une Date | · | · | · | · |
| renvoie des entités du domaine, pas des DTO | · | · | · | · |
Comment faire respecter la règle en équipe
Une règle d’architecture qui n’existe que dans votre tête ne survit pas à la première deadline. Mettez-la dans le lint. Avec no-restricted-imports d’ESLint, découpé par couche, tout mauvais import fait échouer la CI. Dans un monorepo Nx, vous faites la même chose avec des tags et @nx/enforce-module-boundaries (voir Monorepo, bibliothèques et frontières et Barrels, alias et frontières).
// La règle de dépendance, vérifiée à chaque commit (no-restricted-imports d’ESLint avec regex)
export default [
{
files: ['src/app/features/*/domain/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '^(@angular/|rxjs)', message: 'Le domaine est du TypeScript pur : pas d’Angular, pas de RxJS.' },
{ regex: '/(application|infrastructure|ui)/', message: 'Le domaine ne dépend pas des couches externes.' },
] }],
},
},
{
files: ['src/app/features/*/application/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/(infrastructure|ui)/', message: 'Application dépend des ports, pas des adaptateurs ni de l’UI.' },
{ regex: '^@angular/common/http', message: 'HTTP uniquement dans infrastructure.' },
] }],
},
},
{
files: ['src/app/features/*/ui/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/infrastructure/', message: 'L’UI injecte le port ou le cas d’usage, pas l’adaptateur.' },
] }],
},
},
];
// Dans un monorepo Nx : domain/application/infrastructure comme bibliothèques séparées, avec des tags
// et @nx/enforce-module-boundaries (voir la page Monorepo).Quand OUI, quand NON
- De vraies règles métier (prix, validations, flux) qui doivent être testées et réutilisées.
- Un backend instable ou changeant : une nouvelle API, une migration REST → GraphQL, un mode hors ligne.
- La même logique sur plusieurs sources de données (serveur, cache local, un mock pour les démos).
- De grandes applications durables, avec plusieurs équipes sur la même feature.
- Des écrans CRUD qui affichent simplement ce que renvoie le serveur. Un service avec
httpResourcesuffit. - Prototypes et petites applications : quatre couches pour deux formulaires ralentissent l’équipe.
- Quand le « port » n’a qu’une seule implémentation et que vous ne la testerez pas séparément. L’abstraction n’apporte rien.
- La logique d’UI pure (survol, déploiement) : elle reste dans le composant.
Une variante pragmatique convient à la plupart des projets : un domaine pur pour les règles qui comptent, un port seulement là où vous avez, ou aurez, plusieurs implémentations, et le reste dans la structure habituelle data-access / ui (voir Structure des dossiers).
Pièges fréquents
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) sur la classe abstraite paraît pratique, mais le port finit par dépendre de l’infrastructure, et HttpClient atterrit dans le bundle même là où vous ne l’utilisez pas. Le choix de l’adaptateur appartient à la composition root.// ✗ « Implémentation par défaut » placée sur le port
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) // le port importe l’adaptateur !
export abstract class TaskRepository { /* ... */ }
// Application → infrastructure : la règle de dépendance est violée, et HttpClient
// finit dans le bundle même pour l’application qui n’utilise que localStorage.
// ✓ Le port reste vide ; le choix vit dans la composition root
export abstract class TaskRepository { /* ... */ }
providers: [{ provide: TaskRepository, useClass: HttpTaskRepository }]- Des DTO qui fuient dans le domaine. Si le type du serveur atteint les composants, tout changement d’API se propage partout. Le mapping vit dans l’adaptateur.
- Des composants qui injectent l’adaptateur concret (
inject(HttpTaskRepository)). Dans ce cas, vous n’avez plus de port du tout. Injectez le port ou le cas d’usage. - Un domaine « anémique ». Des entités sans règles, avec toute la logique dans des services : vous avez les couches, mais pas le bénéfice.
- Des erreurs techniques qui atteignent l’UI. Un
HttpErrorResponsedans un composant signifie que l’UI sait qu’il s’agit de HTTP. Traduisez les erreurs dans l’adaptateur. - Une horloge et des ids lus dans le domaine (
new Date(),crypto.randomUUID()). Les règles deviennent difficiles à tester. Passez-les en paramètres.
Aide-mémoire
COUCHES (les imports ne pointent que vers l’intérieur)
domain Task, createTask() TypeScript pur — ni Angular, ni RxJS, ni navigateur
application TaskRepository (port) classe abstraite = type + token de DI
AddTask / TaskBoard inject(TaskRepository), Signals pour l’UI
infrastructure HttpTaskRepository extends TaskRepository, DTO ↔ domaine, erreurs traduites
presentation TasksPage, TaskItem injecte application, jamais infrastructure
composition app.config / route.providers { provide: TaskRepository, useClass: Http… }
RECETTES
changer d’adaptateur un autre useClass dans la composition root (ou provideTasks('local'))
test de cas d’usage providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }]
test d’adaptateur contrat partagé + HttpTestingController pour le mapping HTTP
DTO uniquement dans infrastructure ; le port parle en entités
erreurs l’adaptateur lance RepositoryUnavailableError, pas HttpErrorResponse
QUAND S’EN ABSTENIR
CRUD sans règles, petite application, prototype → httpResource dans le composant + mapping dans parse