Créez des façades

Pendant des années, l'écrasante majorité des formateurs (moi y compris) ont enseigné que pour communiquer avec votre API, vous pouvez injecter votre service dans votre component. Pour des applications simples, ça reste tout à fait possible, mais dès que votre application commence à gagner en complexité, ajouter une couche intermédiaire entre components et services devient intéressant : il s'agit des façades.

Qu'est-ce qu'une façade ?

À la base, le design pattern façade fournit une interface simplifiée pour interagir avec un système complexe. Un exemple no-code : quand vous tournez la clef (ou appuyez sur un bouton) pour démarrer votre voiture, les mécanismes derrière sont terriblement complexes : la clef (ou le bouton) est la façade. Ce qui vous intéresse, c'est que la voiture démarre : le comment est géré par le processus mis en place par les fabricants.

Pour revenir dans le monde du développement, imaginez un système de réservation de vols : lorsque l'utilisateur cherche un vol, on veut :

  • afficher un loader pendant le chargement

  • si aucun vol n'est trouvé, afficher un toast

  • si des vols sont trouvés, naviguer vers la page de sélection de vol

  • enregistrer les critères de recherche dans les "recherches récentes"

  • envoyer une requête à un système de monitoring

Au fond, toutes ces étapes et fonctionnalités font partie de "chercher un vol". Le component ne doit absolument pas connaitre toute cette implémentation : en ce qui le concerne, il doit juste envoyer sa recherche et attendre les résultats. On ne veut pas non plus mettre tout cela dans FlightsService. Ça lui donnerait trop de responsabilités, car il doit aussi s'occuper des interactions avec l'API. C'est donc la situation parfaite pour une façade.

Séparez l'intention de l'implémentation

Cette couche intermédiaire permet de décrire l'intention — le quoi — en le séparant de son implémentation — le comment — et surtout de ses détails d'implémentation.

Si on reprend l'exemple ci-dessus, on pourrait imaginer quelque chose comme ceci :

export abstract class FlightSearchFacade {
    foundFlights(): Signal<Flight[]>;
    findFlight(searchParams: FlightSearchParams): void;
    searching(): Signal<boolean>;
}

export class StateFlightSearchFacade implements FlightSearchFacade {
    
    private _foundFlights = signal<Flight[]>([]);
    private _searching = signal(false);
    
    private flightSearch = inject(FlightSearch);
    private userProfile = inject(UserProfile);
    private metrics = inject(Metrics);
    private notifications = inject(Notifications);
    
    foundFlights(): Signal<Flight[]> {
        return this._foundFlights.asReadonly();
    }
    
    findFlight(searchParams: FlightSearchParams): void {
        this._searching.set(true);
        this.userProfile.addRecentSearch(searchParams);
        this.flightSearch.performSearch(searchParams).pipe(
            tap(flights => {
                if (flights.length === 0) {
                    this.notifications.showNotification('No flights found.')
                }
                this._foundFlights.set(flights);
                this.metrics.addToTotalFlightsFound(flights.length);
                this._searching.set(false);
            }),
            catchError(err => {
                this.notifications.showError(err);
                return EMPTY;
            })
        ).subscribe();
    }
    
    searching(): Signal<boolean> {
        return this._searching.asReadonly();
    }
}

Dans cet exemple, ce qui intéresse l'utilisateur, ce sont les méthodes publiques de la façade :

  • je veux voir les vols trouvés par ma recherche

  • je veux chercher un vol

  • je veux savoir si une recherche est en cours

Ce sont des workflow métier, des use case, des user stories…vous l'aurez compris, une façade parle métier, et agit technique. C'est là où vivent les règles métiers dont dépend votre application. Chaque méthode est la traduction technique du besoin métier.

Cette interface simplifiée permet également de modifier l'implémentation facilement et de créer un pivot point pour les tests.

Comme vous pouvez le constater, les façades effectuent donc l'orchestration des services, permettant aux services de se concentrer sur le seul flux de données dont ils sont responsables.

Elles peuvent également s'occuper du caching des données — elles décident s'il faut recharger les données depuis le serveur ou non :

export class StateProductFacade implements ProductFacade {
    
    private _products = signal<Product[]>([]);
    
    allProducts(): Signal<Product[]> {
        if (this.productCacheIsStale()) {
            this.loadProductsFromServer();
        }
        return this._products.asReadonly();
    }
    
    private productCacheIsStale(): boolean {
        // determine whether stored data is still fresh
    }
    
    private loadProductsFromServer(): void {
        // call service to get products, update value of _products Signal
    }
}

Dans les exemples ci-dessus, la façade sert aussi de store local : elle stocke les données issues des services. C'est une approche largement suffisante pour des cas simples, mais si les choses se complexifient, on pourra déporter ce store dans une solution comme NgRx (que nous découvrirons dans le prochain chapitre), NGXS, Akita… À ce moment-là, la façade devient l'interface simplifiée pour interagir avec le store.

