Entity – część druga

W ostatnim artykule na temat entity opowiedziałem Wam czym są encje, jak je zaimplementować oraz dlaczego są pomocne. Jeśli go jeszcze nie widzieliście to przed dalszym czytaniem zachęcam Was do zapoznania się z nim. Ten artykuł jest tylko jego rozszerzeniem bazującym na Waszych sugestiach oraz pytaniach, które pojawiły się pod ostatnim postem odnośnie encji na profilu drycode na facebook’u do którego Was serdecznie zapraszam.

Kompozycja (zagnieżdżenie) entity i value object’ów

Dlaczego kompozycja jest przydatna oraz jakie problemy pomaga nam rozwiązać opisałem już w artykule dotyczącym value object’ów. Identyczne rozwiązanie możemy zastosować w przypadku encji. Standardowo aby lepiej zobrazować temat posłużymy się przykładem. Wyobraźmy sobie, że musimy zaimplementować zamówienie (Order). Nasze zamówienie może być entity i wyglądać w następujący sposób:

interface OrderLine {
  price: {
    currency: string;
    value: number;
  };
  quantity: number;
  name: string;
}

class Order {
  private readonly id: string;
  private orderLines: OrderLine[];

  constructor(id: string) {
    this.id = id;
    this.orderLines = [];
  }

  public add(orderLine: OrderLine): void {
    if (orderLine.quantity < 1) {
      throw Error('Invalid quantity');
    }

    if (orderLine.price.value <= 0) {
      throw Error('Invalid price');
    }
    
    if (...) {
      ...
    }

    this.orderLines.push(orderLine);
  }
}

Jednak jak sami widzicie, nie jest to zbyt czytelne rozwiązanie. Nasze zamówienie posiada teraz dodatkowe odpowiedzialności. Sprawdza poprawność np. ceny, ilości, zniżek itp. Może ten przykład nie jest jakoś bardzo skomplikowany, ale w prawdziwych systemach takie klasy potrafią być bardzo ogromne.

Aby sprawić żeby nasz kod stał się bardziej czytelny i łatwiejszy do modyfikacji możemy użyć kompozycji. Zamiast posiadać jedną klasę Order, możemy rozbić ją na mniejsze. Po pierwsze wydzielmy pozycje zamówienia (OrderLine) do osobnej klasy:

class OrderLine {
  private readonly id: string;
  private price: Price;
  private quantity: number;

  constructor(id: string, price: Price, quantity: number) {
    if (quantity < 1) {
      throw Error('Invalid quantity');
    }

    if (price.value <= 0) {
      throw Error('Invalid price');
    }

    if (...) {
      ...
    }
    
    this.id = id;
    this.price = price;
    this.quantity = quantity;
  }
}

class Order {
  private readonly id: string;
  private orderLines: OrderLine[];

  constructor(id: string) {
    this.id = id;
    this.orderLines = [];
  }

  public add(orderLine: OrderLine): void {
    this.orderLines.push(orderLine);
  }
}

Nasza encja Order, już na obecnym etapie została bardzo odchudzona. Natomiast logika związana z ceną oraz ilością została przeniesiona do OrderLine, co nie jest do końca satysfakcjonującym rozwiązaniem, ponieważ to ona zyskała teraz dodatkową odpowiedzialność. Użyjmy zatem naszych kochanych value object’ów Price oraz Quantity do zenkapsulowania logiki odpowiedzialnej za cenę oraz ilość:

class Price {
  private readonly value: number;

  constructor(value: number) {
    if (value <= 0) {
      throw Error('Invalid price');
    }

    if (...) {
      ...
    }

    this.value = value;
  }
}

class Quantity {
  private readonly value: number;

  constructor(value: number) {
    if (value < 1) {
      throw Error('Invalid quantity');
    }

    this.value = value;
  }
}

class OrderLine {
  private readonly id: string;
  private price: Price;
  private quantity: Quantity;

  constructor(id: string, price: Price, quantity: Quantity) {
    this.id = id;
    this.price = price;
    this.quantity = quantity;
  }
}

class Order {
  private readonly id: string;
  private orderLines: OrderLine[];

  constructor(id: string) {
    this.id = id;
    this.orderLines = [];
  }

  public add(orderLine: OrderLine): void {
    this.orderLines.push(orderLine);
  }
}

Dzięki temu pozbyliśmy się niepotrzebnej logiki z OrderLine i przerzuciliśmy ją w odpowiednie miejsce. Nasze klasy posiadają teraz wysoką kohezję przez co stały się o wiele bardziej czytelne, a co za tym idzie łatwiejsze do modyfikacji.

Słownictwo i mnogość reprezentacji

