SOLID z przykładami w TypeScript

SOLID (Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle), czyli pięć zasad programowania obiektowego, które każdy powinien przestrzegać.

SOLIDny kod

Czym byłby blog o architekturze i programowaniu, który nie podjął tematu SOLID? Wspomniane zagadnienie zostało już szeroko omówione przez wiele osób i w internecie znajduje się cała masa treści, zarówno teoretycznych, jak i praktycznych. Niemniej jednak, podejmuję wyzwanie omówienia zasad zaproponowanych przez Roberta C. Martina. Postaram się ograniczyć ilość teorii i skupić się głównie na przykładach łamiących i spełniających poszczególne reguły wchodzące w skład SOLID. 

Co to jest SOLID?

Lecz zanim przykłady… SOLID jest to akronim od: Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle, zaproponowany przez Roberta C. Martina. Każda składowa akronimu, to osobna zasada lub też zalecenie, dotyczące tworzenia oprogramowania. Stosowanie się do wytycznych SOLID pozwala na tworzenie czytelnego, łatwo rozwijalnego i testowalnego kodu. Jest to święty graal w świecie IT, będący trochę dekalogiem (składającym się z 5 punktów?) dla programistów.

Zasada pojedynczej odpowiedzialności (SRP)

Single responsibility principle jest to zasada mówiąca, że komponent powinien mieć tylko jedną odpowiedzialność, jedno zadanie do realizacji. Inaczej mówiąc, powinien istnieć tylko jeden powód modyfikacji danego komponentu. Komponentem zaś może być klasa, funkcja czy też cały moduł. Przykład łamania zasady pojedynczej odpowiedzialności będzie jednym z łatwiejszych do przedstawienia. 

Spójrzmy poniżej, mamy tutaj klasę reprezentującą wpis na blogu. Ma ona pewne zachowania wyrażone w postaci metod, a także cechy określone za pomocą pól klasy. Wpis zawiera informacje o kategorii do której został przypisany, a także możliwość jej zmiany za pomocą metody changeCategory(), czy sprawdzenia do jakiej kategorii już przynależy. Poniższa klasa zawiera teraz co najmniej dwa powody zmiany: logika samego postu oraz logika związana z kategorią. Inaczej mówiąc, nasz kod odpowiedzialny jest nie tylko za to, za co powinien (co sugeruje nazwa: obsługę wpisu), ale także zawiera zachowania połączone stricte z kategorią.

export class Post {
  constructor(
    private readonly id: PostId,
    private readonly title: string,
    private readonly content: string,
    private readonly categoryName: string,
    private readonly categoryDescription: string,
    private readonly categoryId: string,
    private readonly visible = true,
  ) {}

  toggleVisibility() {
    this.visible = !this.visible;
  }

  changeCategory(dto: CategoryDto) {
    this.categoryName = dto.name;
    this.categoryDescription= dto.description;
    this.categoryId = dto.id;
  }

  categoryIs(categorySlug: string): boolean {
    return this.categorySlug === categorySlug;
  }
}

Jak to naprawić? Oczywiście poprzez rozdzielenie odpowiedzialności: post swoje, a kategoria swoje. Do samego postu możemy wstrzyknąć cały obiekt kategorii, bądź samo jej id – w zależności od problemu z jakim się spotykamy.

export class Category {
  constructor(
    private readonly id: CategoryId,
    private readonly name: string,
    private readonly description: string,
  ) {}

  update(dto: CategoryDto) {
    this.name = dto.name;
    this.description = dto.description;
  }
}

export class Post {
  constructor(
    private readonly id: PostId,
    private readonly title: string,
    private readonly content: string,
    private readonly categoryId: CategoryId,
    private readonly visible = true,
  ) {}

  toggleVisibility() {
    this.visible = !this.visible;
  }

  changeCategory(categoryId: CategoryId) {
    this.categoryId = categoryId;
  }

  categoryIs(categoryId: CategoryId): boolean {
    return this.categoryId === categoryId;
  }
}

