22 kwi 2025
3 min

Wzorzec portów i adapterów dla NGRX Signal Store

Czym jest NGRX Signal Store?

NgRx Signal Store to rozwiązanie do zarządzania stanem w aplikacjach Angular, zbudowane na podstawie sygnałów reaktywnych Angulara, wprowadzonych w Angular 16. Signal Store został zaprojektowany jako bardziej efektywna i prostsza alternatywa dla tradycyjnego NgRx Store. Łączy sygnały Angulara z ekosystemem NgRx, umożliwiając deweloperom zarządzanie stanem aplikacji w sposób bardziej reaktywny i łatwiejszy do zrozumienia w porównaniu do tradycyjnego NgRx Store, który w dużej mierze opiera się na obserwowalnych i akcjach.

Więcej o sygnałach na angular.love:

Więcej o Signal Store na angular.love:

Czym jest architektura heksagonalna (wzorzec portów i adapterów)?

Architektura heksagonalna (porty i adaptery) izoluje główną logikę biznesową od systemów zewnętrznych, wykorzystując interfejsy (porty) do definiowania interakcji oraz adaptery do realizacji implementacji. Taki podział zwiększa modularność, testowalność i elastyczność, pozwalając logice rdzenia pozostać niezależną od zmian infrastrukturalnych, co ułatwia utrzymanie i adaptację aplikacji.

Wzorzec heksagonalny w frameworku Angular, w połączeniu z potężnym mechanizmem Dependency Injection (DI), oferuje elegancki sposób zarządzania i upraszczania skomplikowanych interakcji w aplikacji. Pozwala deweloperom na kapsułkowanie złożonej logiki i udostępnianie uproszczonego API reszcie aplikacji, zwiększając modularność, łatwość utrzymania i testowalność.

Więcej o Dependency Injection na angular.love:

Więcej o architekturze heksagonalnej na angular.love:

  • https://angular.love/pl/porty-i-adaptery-a-architektura-heksagonalna-czy-to-ten-sam-wzorzec

Moc mechanizmu inferencji typów w Signal Store

NgRx SignalStore to rozwiązanie do zarządzania stanem, które wykorzystuje mechanizm inferencji typów TypeScript do zapewnienia bardziej efektywnego i bezpiecznego typowania. Dzięki temu nie trzeba jawnie definiować typów. Ta zależność od inferencji typów zmniejsza ilość zbędnego kodu, zwiększa czytelność kodu i zapewnia bezpieczeństwo typów w całej aplikacji, co ułatwia utrzymanie i refaktoryzację oraz zapobiega błędom w czasie wykonywania wynikającym z niedopasowania typów. Rezultatem jest bardziej intuicyjne API, wspierające nowoczesne praktyki rozwoju Angular z minimalnym wysiłkiem i maksymalną niezawodnością.

Lekka implementacja wzorca Portów i Adapterów dla NGRX Signal Store

Połączenie NgRx Signal Store z architekturą heksagonalną (wzorzec portów i adapterów) oferuje potężne podejście do zarządzania stanem w aplikacjach Angular, jednocześnie zachowując korzyści z inferencji typów dostarczanych przez TypeScript. Dzięki tej integracji możemy wykorzystać elastyczność i modularność wzorca heksagonalnego, tworząc wyraźne granice między logiką biznesową a infrastrukturą aplikacji, bez wprowadzania zbędnej złożoności czy dodatkowego kodu.

Stosując to podejście, zyskujemy korzyści architektury heksagonalnej, takie jak lepsza testowalność i łatwiejsze utrzymanie kodu, jednocześnie utrzymując prostotę i minimalizm oferowany przez Signal Store.

Zazwyczaj w TypeScript implementacja adaptera opiera się na mechanizmie implementacji interfejsów, ale tym razem użyjemy operatora satisfies:

import { Type } from "@angular/core";

export interface Fruit {
  id: string;
  name: string;
}

export interface FruitService {
  fruits: Signal<Fruit[]>;
  loadFruits(): void;
}

const FruitServiceAdapter = signalStore(
  withState({ fruits: [] as Fruit[] }),
  withMethods((store) => {
    return {
      loadFruits: async () => {
        const fruits = await fetch('https://api.example.com/fruits').then(
          (res) => res.json()
        );
        patchState(store, {
          fruits,
        });
      },
    };
  })
) satisfies Type<FruitService>;

Operator satisfies wymusza zgodność wartości z określonym typem, ale nie zmienia typu wartości (np. wynikającego z mechanizmu inferencji typów). W ten sposób nie nadpisujemy typu wywnioskowanego dla Signal Store, ale zapewniamy poprawną implementację portu.