Jeśli zapytam Was jak wygląda zamek, to praktycznie każdy odpowie co innego. Jeden powie, że zamek ma mury obronne, wieże oraz mieszka w nim król. Drugi, że zamek to taka dziurka w drzwiach do której wkładamy klucz. Trzeci natomiast, że zamek to takie „coś” co pozwala nam zapiąć spodnie. Więc jak widzicie jedno słowo potrafi mieć wiele znaczeń. A te znaczenia zależą od kontekstu w jakim się znajdujemy.

Jeśli prosiłbym Was teraz o zaimplementowanie zamków w kodzie to czy utworzylibyście jedną klasę z możliwością otwierania i zamykania drzwi, rozpięcia spodni oraz funkcją mieszkalną dla króla? Prawdopodobnie nie, a przynajmniej mam taką nadzieję!

To czemu dokładnie coś takiego robimy w naszych aplikacjach? Ale jak to?! Kto zdrowy na umyśle zrobiłby coś takiego? Na początku może się to wydawać wręcz nieprawdopodobne, ale czy entity o nazwie Order, User albo Product brzmi znajomo? Często takie encje mają po kilkadziesiąt metod, pól, możliwych stanów, zmieniają się z różnych powodów oraz posiadają w sobie masę niezwiązanej ze sobą logiki biznesowej.

entity

Dzieje się tak, ponieważ bardzo często nie zdajemy sobie sprawy z kontekstu w jakim dane słowa są używane. Jeśli słyszymy zdania typu „Produkt posiada cenę oraz opis”, „Produkt powinien mieć wymiary oraz wagę”, „Produkt powinien posiadać swojego opiekuna” to z reguły skupiamy się tylko na rzeczownikach i pchamy te wszystkie właściwości do jednej klasy Product, przez co powstaje nam taki potworek.

Nie pomaga nam również to czego uczą nas w większości szkół oraz kursów programowania. Czyli, że programowanie obiektowe jest odzwierciedleniem obiektów z prawdziwego życia. Nie jest to prawdą. Obiekty są tylko modelami, które powinny być tworzone do realizacji konkretnych zadań. Nie powinny one zawierać wszystkich możliwych właściwości.

Sytuacja pogarsza się jeszcze bardziej w momencie w którym dostajemy od grafika makiety i widzimy, że te wszystkie właściwości są wyświetlane obok siebie. Wtedy przyjmujemy wręcz za pewnik, że przecież to musi być jedna klasa. Otóż nic bardziej mylnego!

Zamiast tego po pierwsze powinniśmy poznać kontekst lub innymi słowy subdomenę w której się znajdujemy, a po drugie zamiast skupiać się na rzeczownikach powinniśmy skupić się na zachowaniach. Czyli innymi słowy co dana klasa robi, a nie jak wygląda. Dzięki temu będziemy w stanie stworzyć małą klasę tylko z właściwościami, których potrzebujemy do realizacji konkretnych zachowań w danym kontekście.

Ale jak nazwać takie klasy? Odpowiedź jest prosta, tak samo 🙂 Nic nie stoi na przeszkodzie, żeby zrobić kilka modułów z klasą Product.

Produkt posiada cenę oraz opis (subdomena sprzedaży):

class Product {
  private id: string;
  private description: string;
  private price: Price;
  private isPublished: boolean;

  constructor(id: string, description: string, price: Price) {
    this.id = id;
    this.description = description;
    this.price = price;
    this.isPublished = false;
  }

  public changePrice(newPrice: Price): void {
    // ...
  }

  public publish(): void {
    // ...
  }
}

Produkt powinien mieć wymiary oraz wagę (subdomena magazynu):

class Product {
  private id: string;
  private weight: Weight;
  private size: Size;

  constructor(id: string, weight: Weight, size: Size) {
    this.id = id;
    this.weight = weight;
    this.size = size;
  }

  public reserve(): void {
    // ...
  }

  public move(...): void {
    // ...
  }

  // ...
}

Jak widzicie pomimo tego, że oba byty mają identyczną nazwę to jest to przypadkowe i każdy z nich odpowiada za zupełnie inne zachowania i enkapsuluje w sobie zupełnie inną logikę biznesową. Dzięki takiemu rozwiązaniu zamiast mieć jedną ogromną klasę odpowiedzialną za wszystko, to mamy kilka mniejszych z wysoką kohezją, które mają tylko jeden powód do zmiany.

