Clean Architecture en Angular : Structure Scalable et Maintenable
La Clean Architecture, proposée par Robert C. Martin (Uncle Bob), définit un modèle d'organisation du code dans lequel les règles métier sont isolées des détails d'implémentation. Appliquée à Angular, cette architecture permet de construire des applications qui résistent au temps, facilitent le testing et permettent de remplacer frameworks, bibliothèques et infrastructures sans réécrire la logique de domaine.
Dans cet article, nous verrons comment structurer un projet Angular en suivant les principes de Clean Architecture, avec des couches séparées pour Domain, Application, Infrastructure et Presentation. Nous aborderons la dependency rule, l'inversion des dépendances et des patterns concrets comme Use Cases, Repository et Mapper.
Ce que vous apprendrez
- Les principes fondamentaux de Clean Architecture
- Comment organiser les dossiers dans un projet Angular
- Le pattern Use Case / Interactor
- Repository Pattern pour l'accès aux données
- Mapping entre Domain Model et DTO
- Dependency Injection et inversion des dépendances
- Application pratique des principes SOLID
Principes Fondamentaux
Clean Architecture repose sur un concept central : les dépendances pointent toujours vers l'intérieur. Les couches externes (UI, base de données, API) dépendent des couches internes (logique métier, entités de domaine), jamais l'inverse. Ce principe, connu sous le nom de Dependency Rule, garantit que le cœur de l'application reste indépendant de toute technologie spécifique.
Les Quatre Couches
| Couche | Responsabilité | Exemples en Angular |
|---|---|---|
| Domain | Entités, Value Objects, interfaces Repository | Classes TypeScript pures, aucune dépendance Angular |
| Application | Use Cases, orchestration de la logique | Services qui coordonnent les opérations du domaine |
| Infrastructure | Implémentations concrètes, accès aux données, API | HttpClient, localStorage, Firebase, adaptateurs |
| Presentation | UI, composants, gestion de l'état visuel | Composants Angular, templates, pipes |
Structure des Dossiers
La structure des dossiers reflète directement les couches architecturales. Chaque fonctionnalité de l'application est organisée selon le même schéma, rendant le projet prévisible et navigable même pour les développeurs qui rejoignent l'équipe ultérieurement.
src/
app/
core/ # Services singleton, guards, intercepteurs
interceptors/
guards/
features/
orders/ # Feature : Gestion des Commandes
domain/
models/
order.model.ts # Entité de domaine
order-item.model.ts # Value Object
repositories/
order.repository.ts # Interface (port)
application/
use-cases/
create-order.use-case.ts
get-orders.use-case.ts
cancel-order.use-case.ts
dto/
create-order.dto.ts
order-response.dto.ts
mappers/
order.mapper.ts
infrastructure/
repositories/
order-http.repository.ts # Implémentation HTTP
order-mock.repository.ts # Implémentation mock
adapters/
order-api.adapter.ts
presentation/
pages/
order-list/
order-detail/
components/
order-card/
order-form/
state/
orders.store.ts
shared/ # Composants et utilitaires partagés
ui/
pipes/
directives/
La Couche Domain
La couche Domain contient les entités métier et les interfaces qui définissent les contrats pour l'accès aux données. Cette couche n'a aucune dépendance envers Angular, RxJS ou toute bibliothèque externe. Ce sont des classes et interfaces TypeScript pures.
Entités de Domaine
Les entités représentent les concepts centraux du métier. Elles contiennent des règles de validation et une logique intrinsèque au domaine. Ce ne sont pas de simples conteneurs de données (anemic model), mais des objets riches en comportement.
// domain/models/order.model.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus = 'draft',
public readonly createdAt: Date = new Date()
) {
if (_items.length === 0) {
throw new Error('Une commande doit contenir au moins un article');
}
}
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray<OrderItem> { return [...this._items]; }
get total(): number {
return this._items.reduce((sum, item) => sum + item.subtotal, 0);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error('Seules les commandes en brouillon peuvent être confirmées');
}
this._status = 'confirmed';
}
cancel(): void {
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Impossible d\'annuler une commande déjà expédiée');
}
this._status = 'cancelled';
}
addItem(item: OrderItem): void {
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
throw new Error('Produit déjà présent. Mettez à jour la quantité.');
}
this._items.push(item);
}
}
Value Objects
Les Value Objects sont des objets immuables définis par leur valeur, pas par une identité. Deux Value Objects avec les mêmes attributs sont considérés comme égaux. Ils sont idéaux pour représenter des concepts comme le prix, l'adresse, la quantité.
// domain/models/order-item.model.ts
export class OrderItem {
public readonly subtotal: number;
constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number
) {
if (quantity <= 0) {
throw new Error('La quantité doit être supérieure à zéro');
}
if (unitPrice < 0) {
throw new Error('Le prix unitaire ne peut pas être négatif');
}
this.subtotal = quantity * unitPrice;
}
equals(other: OrderItem): boolean {
return this.productId === other.productId
&& this.quantity === other.quantity
&& this.unitPrice === other.unitPrice;
}
}
Interface Repository (Port)
L'interface du repository définit le contrat pour l'accès aux données. Elle réside dans la couche Domain car elle représente ce dont le domaine a besoin, sans spécifier comment les données sont obtenues. C'est l'essence de l'inversion des dépendances.
// domain/repositories/order.repository.ts
import { Observable } from 'rxjs';
import { Order } from '../models/order.model';
export abstract class OrderRepository {
abstract getAll(): Observable<Order[]>;
abstract getById(id: string): Observable<Order>;
abstract getByCustomer(customerId: string): Observable<Order[]>;
abstract save(order: Order): Observable<Order>;
abstract delete(id: string): Observable<void>;
}
Pourquoi une classe abstraite et non une interface ?
Dans Angular, le système de Dependency Injection nécessite un token au runtime. Les interfaces TypeScript sont supprimées lors de la compilation et ne peuvent pas être utilisées comme token DI. Les classes abstraites existent au runtime et servent à la fois de contrat et de token pour l'injection.
La Couche Application : Use Cases
La couche Application contient les cas d'utilisation (ou interactors), qui orchestrent la logique métier en coordonnant entités de domaine et repositories. Chaque use case représente une seule opération que l'application peut exécuter.
Structure d'un Use Case
// application/use-cases/create-order.use-case.ts
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { CreateOrderDto } from '../dto/create-order.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { OrderMapper } from '../mappers/order.mapper';
@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
execute(dto: CreateOrderDto): Observable<OrderResponseDto> {
const items = dto.items.map(
i => new OrderItem(i.productId, i.productName, i.quantity, i.unitPrice)
);
const order = new Order(crypto.randomUUID(), dto.customerId, items);
return this.orderRepo.save(order).pipe(
map(saved => OrderMapper.toResponse(saved))
);
}
}
DTO et Mapper
Les Data Transfer Object (DTO) définissent la forme des données en entrée et en sortie de la couche Application. Les Mapper convertissent entre entités de domaine et DTO, maintenant les deux mondes séparés.
// application/dto/create-order.dto.ts
export interface CreateOrderDto {
customerId: string;
items: { productId: string; productName: string; quantity: number; unitPrice: number; }[];
}
// application/mappers/order.mapper.ts
export class OrderMapper {
static toResponse(order: Order): OrderResponseDto {
return {
id: order.id, customerId: order.customerId, status: order.status,
items: order.items.map(item => ({
productId: item.productId, productName: item.productName,
quantity: item.quantity, unitPrice: item.unitPrice, subtotal: item.subtotal,
})),
total: order.total, createdAt: order.createdAt.toISOString(),
};
}
}
La Couche Infrastructure
La couche Infrastructure fournit les implémentations concrètes des interfaces définies dans le Domain. C'est ici que résident les appels HTTP, la persistance dans localStorage, l'intégration avec Firebase et tout autre détail technologique.
// infrastructure/repositories/order-http.repository.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
@Injectable()
export class OrderHttpRepository extends OrderRepository {
private readonly baseUrl = '/api/orders';
constructor(private readonly http: HttpClient) { super(); }
getAll(): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(this.baseUrl).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
save(order: Order): Observable<Order> {
const payload = this.toApi(order);
return this.http.post<OrderApiResponse>(this.baseUrl, payload).pipe(
map(r => this.toDomain(r))
);
}
private toDomain(response: OrderApiResponse): Order {
const items = response.items.map(
i => new OrderItem(i.product_id, i.product_name, i.qty, i.price)
);
return new Order(response.id, response.customer_id, items,
response.status as any, new Date(response.created_at));
}
}
Configuration de la Dependency Injection
Le lien entre interface et implémentation se fait via le système DI d'Angular.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{ provide: OrderRepository, useClass: OrderHttpRepository },
],
};
Avantage : Substituabilité
En changeant une seule ligne de configuration, vous pouvez passer de OrderHttpRepository
à OrderMockRepository pour les tests, ou à OrderFirebaseRepository
si vous migrez le backend. Le reste de l'application ne change pas.
La Couche Presentation
La couche Presentation comprend les composants Angular, les templates et la gestion de l'état visuel. Les composants interagissent exclusivement avec les Use Cases de la couche Application.
// presentation/pages/order-list/order-list.component.ts
import { Component, OnInit, signal } from '@angular/core';
@Component({
selector: 'app-order-list',
standalone: true,
templateUrl: './order-list.component.html',
})
export class OrderListComponent implements OnInit {
orders = signal<OrderResponseDto[]>([]);
loading = signal(true);
error = signal<string | null>(null);
constructor(private readonly getOrders: GetOrdersUseCase) {}
ngOnInit(): void {
this.getOrders.execute().subscribe({
next: (data) => { this.orders.set(data); this.loading.set(false); },
error: (err) => { this.error.set('Erreur lors du chargement des commandes'); this.loading.set(false); },
});
}
}
Principes SOLID en Pratique
Clean Architecture est le véhicule naturel pour appliquer les principes SOLID dans un projet Angular.
| Principe | Application dans Clean Architecture |
|---|---|
| Single Responsibility | Chaque Use Case a une seule responsabilité. Chaque couche a un rôle bien défini. |
| Open/Closed | Nouvelles implémentations du Repository (ex. Firebase) sans modifier le code existant. |
| Liskov Substitution | Toute implémentation du Repository peut remplacer celle actuelle sans effets secondaires. |
| Interface Segregation | Les DTO n'exposent que les données nécessaires. Les interfaces des repositories sont spécifiques par feature. |
| Dependency Inversion | Les couches hautes dépendent d'abstractions, pas d'implémentations concrètes. |
Testing dans Clean Architecture
L'un des principaux avantages de Clean Architecture est la testabilité. Chaque couche peut être testée en isolation grâce à la séparation des dépendances.
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let mockRepo: jasmine.SpyObj<OrderRepository>;
beforeEach(() => {
mockRepo = jasmine.createSpyObj('OrderRepository', ['save']);
useCase = new CreateOrderUseCase(mockRepo);
});
it('should create an order and return a response DTO', (done) => {
const dto = { customerId: 'cust-1', items: [{
productId: 'prod-1', productName: 'Widget', quantity: 2, unitPrice: 25.00
}] };
const savedOrder = new Order('order-123', 'cust-1',
[new OrderItem('prod-1', 'Widget', 2, 25.00)]);
mockRepo.save.and.returnValue(of(savedOrder));
useCase.execute(dto).subscribe(result => {
expect(result.id).toBe('order-123');
expect(result.total).toBe(50);
done();
});
});
});
Erreurs Courantes à Éviter
| Erreur | Conséquence | Solution |
|---|---|---|
| Utiliser HttpClient directement dans les composants | Couplage entre UI et infrastructure | Toujours passer par les Use Cases et le Repository |
| Entités de domaine anémiques | La logique métier finit dans les services | Enrichir les entités avec des validations et des comportements |
| DTO correspondant 1:1 aux entités | Modifier le domaine impacte directement l'API | Maintenir DTO et modèles indépendants, utiliser des Mappers |
| Dépendances circulaires entre couches | Violation de la Dependency Rule | Vérifier que les dépendances pointent toujours vers le Domain |
| Over-engineering pour des applications simples | Complexité non justifiée | Évaluer la complexité du projet avant d'adopter Clean Architecture |
Quand Adopter Clean Architecture
Évaluation Rapide
| Scénario | Clean Architecture ? | Alternative |
|---|---|---|
| Prototype / MVP | Non | Structure simple feature-based |
| Application CRUD simple | Probablement non | Architecture en couches (MVC) |
| Application entreprise avec logique complexe | Oui | - |
| Équipe large, maintenance long terme | Oui | - |
| Possible changement de backend | Oui | - |
Conclusion
Clean Architecture appliquée à Angular transforme la façon dont nous structurons les applications frontend. En séparant le domaine de la technologie, nous obtenons un code plus testable, maintenable et résistant aux changements. La Dependency Rule garantit que le cœur de l'application reste stable même lorsqu'on change de framework, de bibliothèques ou de services externes.
Le coût initial en termes de boilerplate est compensé par la facilité de maintenance à long terme, la clarté de la structure et la possibilité de faire travailler des équipes différentes sur des couches indépendantes. Pour les applications entreprise avec une logique métier significative, Clean Architecture est un investissement qui porte ses fruits dans le temps.