Zasada otwarte-zamknięte (OCP)

Open/Closed Principle jest zasadą mówiącą, że komponenty powinny być otwarte na rozszerzenia, ale zamknięte na modyfikacje. Co to oznacza w praktyce? Jeżeli chcemy rozszerzyć nasz komponent o dodatkową logikę, a nagle okazuje się, że w tym celu musimy zmieniać istniejący kod w sposób niekiedy lawinowy, oznacza to, że łamiemy zasadę OCP. Pisany kod powinien więc pozwalać na dodawanie nowych funkcjonalności bez konieczności modyfikacji obecnego.

Częstymi przykładami łamiącymi tę zasadę są te, gdzie widzimy warunki używające instanceof, wszelkiego rodzaju switch-e czy choinkę if-else if. Trzeba obsłużyć kolejny przypadek? Myyyyk, dodajmy else if lub case. Wiadomo, że gdzieś taka logika czasami musi istnieć, jednak przeważnie, wcale nie musi. Poniżej widzimy klasę reprezentującą produkt ze wspomnianym właśnie problemem. Nie wnikajmy zbytnio w pozostałą logikę, bo model zależy oczywiście od kontekstu, ale powiedzmy, że niektóre produkty mają zmienne ceny, w zależności od okresu i kategorii produktu.

export class Product {
  constructor(
    private readonly id: ProductId,
    private readonly name: string,
    private readonly basePrice: Price,
    private readonly category: Category,
  ) {}

  calculatePrice(season: PricingSeason): Price {
    const price = this.basePrice;

    if (this.category.giftable() && season.preGiftable()) {
      price = price.percentageIncrease(15);
    } 

    if (this.category.giftable() && season.giftable()) {
      price = price.percentageDecrease(5);
    } 

    if (this.category.giftable() && season.postGiftable()) {
      price = price.percentageDecrease(10);
    } 

    if (this.category.excisable()) {
      price = price.percentageIncrease(5);
    } 

    return price;
  }
}

Jak naprawić kod, aby spełniał open/closed principle? Wystarczy wdrożenie wzorca strategii w jakiejkolwiek jego formie. Użyję jednak trochę innego podejścia. Skorzystajmy z czegoś na wzór policy pattern. Utwórzmy politykę na każdą okazję, a następnie użyjmy kolekcji takich polityk, aby poprawnie wyliczyć cenę.

export interface ProductPricePolicy {
  calculate(season: PricingSeason, category: Category, base: Price): Price;
}

export class PreGiftablePricePolicy {
  calculate(season: PricingSeason, category: Category, base: Price): Price {
    return category.giftable() && season.preGiftable() 
        ? price.percentageIncrease(15)
        : price;
  }
}

export class Product {
  constructor(
    private readonly id: ProductId,
    private readonly name: string,
    private readonly basePrice: Price,
    private readonly category: Category,
  ) {}

  calculatePrice(season: PricingSeason, policies: ProductPricePolicy[]): Price {
    return policies.reduce(
       (price: Price, policy: ProductPricePolicy) => policy.calculate(season, this.category, price), 
       this.basePrice
    );
  }
}

W ten sposób, jeżeli w przyszłości dojdzie nowa reguła wyliczania ceny, to wystarczy, że dodamy nową politykę i umieścimy ją w przekazywanej kolekcji. Rozszerzanie, bez zmieniania kodu. Problem może pojawić się z argumentami przekazywanymi do polityki (np. cena może w przyszłości zależeć także od innych zmiennych produktu), wtedy możemy przekazać do polityki cały obiekt produktu, a jeżeli komuś wadzi takie przekazywanie, to polityki możemy wtedy iterować gdzieś wyżej (w serwisie?).

Zasada podstawienia Barbary Liskov (LSP)

