📧 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

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.