~4 min de lecture
httpResource est-il un anti-pattern ? Clean architecture et pragmatisme en Angular
Angular 19.2 a introduit httpResource, une API déclarative pour charger des données HTTP via les signals. Simple, élégante… mais qui fait grincer des dents les puristes de la clean architecture.
Faut-il l'adopter ou la fuir ? La réponse dépend de ton contexte. Voyons ça en détail.
Ce que httpResource change
Avant httpResource, charger des données impliquait un service, un HttpClient, et souvent un Observable à gérer :
// Avant : le pattern classique
@Component({
template: `
@if (loading()) {
<app-spinner />
}
@if (user(); as user) {
<h1>{{ user.name }}</h1>
}
@if (error()) {
<app-error [message]="error()!" />
}
`,
})
export class UserProfile {
private readonly userService = inject(UserService);
readonly loading = signal(true);
readonly error = signal<string | undefined>(undefined);
readonly user = signal<User | undefined>(undefined);
constructor() {
this.userService.getById('123').subscribe({
next: (user) => {
this.user.set(user);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.loading.set(false);
},
});
}
}
Avec httpResource, tout ça se réduit à :
// Après : httpResource
@Component({
template: `
@if (user.isLoading()) {
<app-spinner />
}
@if (user.hasValue()) {
<h1>{{ user.value()!.name }}</h1>
}
@if (user.error()) {
<app-error [message]="user.error()" />
}
`,
})
export class UserProfile {
readonly user = httpResource<User>({
url: '/api/users/123',
});
}
C'est séduisant. Mais regarde bien : l'URL de l'API est codée en dur dans le component. On a fusionné la couche présentation et la couche données en une seule ligne.
Le problème architectural
En clean architecture, les responsabilités sont séparées :
┌─────────────────────────────────────────────────┐
│ Component (Présentation) │
│ → Affiche les données, gère les interactions │
└──────────────────────┬──────────────────────────┘
│ appelle
┌──────────────────────▼──────────────────────────┐
│ Service / Use Case (Logique métier) │
│ → Orchestre, transforme, applique des règles │
└──────────────────────┬──────────────────────────┘
│ utilise
┌──────────────────────▼──────────────────────────┐
│ Repository / Data Source (Accès données) │
│ → Sait comment parler à l'API │
└─────────────────────────────────────────────────┘
Avec httpResource dans un component, tu court-circuites tout :
┌─────────────────────────────────────────────────┐
│ Component │
│ → Affiche ET sait parler à l'API │
│ → httpResource('/api/users/123') │
└─────────────────────────────────────────────────┘
Concrètement, ça veut dire :
// ❌ Le component connaît l'URL, le protocole, la structure de l'API
export class UserProfile {
private readonly userId = input.required<string>();
readonly user = httpResource<User>({
url: () => `/api/v2/users/${this.userId()}`,
});
}
Le jour où ton API passe de /api/v2/users à /api/v3/members, tu modifies un component. En clean architecture, tu modifies un repository, et rien d'autre ne bouge.
Mais la clean architecture a un coût
Soyons honnêtes. Pour une page qui affiche une liste de produits sans aucune logique métier, voilà ce que donne le pattern "propre" :
// product.type.ts
type Product = {
id: string;
name: string;
price: number;
};
// product.repository.ts
interface ProductRepository {
getAll(): Observable<Product[]>;
}
// product-http.repository.ts
@Injectable({ providedIn: 'root' })
export class ProductHttpRepository implements ProductRepository {
private readonly http = inject(HttpClient);
getAll(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products');
}
}
// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly repo = inject(ProductHttpRepository);
getAll(): Observable<Product[]> {
return this.repo.getAll(); // ... pass-through
}
}
// product-list.ts
export class ProductList {
private readonly productService = inject(ProductService);
readonly products = toSignal(this.productService.getAll());
}
Quatre fichiers. Une interface. Un service qui ne fait que passer le relais. Pour afficher une liste.
Compare avec :
// product-list.ts
export class ProductList {
readonly products = httpResource<Product[]>({
url: '/api/products',
});
}
La différence de productivité est réelle. Et sur un projet avec 50 endpoints CRUD, ces couches "vides" s'accumulent vite.
La bonne approche : adapter selon la complexité
L'erreur, c'est de choisir une seule stratégie pour tout. Voici trois niveaux selon la complexité réelle de ton cas.
Niveau 1 — Lecture simple sans logique métier
Le cas parfait pour httpResource : un affichage direct, pas de transformation, pas de cache.
// ✅ httpResource directement dans le component
@Component({
selector: 'app-country-list',
template: `
@if (countries.hasValue()) {
@for (country of countries.value()!; track country.code) {
<span>{{ country.name }}</span>
}
}
`,
})
export class CountryList {
readonly countries = httpResource<Country[]>({
url: '/api/countries',
});
}
Pourquoi c'est acceptable : la liste des pays est une donnée de référence. Pas de logique métier. Si l'URL change, tu n'as qu'un endroit à modifier. Le risque d'évolution complexe est quasi nul.
Niveau 2 — httpResource dans un service
Dès que plusieurs components partagent la même donnée, ou que tu veux centraliser les URLs, déplace httpResource dans un service en tant que propriété readonly :
// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly apiUrl = '/api/products';
readonly products = httpResource<Product[]>({
url: this.apiUrl,
});
}
// product-list.ts
@Component({
template: `
@if (productService.products.hasValue()) {
@for (product of productService.products.value()!; track product.id) {
<app-product-card [product]="product" />
}
}
`,
})
export class ProductList {
protected readonly productService = inject(ProductService);
}
Le component ne connaît plus l'URL. Le service centralise l'accès aux données et la resource est partagée : tous les components qui injectent ProductService accèdent à la même instance. C'est un bon compromis pour 80% des cas.
Niveau 3 — Service réactif avec état partagé
Quand tu as besoin de cache, de transformations, de logique métier, ou d'un état partagé entre plusieurs components :
// cart.service.ts
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly http = inject(HttpClient);
// État centralisé
private readonly cartItems = signal<CartItem[]>([]);
readonly items = this.cartItems.asReadonly();
readonly totalPrice = computed(() =>
this.cartItems().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly isEmpty = computed(() => this.cartItems().length === 0);
loadCart(): void {
this.http.get<CartItem[]>('/api/cart').subscribe((items) => {
this.cartItems.set(items);
});
}
addItem(productId: string, quantity: number): void {
this.http
.post<CartItem>('/api/cart/items', { productId, quantity })
.subscribe((newItem) => {
this.cartItems.update((items) => [...items, newItem]);
});
}
removeItem(itemId: string): void {
this.http.delete(`/api/cart/items/${itemId}`).subscribe(() => {
this.cartItems.update((items) => items.filter((i) => i.id !== itemId));
});
}
}
// cart.ts
@Component({
template: `
@for (item of cartService.items(); track item.id) {
<app-cart-item
[item]="item"
(remove)="cartService.removeItem(item.id)"
/>
}
<p>Total : {{ cartService.totalPrice() | currency }}</p>
`,
})
export class Cart {
protected readonly cartService = inject(CartService);
constructor() {
this.cartService.loadCart();
}
}
Ici, httpResource ne convient pas : tu as des mutations (POST, DELETE), un état partagé, et de la logique métier (calcul du total). HttpClient classique reprend ses droits.
Et pour les cas avancés ? Le pattern Repository
Si ton app a un domaine riche (DDD, logique métier complexe, multiples sources de données), le repository pattern reste pertinent :
// order.type.ts
type Order = {
id: string;
items: OrderItem[];
status: OrderStatus;
total: number;
createdAt: Date;
};
type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
type CreateOrderRequest = {
items: { productId: string; quantity: number }[];
shippingAddressId: string;
};
// order.repository.ts — le contrat
interface OrderRepository {
getById(id: string): Observable<Order>;
getByStatus(status: OrderStatus): Observable<Order[]>;
create(request: CreateOrderRequest): Observable<Order>;
cancel(id: string): Observable<void>;
}
// order-http.repository.ts — l'implémentation
@Injectable({ providedIn: 'root' })
export class OrderHttpRepository implements OrderRepository {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/orders';
getById(id: string): Observable<Order> {
return this.http.get<Order>(`${this.apiUrl}/${id}`).pipe(
map((order) => ({
...order,
createdAt: new Date(order.createdAt),
}))
);
}
getByStatus(status: OrderStatus): Observable<Order[]> {
return this.http.get<Order[]>(this.apiUrl, {
params: { status },
});
}
create(request: CreateOrderRequest): Observable<Order> {
return this.http.post<Order>(this.apiUrl, request);
}
cancel(id: string): Observable<void> {
return this.http.patch<void>(`${this.apiUrl}/${id}`, {
status: 'cancelled',
});
}
}
// order.service.ts — la logique métier
@Injectable({ providedIn: 'root' })
export class OrderService {
private readonly repo = inject(OrderHttpRepository);
canCancel(order: Order): boolean {
return order.status === 'draft' || order.status === 'confirmed';
}
cancel(order: Order): Observable<void> {
if (!this.canCancel(order)) {
throw new Error(`Cannot cancel order in status: ${order.status}`);
}
return this.repo.cancel(order.id);
}
getActiveOrders(): Observable<Order[]> {
return this.repo.getByStatus('confirmed').pipe(
map((orders) => orders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()))
);
}
}
Ici, la séparation a du sens : le service contient de vraies règles métier (canCancel), le repository isole les détails HTTP, et chaque couche a une raison d'exister.
Guide de décision
| Question | Oui → | Non → |
|---|---|---|
| Lecture seule, un seul component ? | httpResource dans le component |
↓ |
| Lecture seule, plusieurs components ? | httpResource dans un service |
↓ |
| Mutations (POST, PUT, DELETE) ? | HttpClient dans un service |
↓ |
| État partagé / cache nécessaire ? | Service réactif avec signals | ↓ |
| Logique métier complexe ? | Repository + Service dédié | — |
Les pièges à éviter
1. Le service pass-through inutile
// ❌ Ce service n'apporte rien
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
getAll(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
// ✅ Autant utiliser httpResource directement
export class UserList {
readonly users = httpResource<User[]>({ url: '/api/users' });
}
Si ton service ne fait que return this.http.get(...) sans aucune logique, il n'existe que par dogme.
2. httpResource avec des mutations
// ❌ httpResource est fait pour la lecture
// Il n'y a pas de .post() ou .delete() sur httpResource
// Pour les mutations, utilise HttpClient
httpResource est un outil de lecture déclarative. Ne cherche pas à lui faire faire ce qu'il n'est pas conçu pour faire.
3. L'over-engineering systématique
// ❌ Repository + Service + Interface pour afficher une liste statique
// de 10 catégories qui ne changent jamais
// ✅
readonly categories = httpResource<Category[]>({ url: '/api/categories' });
La clean architecture n'est pas une fin en soi. C'est un outil pour gérer la complexité. Si la complexité n'existe pas, l'outil est superflu.
En résumé
httpResource n'est pas un anti-pattern. C'est un outil avec un périmètre précis : la lecture déclarative de données simples.
L'anti-pattern, c'est de l'utiliser partout sans réfléchir. Ou, à l'inverse, de le refuser par dogme et d'empiler des couches vides "au cas où".
La bonne question n'est pas "httpResource ou clean architecture ?". C'est : quelle est la complexité réelle de mon cas ? La réponse guide naturellement vers le bon niveau d'abstraction.