Liskov substitution principle jest to zasada, której nazwa wywodzi się od nazwiska wspomnianej w nagłówku programistki, Barbary Liskov. Nie wiem dlaczego, ale co do tej zasady ciężko znaleźć w internecie rzetelny, życiowy przykład. Większość z nich opiera się bowiem na wyimaginowanych, często akademickich przykładach: o zwierzętach, samochodach. Czemu <zamyślonyPingwin>?

Aby kod był zgodny z Liskov substitution principle, to wszystkie rozszerzenia klasy powinny być zgodne w swojej implementacji. Oznacza to tyle, że jeżeli np. dwie klasy rozszerzają tę samą, nadrzędną klasę, to powinny zawsze implementować jej abstrakcje zgodnie ze stanem faktycznym. Nie dopuszcza się tutaj sytuacji, w której jedna klasa w zaimplementowanej metodzie wyrzuca wyjątek informujący, iż wskazana metoda jest nieimplementowalna. Mówiąc natomiast bardziej profesjonalnie, jeżeli jakiś byt (np. serwis w swojej metodzie) używa odniesienia do klasy bazowej, to musi on być w stanie używać również obiektów klas dziedziczących po takiej klasie.

export abstract class BlogMedia {
  // other logic 

  abstract toPDF(): PDF;
}

export class Post extends BlogMedia {
  toPDF(): PDF {
    return this.pdfConverter.convert(
      this.toPayload()
    );
  }
}

export class Movie extends BlogMedia {
  toPDF(): PDF {
    throw new Error('Movie cannot be converted to PDF file!');
  }
}

export class BlogMediaService {
  convertToPDF(mediaId: string): PDF {
    const media: BlodMedia = this.mediaRepository.find(mediaId);

    return media.toPDF();
  }
}

Naprawa powyższego przykładu może odbyć się na wiele sposobów. Możemy np. wyciągnąć logikę konwertującą do PDF poza BlogMedia i użyć interfejsu do wskazania, który BlogMedia może być skonwertowany do postaci pliku pdf.

export abstract class BlogMedia {
  // other logic 
}

export interface ConvertableToPDF {
  getBlogMediaPDFPayload(): BlogMediaPDFPayload;
}

export class Post extends BlogMedia implements ConvertableToPDF {
  getBlogMediaPDFPayload(): BlogMediaPDFPayload {
    return {
      name: this.title,
      language: this.language,
      content: this.content,
    };
  }
}

export class Movie extends BlogMedia {
  // other logic 
}

export class BlogMediaService {
  convertToPDF(media: BlogMediaConvertableToPDF, converter: PDFConverter): PDF {
    return converter.convert(
      media.getBlogMediaPDFPayload()
    );
  }
}

Zasada segregacji interfejsów (ISP)

Interface segregation principle to część SOLIDa do której zawsze podchodzę z dystansem. Mówi ona o tym, aby projektować swoje interfejsy tak, aby były jak najmniejsze oraz skoncentrowane na małym fragmencie logiki. W zamyśle, ma to zapobiegać tworzeniu dużych interfejsów i późniejszych problemów związanych z ich implementacją przez klasy, które potrzebują jedynie część funkcjonalności (podobnie, jak w przykładzie wyżej). Dodatkowo interfejsy powinny być bezstanowe i służyć w głównej mierze do ochrony typu klasy. Spójrzmy poniżej, gdzie zamieściłem przykład niespełniający ISP.

export interface Convertable {
  toPDF(): PDF;
  toDOC(): DOC;
  toHTML(): HTML;
  toTXT(): TXT;
}

export class Post implements Convertable {
  // implementations
}

Co w sytuacji, jeżeli zapragniemy dodać byt, który jest konwertowalny jedynie do wyjścia bez formatowania, np. tylko czyste pliki tekstowe, ale metoda w serwisie przyjmuje całościowo obiekty klas implementujących Convertable. Rozwiązaniem są mniejsze interfejsy.

export interface ConvertableToPDF {
  toPDF(): PDF;
}

// ...

export interface ConvertableToTXT {
  toTXT(): TXT;
}

