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 strikte 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 switche 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 wyjatek 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ąć logike 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 class BlogMediaConvertableToPDF {
  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 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. Wystarcza wprowadzić dodatkowy interfejs, który zaimplementuje BlogPostRepository. Jak wspomniałem wcześniej, ilość korzyści płynących z takiego zabiegu jest ogromna, ale przed 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




Polecane wpisy:

Wzorzec budowniczy (builder)

Wzorzec projektowy budowniczy służy do dynamicznej budowy obiektów. Pozwala na kreowanie różnych typów obiektu w zależności od przesłanych wymagań. Dzięki temu posiadamy centralny punkt odpowiedzialny jedynie za tworzenie wariacji danego typu.

Sprawdź ten wpis

Wzorzec metoda wytwórza (factory metod)

Jeżeli masz do czynienia z tworzeniem różnych obiektów bazujących na predefiniowanym modelu, to powinieneś/aś skorzystać z metody wytwórczej. Kreacyjny wzorzec projektowy factory method pozwala być fancy w tworzeniu obiektów.

Sprawdź ten wpis

Jak zostać architektem oprogramowania?

Aby projektować dobrą architekturę oprogramowania trzeba najpierw zaznajomić się z jej przesłankami, rodzajami, a także sposobami wdrażania. Architektura nie jest przecież czymś stałym, co wszędzie implementowane jest w jednakowy sposób.

Sprawdź ten wpis

Kompozycja zamiast dziedziczenia (composition over inheritance)

Czym jest podejście composition over inheritance? Dzisiaj o tym, dlaczego każdy powinien przemyśleć czy dziedziczenie klas w jego projekcie jest naprawdę potrzebne. Prawdopodobnie powinieneś ograniczyć dziedziczenie klas na rzecz ich kompozycji.

Sprawdź ten wpis

Command-Query Separation (CQS)

Command-Query Separation, czyli zasada o rozdzielaniu zadań metod tak, aby były jedynie komendami lub zapytaniami. Jeżeli Twoja metoda realizuje logikę i zwraca wyniki, lub podczas pobierania danych wykonuje side taski, to łamie ona CQS.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.