📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

EasyAngularKit

Formation complète pour maîtriser Angular et développer des applications web modernes.

Navigation

Contact

Légal

© 2026 Easy Angular Kit. Tous droits réservés.