export class Post implements ConvertableToPDF, ConvertableToDOC, ConvertableToHTML, ConvertableToTXT {
  // implementations
}

export class AccessLog implements ConvertableToTXT {
  // implementation
}

Z tą zasadą wiąże się pewien ból, mianowicie nie można tu przesadzić. Usilne tworzenie interfejsu dla każdej metody może spowodować spory bałagan, z uwagi na ilość interfejsów, która powstanie, a później trzeba je jeszcze zaimplementować, prawda? Zawsze warto zastanowić się czy zbyt duża skrupulatność w granulacji interfejsów jest warta swojej ceny.

Zasada odwrócenia zależności (DIP)

Na sam koniec, moja ulubiona, zasada dependency inversion principle polegająca na szerokim stosowaniu abstrakcji. Mówi ona o tym, że zależności ze wszelkimi komponentami powinny wynikać z abstrakcji, co da swobodę w podmienianiu funkcjonalności, a co za tym z kolei idzie ułatwi testowanie kodu. Pozostańmy na przykładzie BlogPost. Poniżej widzimy serwis, który bezpośrednio w konstruktorze używa BlogPostRepository, co w myśl DIP jest niepoprawne z uwagi na brak abstrakcji i „sztywność” powiązania serwisu i repozytorium.

export class BlogPostsService {
  constructor(
    private readonly repository: BlogPostRepository,
  ) {}

  remove(id: string) {
    const post = this.repository.findOrFail(id);

    this.repository.remove(post)
  }
}

Naprawa tego kodu będzie bajecznie prosta. Wystarczy wprowadzić dodatkowy interfejs, który zaimplementuje BlogPostRepository. Jak wspomniałem wcześniej, ilość korzyści płynących z takiego zabiegu jest ogromna, ale przede wszystkim wyróżnić należy możliwość łatwej podmiany zależności.

export interface BlogPostRepositoryInterface {
  findOrFail(id: string): BlogPost;
  remove(post: BlogPost);
}

export class BlogPostsService {
  constructor(
    private readonly repository: BlogPostRepositoryInterface,
  ) {}

  remove(id: string) {
    const post = this.repository.findOrFail(id);

    this.repository.remove(post)
  }
}

Podsumowanie

  • SOLID to zbiór pięciu zasad zaproponowanych przez Roberta C. Martina.
  • Stosowanie ich pozwala na posiadanie utrzymywalnego, łatwo rozwijalnego i testowalnego kodu.
  • Każdy programista powinien znać i stosować SOLID.
Autor wpisu

blog@orbisbit.com

Komentarze

2 odpowiedzi na „SOLID z przykładami w TypeScript”

  1. Awatar Ewelina M

    Wkońcu fajny praktyczny przykład z Liskov, ale z 2 błędami mogącymi utrudniać zrozumienie.
    1) export interface class BlogMediaConvertableToPDF {
    nie ma czegoś takiego jak interface i class jednocześnie
    2) Nazwa nie zgadza się z tym co później post implementuje, więc powinna być raczej :
    ConvertableToPDF

    1. Awatar Gabriel Ślawski

      Hej, dzięki za komentarz! Naniosłem poprawki do treści, good catch 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również
  • CQRS – Command Query Responsibility Segregation

    Command Query Responsibility Segregation czyli CQRS. Jest to wzorzec projektowy, który rozdziela zadania odczytu i zapisu do osobnych modeli. Sprawdź ten wpis, aby dowiedzieć się kiedy i jak z niego skorzystać.

    Zobacz wpis

  • GRASP – kolejny zbiór zasad Clean Code do zapamiętania

    Pewnie większość z Was słyszała o zasadach SOLID. Są one bardzo rozpowszechnione i dosyć często stosowane, ale czy słyszeliście o GRASP? General Responsibility Assignment Software Patterns, to kolejna dawka zasad czystego kodu do zapamiętania.

    Zobacz wpis

  • Wzorzec strategia (strategy pattern)

    Jeżeli masz dość if-ologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

    Zobacz wpis