Ktoś może teraz słusznie zadać pytanie: No dobra ale co w momencie, jak będę musiał w jakiś sposób powiązać ze sobą dane klasy? Przykładowo co się stanie jak ktoś w domenie sprzedaży kliknie „kup” i będę musiał dokonać rezerwacji w domenie magazynu? Odpowiedź jest bardzo prosta. Nic nie stoi na przeszkodzie aby obie klasy miały takie samo id. Dzięki temu w łatwy sposób będziemy mogli je ze sobą powiązać, bez konieczności tworzenia dodatkowych pól i robienia relacji.

Zostaje jeszcze jedna kwestia, a mianowicie „wspólny widok”. Co zrobić w momencie w którym będziemy chcieli wyświetlić zarówno wagę, cenę jak i opis na jednym ekranie? W takim wypadku należy pamiętać, że jest to tylko widok, który nie ma wpływu na naszą logikę biznesową. W tej sytuacji możemy zastosować CQRS i odseparować to jak wyświetlamy nasz obiekt od tego jak on wygląda w naszej domenie. Przykładowo możemy zrobić trzecią klasę ProductDescriptor, która będzie posiadać wszystkie te właściwości, ale tylko na potrzeby odczytu. Jeśli chcielibyście się dowiedzieć czegoś wiecej na ten temat do dajcie znać w komentarzach 🙂

Obsługa wielu stanów

Często w naszych systemach mamy klasy, które mają jakieś statusy/stany. Czasami jest ich bardzo dużo. Takim przykładem może być dokument, który może być w statusie new, accepted, published i tak dalej. Bardzo często jest tak, że od tych statusów zależą zachowania. Przykładowo nie możemy opublikować nowego dokumentu, który nie został zaakceptowany lub nie możemy zmienić treści dokumentu, który został opublikowany. Aby zapewnić tego typu zabezpiecznia w naszym kodzie umieszczamy bardzo dużą ilość ifów:

enum Status {
  New = 'New',
  Accepted = 'Accepted',
  Published = 'Published'
}

export class Document {
  private readonly id: string;
  private status: Status;
  private text: string;

  constructor(id: string, status: Status, text: string) {
    this.id = id;
    this.status = status;
    this.text = text;
  }

  public changeText(newText: string): void {
    if (this.status !== Status.New) {
      throw new Error('Cannot change text');
    }
    
    this.text = newText;
  }

  public publish(): void {
    if (this.status !== Status.Accepted) {
      throw new Error('Cannot publish');
    }

    this.status = Status.Published;
  }

  public accept(): void {
    if (this.status === Status.Accepted) {
      throw new Error('Cannot accept accepted document');
    }

    if (this.status === Status.Published) {
      throw new Error('Cannot accept published document')
    }

    if (this.status === Status.New) {
      this.status = Status.Published;
    }
  }
}

Stosując takie rozwiązanie sprawiamy, że nasze klasy „puchną” i pojawia się w nich coraz większa ilość logiki. Jeśli stanów jest bardzo dużo to jest nam bardzo ciężko taką klasę utrzymywać i zadbać w niej o poprawność danych. Z czasem liczba if’ów znacznie wzrośnie, pojawią się zagnieżdżenia spowodowane pod statusami i utrzymywanie takiego kodu stanie się koszmarem.

Do rozwiązania problemu wielu stanów można podejść w nieco inny sposób. Po pierwsze należy sobie zadać pytanie, czy aby na pewno dokument jest jednym bytem? Bo przecież nieopublikowany dokument to dopiero draft prawda? Natomiast opublikowany może być na przykład postem lub artykułem w zależności od systemu, który tworzymy. Więc czemu by tego nie odzwierciedlić w kodzie i nie rozbić naszej klasy na mniejsze?

export class Post {
  private readonly id: string;
  private text: string;

  constructor(id: string, text: string) {
    this.id = id;
    this.text = text;
  }
}

export class AcceptedDocument {
  private readonly id: string;
  private text: string;

  constructor(id: string, text: string) {
    this.id = id;
    this.text = text;
  }

  public publish(): Post {
    return new Post(this.id, this.text);
  }
}

export class Draft {
  private readonly id: string;
  private text: string;

  constructor(id: string, text: string) {
    this.id = id;
    this.text = text;
  }

  public changeText(newText: string): void {
    this.text = newText;
  }

  public accept(): AcceptedDocument {
    return new AcceptedDocument(this.id, this.text);
  }
}

Dzięki takiemu rozwiązaniu pozbywamy się pola status oraz masy if’ów. Zamiast nich tworzymy osobne klasy tylko z takimi zachowaniami na jakie pozwalają. Jest to o tyle fajne rozwiązanie, że już na poziomie pisania kodu dostaniemy błąd, mówiący o tym, że dana metoda nie istnieje po tym jak na przykład spróbujemy wywołać metodę changeText na klasie Post. Dodatkowo w działającej aplikacji zamiast sprawdać czy status jest odpowiedni do wykonania jakiejś akcji możemy po prostu rzucić błąd 404. Ponieważ, już na poziomie pobierania takiego dokumentu z bazy stwierdzimy, że przecież nie mamy takiego Draftu, bo został on już dawno opublikowany.