Vous l'aurez compris, les façades peuvent être d'une valeur inestimable dans vos architectures. Faites attention aux anti-patterns cependant !

1. Le couteau suisse

Il s'agit d'une façade qui est responsable de trop de domaines métier, qui devient un monolithe incompréhensible. Généralement cet anti-pattern grossit avec le temps.

export class ApplicationFacade {
  loginUser(credentials: LoginData): Observable<User> { }
  registerUser(userData: UserData): Signal<User> { }
  updateProfile(profile: Profile): Signal<void> { }
  
  searchProducts(criteria: SearchCriteria): Signal<Product[]> { }
  getProductDetails(id: string): Signal<Product> { }
  
  addToCart(productId: string): void { }
  removeFromCart(itemId: string): void { }
  
  placeOrder(orderData: OrderData): Signal<Order> { }
  trackOrder(orderId: string): Signal<OrderStatus> { }
  
  processPayment(paymentData: PaymentData): Signal<PaymentResult> { }
  
  sendEmail(email: EmailData): Signal<void> { }
  
  trackEvent(event: AnalyticsEvent): void { }
  
}

Le problème : l'interface de ce genre de façade évoluera en permanence : elle deviendra incompréhensible et intestable, car trop de dépendances différentes.

J'imagine qu'il y a une solution ? 

Oui ! La séparer en plusieurs façades délimitées par leur domaine métier :

export class UserAccountFacade { }
export class ProductCatalogFacade { }
export class ShoppingCartFacade { }
export class OrderProcessingFacade { }
2. The Emperor's New Abstraction

La façade qui croit être une abstraction, mais qui finalement n'abstrait pas grand chose :

export class FlightBookingFacade {
  processBooking(bookingData: BookingData): Observable<BookingResult> {
    return this.bookingApi.createReservation(bookingData).pipe(
      catchError(error => {
        if (error.code === 'AF_SEAT_UNAVAILABLE') {
          throw new Error('AIR_FRANCE_NO_SEATS');
        }
        if (error.code === 'LH_PAYMENT_DECLINED') {
          throw new Error('LUFTHANSA_PAYMENT_FAILED');
        }
        if (error.status === 429) {
          throw new Error('AMADEUS_RATE_LIMITED');
        }
        throw error;
      })
    );
  }
  
  getAmadeusSession(): AmadeusSession { return this.amadeus; }
  getSabreConnection(): SabreAPI { return this.sabre; }
  
  initializeAirlineProvider(airline: 'air-france' | 'lufthansa' | 'british-airways'): void { }
  validateSeatWithAirline(seat: Seat, airlineCode: string): Observable<boolean> { }
  submitToAirlineGDS(gdsData: any): Observable<any> { }
  
  processStripePayment(token: StripeToken): Observable<StripeCharge> { }
  processPayPalPayment(paypalData: PayPalData): Observable<PayPalResult> { }
}

Le problème : avec cet anti-pattern, les components doivent gérer des détails d'implémentation et pas seulement l'intention de l'utilisateur. Ces "façades" peuvent aussi exposer des spécificités dont le component ne devrait pas avoir à s'occuper. Ici, on doit savoir gérer les cas de figure des différentes compagnies, on doit traiter les erreurs spécifiques, les APIs de paiement…

Et la solution ?

Cachez les détails d'implémentation et fournissez une interface réellement simplifiée :

export class FlightBookingFacade {
    
  //...
  
  processBooking(bookingData: BookingData): Observable<BookingResult> {
    return this.validateBookingData(bookingData).pipe(
      mergeMap(() => this.reserveSeat(bookingData.flight, bookingData.seat)),
      mergeMap(() => this.processPayment(bookingData.payment, bookingData.totalPrice)),
      mergeMap(payment => this.confirmReservation(bookingData, payment.id)),
      tap(booking => this.sendConfirmationEmail(booking)),
      catchError(error => {
        if (this.isSeatUnavailable(error)) {
          throw new SeatUnavailableError(bookingData.seat);
        }
        if (this.isPaymentDeclined(error)) {
          throw new PaymentDeclinedError();
        }
        if (this.isServiceTemporarilyDown(error)) {
          throw new BookingTemporarilyUnavailableError();
        }
        throw new BookingFailedError('Unable to complete booking');
      })
    );
  }
}

Mais si les façades s'occupent des besoins métier, qui s'occupent du technique dans tout ça ?

Parlons des services

Les façades exposent des méthodes "métier" pour les components, mais à un moment ou un autre, elles doivent fournir une solution technique : elles le font en faisant appel aux services.

Dans l'idéal, chaque service est responsable d'un seul domaine technique : la communication avec un API spécifique, l'envoi de mail, la validation, les analytics…très souvent, ils représentent une frontière de communication avec un système externe ou une dépendance tierce.

Stateful or stateless

Les services stateless sont beaucoup plus faciles à maintenir et à manipuler. Si jamais un service doit absolument contenir un state, veillez à ce que ce state soit minimal et technique : essayez le plus possible de ne pas de données métier dans un service.