Teraz możemy przygotować wygodne narzędzia do wstrzykiwania adaptera pod port:

const fruitServiceInjectionToken = new InjectionToken<FruitsService>(
  'fruits-service'
);

export function provideFruitService(): Provider {
  return {
    provide: fruitServiceInjectionToken,
    useClass: FruitServiceAdapter,
  };
}

export function injectFruitService(): FruitsService {
  return inject(fruitServiceInjectionToken);
}

A tak wygląda ich użycie wewnątrz komponentu lub innej usługi:

@Component({
  selector: 'app-root',
  standalone: true,
  template: ``,
  providers: [provideFruitService()],
})
export class App {
  private fruitService = injectFruitService();

  constructor() {
    this.fruitService.loadFruits();
  }
}

Stackblitz:
https://stackblitz.com/edit/stackblitz-starters-pbmwtt?file=src%2Ffruit.service.ts

Możemy także uprościć narzędzia do wstrzykiwania, korzystając z klas abstrakcyjnych. Podejście to zostało opisane w artykule angular.love o Portach i Adapterach:

  • https://angular.love/pl/porty-i-adaptery-a-architektura-heksagonalna-czy-to-ten-sam-wzorzec

Podsumowanie

Połączenie NgRx SignalStore z architekturą heksagonalną oferuje nowoczesne, bezpieczne typowo podejście do zarządzania stanem w aplikacjach Angular. Dzięki wykorzystaniu potężnego mechanizmu inferencji typów TypeScript możemy zachować prostotę i minimalizm Signal Store, jednocześnie korzystając z modularności i testowalności wzorca heksagonalnego.

To podejście nie tylko redukuje ilość zbędnego kodu i poprawia czytelność, ale również zapewnia solidną architekturę aplikacji z wyraźnym podziałem odpowiedzialności. Przy minimalnym dodatkowym kodzie można zaimplementować przejrzyste, łatwe w utrzymaniu i skalowalne rozwiązanie do zarządzania stanem, które łączy paradygmat reaktywny z zasadami czystej architektury. Ta synergia ułatwia deweloperom budowanie i utrzymanie złożonych aplikacji z pewnością i efektywnością.

Najczęściej zadawane pytania

Jak zaimplementować port i adapter dla NgRx Signal Store w Angularze?

Adapter tworzysz jako signalStore(withState(...), withMethods(...)), a jego zgodność z interfejsem portu zapewnia operator satisfies Type<FruitService>. Metody, np. loadFruits(), aktualizują stan przez patchState(store, ...), dzięki czemu logika pobierania danych pozostaje w adapterze. Port, czyli interfejs z polem fruits: Signal<Fruit[]>, definiuje tylko kontrakt używany przez resztę aplikacji.

Jak oddzielić logikę biznesową od infrastruktury w aplikacji Angular?

Najprościej zdefiniować port jako interfejs opisujący potrzebne operacje, a adapter jako konkretną implementację, np. korzystającą z fetch(). Ten podział to architektura heksagonalna, czyli wzorzec portów i adapterów. Logika rdzenia pozostaje niezależna od zmian infrastrukturalnych, a Dependency Injection z InjectionToken wiąże port z adapterem w jednym miejscu, np. w provideFruitService().

Jak wstrzyknąć adapter NgRx Signal Store pod token InjectionToken w Angularze?

Zarejestruj adapter w funkcji zwracającej Provider, używając provide: fruitServiceInjectionToken oraz useClass: FruitServiceAdapter. Następnie utwórz funkcję injectFruitService(), która wywołuje inject(fruitServiceInjectionToken), i użyj jej w komponencie. Komponent zależy wtedy od portu, a nie od konkretnej implementacji, co ułatwia podmianę adaptera w testach.

Dlaczego w adapterze NgRx Signal Store używa się operatora satisfies zamiast implements?

Operator satisfies sprawdza, czy wartość spełnia typ portu, ale nie nadpisuje typu wywnioskowanego przez Signal Store. Zwykle do implementacji interfejsu używa się implements, tutaj jednak wybrano satisfies, dzięki czemu typy stanu i metod zachowują się zgodnie z inferencją. Port Type<FruitService> jest dzięki temu weryfikowany przez kompilator TypeScript.

Czy w NgRx Signal Store trzeba jawnie definiować typy stanu?

Nie, typy stanu i metod są wnioskowane przez TypeScript z konfiguracji withState i withMethods. W przykładzie wystarczy withState({ fruits: [] as Fruit[] }), aby stan otrzymał typ Fruit[]. Ogranicza to ilość zbędnego kodu, chroni przed błędami niedopasowania typów i ułatwia refaktoryzację całej aplikacji.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.