Jednak nie zawsze takie rozbicie encji na mniejszej jest tak proste jak w tym przypadku. Czasami te stany nie są tak oczywiste i nie możemy tak po prostu pociąć encji na mniejsze. W takim wypadku należy szukać tzw. kluczowych cięć biznesowych, które pozwolą nam pociąć encję. Takie miejsca charakteryzują się tym, że po dokonaniu jakiejś akcji, nie możemy już w łatwy sposób wrócić do porzedniego stanu.

Przykładowo takim miejscem może być dokonanie płatności za zamówienie. Jak wszyscy wiemy, wycofanie się z takiej akcji to nie jest prosta sprawa. Musimy pisać do supportu, podawać powody itp. Nie możemy również od tak zmienić już opłaconego zamówienia. Identyfikacja takich miejsc nie jest prosta i wymaga zastosowania różnych technik takich jak Event Storming. Natomiast pozwala nam ona wyznaczyć jasne granicę pomiędzy klasami, tak aby nie pchać zbyt wiele odpowiedzialności do jednego bytu.

Należy być również pragmatycznym i jeśli widzimy, że liczba stanów jest mała i prawdopodobnie się nie zmieni to stworzenie jednej klasy nie będzie problemem. Wręcz nam pomoże, gdyż znacznie przyspieszymy proces developmentu, a kod nie ucierpi przy tym na czytelności. Warto się również zastanowić nad implementacją tzw. maszyny stanów dzięki której będziemy mogli zadeklarować jasne przejścia pomiędzy poszczególnymi stanami oraz zadbać o poprawność danych.

Przydatne rzeczy do pracy z entity

Jak wyciągnąć prywatne pola?

Jak pewnie zauważyliście większość pól w naszych klasach jest prywatna. Ktoś słusznie może zadać pytanie: jak mam przekazać te dane na front albo zapisać je w bazie danych?

Pomocnym rozwiązaniem może okazać się dodanie dwóch metod toDTO dzięki której wyciągniemy nasze pola z myślą o wyświetleniu ich na froncie lub przekazaniu do innego serwisu oraz toSnapshot dzięki, której będziemy mogli uzyskać dane potrzebne do zapisu.

interface PostDTO {
  id: string;
  text: string;
}

interface PostSnapshot {
  id: string;
  text: string;
}

export class Post {
  private readonly id: string;
  private text: string;

  constructor(id: string, text: string) {
    this.id = id;
    this.text = text;
  }
  
  public toDTO(): PostDTO {
    return {
      id: this.id,
      text: this.text,
    }
  }
  
  public toSnapshot(): PostSnapshot {
    return {
      id: this.id,
      text: this.text,
    }
  }
}

Dlaczego metody, a nie publiczne pola? Ponieważ zależy nam na enkapsulacji. Nie chcemy udostępniać naszych pól na zewnątrz i pozwalać na dowolną edycję. Chcemy aby były prywatne i możliwe do zmiany tylko z poziomu klasy. Lepszym rozwiązaniem jest stworzenie metod, które zwrócą nam takie dane. Należy jednak pamiętać, aby zwracane dane nie posiadały referencji do wewnętrznych pól znajdujących się w klasie na przykład obiektów.

Dlaczego dwie metody? Ponieważ, nie zawsze to co chcemy zapisać do bazy danych chcemy również przekazać do innego serwisu. Przykładem może być hasło użytkownika. Chcemy je zapisać w bazie danych (oczywiście w zaszyfrowanej formie), ale nie chcemy go przesyłać do innych serwisów, a tym bardziej wyświetlać na froncie.

Typescript

Bardzo pomocne jest również użycie TypeScript, który zapewnia nam typy bardzo ułatwiające pracę z encjami i nie tylko.

ID

Przydatne jest również stworzenie ID nie jako zwykłego stringa, ale jako value object. Dzięki temu unikniemy omyłkowego przekazania nieprawidłowego id i wykryjemy błąd już na poziomie kompilacji:

function getPost(userId: string, postId: string): Post {
  const post = repository.get(postId);

  if (!post.isOwner(userId)) {
    throw new Error('');
  }

  return post;
}

getPost('postId', 'userId'); // NIEPOPRAWNA KOLEJNOŚĆ
getPost('userId', 'postId');

Ważne jest to, aby value object’y id były typowane nominalnie! W przeciwnym razie nie przyniosą nam żadnej korzyści.