Lorsqu'on structure les services pour avoir une seule responsabilité technique, il s'agit peut-être de la couche la plus "simple" (au sens "sans complexité") d'une application Angular.

Testez les façades

Dans le chapitre sur les components, j'ai expliqué le principe d'utiliser un fake ou stub de façade pour tester le comportement des components. La façade étant le pivot point, on peut tester tout le reste de l'application à partir d'elle. Le SUT (System Under Test) devient la vraie implémentation de la façade, et l'objectif est de laisser les vraies implémentations d'un maximum de ses dépendances :

  • les requêtes HTTP peuvent être mockées et gérées par le HttpTestingController, permettant d'utiliser les vraies implémentations des services API

  • le fait d'avoir wrap vos dépendances tierces permet de créer des fakes simples pour celles-ci pour les tests

  • si vous utilisez une solution de state management (NgRx, NGXS…), je conseille fortement de ne pas les mocker et d'utiliser les vraies implémentations, car ça vous permet de valider chaque comportement en entier

Voici un exemple simple dans notre recherche de vols :

class FakeNotifications implements Notifications {
  lastNotification = '';
  lastError = '';
  
  showNotification(message: string): void {
    this.lastNotification = message;
  }
  
  showError(error: any): void {
    this.lastError = error;
  }
}

class FakeUserProfile implements UserProfile {
  recentSearches: FlightSearchParams[] = [];
  
  addRecentSearch(params: FlightSearchParams): void {
    this.recentSearches.push(params);
  }
}

describe('FlightSearchFacade', () => {
  let facade: FlightSearchFacade;
  let httpController: HttpTestingController;
  let fakeNotifications: FakeNotifications;
  let fakeUserProfile: FakeUserProfile;

  beforeEach(() => {
    fakeNotifications = new FakeNotifications();
    fakeUserProfile = new FakeUserProfile();

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        StateFlightSearchFacade,
        FlightSearch, // <- la vraie implémentation concrète
        { provide: Notifications, useValue: fakeNotifications },
        { provide: UserProfile, useValue: fakeUserProfile }
      ]
    });

    facade = TestBed.inject(StateFlightSearchFacade);
    httpController = TestBed.inject(HttpTestingController);
  });

  it('should find flights and save search', () => {
    const searchParams = { origin: 'CDG', destination: 'JFK' };
    const dummyFlights = [{ id: '1', flightNumber: 'AF123' }];

    facade.findFlight(searchParams);

    const req = httpController.expectOne('/api/flights/search');
    req.flush(mockFlights);

    expect(facade.foundFlights()()).toEqual(dummyFlights);
    expect(fakeUserProfile.recentSearches).toContain(searchParams);
  });

  it('should show notification when no flights found', () => {
    const searchParams = { origin: 'CDG', destination: 'NOWHERE' };

    facade.findFlight(searchParams);

    const req = httpController.expectOne('/api/flights/search');
    req.flush([]);

    expect(facade.foundFlights()()).toEqual([]);
    expect(fakeNotifications.lastNotification).toBe('No flights found.');
  });
});

Dans cette suite :

  • on utilise des fakes pour les  Notifications  et le  UserProfile  (en imaginant que ce sont des dépendances tierces)

  • on utilise le vrai service  FlightSearch

  • on intercepte et on gère toutes les requêtes HTTP envoyées par  FlightSearch

Avec ces deux couches de tests unitaires (component -> fake façade / vraie façade -> le reste), vos tests unitaires couvrent tout le comportement de votre application, sans que cela crée des suites de tests énormes et ingérables.

À vous de jouer

Contexte

Vous travaillez sur une application de e-commerce où les components injectent directement plusieurs services :ProductApi,Cart,Wishlist,Recommendations, etAnalytics. Le component de page produit contient 150 lignes de code avec de la logique métier complexe : gestion des promotions, calcul de prix avec remises, recommendations personnalisées, et tracking d'événements. Les tests sont difficiles à maintenir car ils doivent mocker chaque service individuellement.

Consigne

Corrigé

En résumé

  • Positionnez vos façades comme la traduction technique des besoins métier, orchestrant les services pour créer des workflows utilisateur cohérents

  • Séparez intention/implémentation : les components expriment l'intention métier via les façades, qui se chargent de tous les détails techniques et de l'orchestration des services

  • Evitez les anti-patterns : méfiez-vous du couteau suisse (trop de responsabilités) et de The Emperor's New Abstraction (exposition des détails)

  • Utilisez les façades comme pivot point pour vos tests, en gardant les vraies implémentations des services le plus possible (tout en créant des fakes pour les dépendances tierces)

Avec cette architecture de façades bien structurée, vous maîtrisez l'orchestration de la logique métier, mais que faire lorsque le state de votre application devient trop complexe pour être géré localement dans les façades ?

Ever considered an OpenClassrooms diploma?
  • Up to 100% of your training program funded
  • Flexible start date
  • Career-focused projects
  • Individual mentoring
Find the training program and funding option that suits you best