Entity i Value Object w taktycznym Domain-Driven Design - okładka

Entity i Value Object w Domain-Driven Design

Opublikowano Kategorie DDDCzas czytania 9min

Entity i Value Object to building blocks z obszaru taktycznego Domain-Driven Design. W tym artykule poznasz ich charakterystykę i zastosowanie, a także dowiesz się, czym jest problem Primitive Obsession i jak można sobie z nim radzić.

Czym jest Entity

Zaraz po Model i Service, Entity jest pojęciem opisującym tak wiele w naszej branży, że definicja może się różnić w zależności od tego, kogo zapytamy i kiedy to zrobimy.

W kontekście Domain-Driven Design definicja Entity jest jednak jednoznacznie określona. Entity to obiekt modelujący jakieś pojęcie biznesowe, który ma jasno zdefiniowaną tożsamość (identity) oraz ciągłość (continuity).

Tożsamość pozwala nam stwierdzić, że mówimy o tej konkretnej Entity. Jesteśmy w stanie odróżnić je od innych obiektów tego samego typu, nawet jeśli są one pozornie identyczne. Przykładowo, mając w systemie dwóch Janów Kowalskich urodzonych tego samego dnia, jesteśmy każdorazowo w stanie wskazać, którego Jana mamy na myśli. Tożsamość możemy zapewnić Entity przez nadanie unikalnego identyfikatora. Identyfikator może być zarówno naturalny, taki jak PESEL czy numer faktury, jak i syntetyczny, np. UUID. Dzięki temu możemy odróżnić od siebie poszczególne Entity, nawet jeśli mają identyczne właściwości.

Ciągłość Entity sprawia, że jesteśmy w stanie rozpoznać ją jako ten sam obiekt w różnych momentach jej cyklu życia. Jesteśmy w stanie zidentyfikować ją i przypisać do konkretnych zdarzeń w systemie. Tożsamość Entity jest zachowana mimo zmian jej właściwości i stanu.

Entity może zawierać inne obiekty, zarówno Entities, jak i Value Objecty. W DDD takie obiekty mogą być grupowane w Agregaty, które definiują granicę spójności modelu.

Entity powinno kontrolować zmiany swojego stanu oraz pilnować reguł dotyczących jego stanu i zachowania. Powinno również chronić swoje inwarianty domenowe (niezmienniki), czyli reguły, które zawsze muszą być spełnione przez obiekt domenowy. Przykładowo, jeśli w Entity User mamy inwariant, że system może zawierać konta tylko pełnoletnich użytkowników, to taki inwariant powinien być częścią logiki Entity User. Entity będące jedynie opakowaniem na dane mogą potencjalnie świadczyć o obecności anemicznych modeli domenowych w naszym systemie.

Należy jednak pilnować, aby metody definiowane w ramach Entity nie wykraczały poza jej zakres odpowiedzialności. Jeśli operacja wymaga współpracy kilku obiektów domenowych i nie ma naturalnego właściciela, warto rozważyć przeniesienie jej do Domain Service.

Value Object

Value Object w przeciwieństwie do Entity nie posiada tożsamości, a jedynie opisuje cechy określonego obiektu lub pojęcia. W przypadku Value Objects interesuje nas ich wartość, a nie tożsamość.

Value Objects z reguły są niemutowalne, tzn. ich wartość nie ulega zmianie i chcąc ją zmienić, należy powołać do życia nowy Value Object. Oznacza to również, że Value Object nie zachowuje ciągłości. Możemy swobodnie tworzyć i zastępować jeden Value Object innym o nowej wartości, bez potrzeby śledzenia jego indywidualnej historii.

Niemutowalność ma na celu zapobiec niespójnościom w systemie, ponieważ ten sam Value Object można wykorzystywać wielokrotnie, a także kopiować. Evans jednak dopuszcza kilka przypadków, w których można rozważyć złamanie warunku niemutowalności:

  • jeśli wartość reprezentowana przez Value Object bardzo często się zmienia;
  • gdy tworzenie lub usuwanie obiektu jest kosztowne;
  • jeśli zastąpienie obiektu nowym naruszyłoby grupę obiektów;
  • gdy Value Objecty nie są współdzielone lub gdy faktycznie prowadzi to do usprawnienia pracy systemu.

W praktyce niemutowalność powinna być domyślnym wyborem.

Podobnie jak Entity, w przypadku Value Objectów również powinniśmy zadbać o to, by pilnowały swoich niezmienników. Dzięki temu nie musimy martwić się każdorazowo o walidację.

Value Objects również mogą zawierać inne Value Objects. Przykładowo, jeśli obiekt klasy Amount przechowuje kwotę, chcielibyśmy również przechowywać informację o jej walucie. W takim przypadku Amount może zawierać Currency.


class Currency {
  constructor(
    public readonly code: string
  ) {}
}

class Amount {
  constructor(
    public readonly value: number,
    public readonly currency: Currency
  ) {}
}

const pln = new Currency('PLN');
const amount = new Amount(100, pln);

console.log(amount.value, amount.currency.code); // 100 PLN

Typowymi przykładami Value Objectów są pieniądze (Money), waluta (Currency), adres (Address), adres e-mail (Email), kolory czy daty. Poniżej znajdziesz przykład niewielkiego modelu domenowego wykorzystującego Entity i Value Objects.


// Nie wszystko musi być Value Object - czasami typ jest wystarczający.
type OrderStatus = 'pending' | 'paid' | 'cancelled';

class Currency {
  constructor(
    public readonly code: string
  ) {
    // Uproszczenie - w praktyce walidacja powinna uwzględniać
    // tylko prawdziwe kody
    if (!/^[A-Z]{3}$/.test(code)) {
      throw new Error('Invalid currency code');
    }
  }
}

class Amount {
  constructor(
    public readonly value: number,
    public readonly currency: Currency
  ) {
    if (value < 0) {
      throw new Error('Amount cannot be negative');
    }
  }
}

class Order {
  constructor(
    public readonly id: string,
    private total: Amount,
    private status: OrderStatus = 'pending'
  ) {}

  pay(): void {
    if (this.status !== 'pending') {
      throw new Error('Order cannot be paid');
    }

    this.status = 'paid';
  }

  cancel(): void {
    if (this.status === 'cancelled') {
      throw new Error('Order is already cancelled');
    }

    this.status = 'cancelled';
  }
}

const order = new Order(
  'order-123',
  new Amount(100, new Currency('PLN'))
);

order.pay();

Właściwości takie jak kwota czy waluta zostały opisane Value Objectami. Każde z nich pilnuje swoich inwariantów. W przypadku Entity Order obiekt ma swój identyfikator.

Problem Primitive Obsession

Z Value Objectami wiąże się również problem Primitive Obsession. Prymitywne typy opisują typ danych, ale nie niosą ze sobą semantyki domenowej. Sam typ number mówi nam jedynie, że mamy do czynienia z liczbą. Nie mówi natomiast, czy jest to kwota, wysokość podatku czy temperatura. W silnie typowanych językach często możemy stworzyć bardziej precyzyjne typy, np. listę dozwolonych wartości. Czasami jednak to za mało, by ustrzec się przed wpadkami. Polegając na prymitywach, pomijamy semantyczny aspekt opisu danej zmiennej czy parametru.

Pierwszym z problemów wynikających z nadmiernego polegania na typach prymitywnych jest ryzyko pomyłki, która nie zostanie wyłapana na etapie kompilacji lub sprawdzania typów. Spójrz na poniższy przykład funkcji do obliczania podatku VAT:


function calculateTax( amount: number, taxPercentage: number ): number {
  return amount * taxPercentage / 100;
}

const correctTax = calculateTax( 100, 23 );

const incorrectTax = calculateTax( 23, 100 );

console.log( correctTax, incorrectTax );

Technicznie oba wywołania funkcji calculateTax są poprawne. Jednak drugie wywołanie jest semantycznie niepoprawne – stawka VAT 100% nie jest dozwolona. Obecnie na etapie sprawdzania typów nic nas nie chroni przed takim błędem. Oczywiście z dużym prawdopodobieństwem taka pomyłka zostałaby wyłapana na etapie pisania testów czy code review. Jednak w ferworze pisania kodu jest to błąd trudny do wyłapania.

Jednym ze skutecznych rozwiązań tego problemu jest skorzystanie z Value Objectów. Zamiast polegać na typie number, możemy oczekiwać Value Objectów.


class Amount {
  private readonly _brand!: void;

  public readonly value: number;

  constructor(value: number) {
    if (value < 0) {
      throw new Error("Amount cannot be negative");
    }
    this.value = value;
  }
}

class TaxPercentage {
  private readonly _brand!: void;

  constructor(public readonly value: number) {
    const allowedRates = [0, 5, 8, 23];

    if (!allowedRates.includes(value)) {
      throw new Error(`Invalid VAT rate: ${value}%`);
    }
  }
}

function calculateTax(amount: Amount, taxPercentage: TaxPercentage): number {
  return amount.value * taxPercentage.value / 100;
}

const correctTax = calculateTax(new Amount(100), new TaxPercentage(23));

// Argument of type 'TaxPercentage' is not assignable to
// parameter of type 'Amount'.
// const incorrectTax = calculateTax(new TaxPercentage(23), new Amount(100));

Ponieważ TypeScript wykorzystuje typowanie strukturalne, wykorzystałem branding pattern, by odróżnić instancje poszczególnych klas. W przeciwnym razie, mimo wykorzystania Value Objectów, błędne wywołanie nie spowodowałoby błędu.

Drugim problemem wynikającym z Primitive Obsession jest brak możliwości walidowania reguł innych niż zgodność typu. W przykładzie z obliczaniem podatku nic nie chroni nas przed np. podaniem ujemnej kwoty. Konieczne byłoby zawarcie odpowiednich reguł w funkcji calculateTax. Jednak miejsc, w których musielibyśmy sprawdzać nieujemność kwot, może być znacznie więcej. To zwiększa ryzyko przypadkowego pominięcia sprawdzenia. W przypadku bardziej skomplikowanych warunków może również prowadzić do niespójności między poszczególnymi miejscami w systemie.

Wykorzystując Value Objects, jesteśmy w stanie zawrzeć w definicjach ich klas niezmienniki domenowe. Value Object przy inicjalizacji pilnuje poprawności wartości, a zawarcie reguł w jego klasie skupia odpowiedzialność za walidację inwariantów i poprawność obiektu domenowego w jednym miejscu w systemie. Przedstawiony wcześniej przykład wykorzystuje Value Objects definiujące inwarianty dla kwoty oraz podatku.

Ostatnią, lecz nie mniej ważną korzyścią z wykorzystania Value Objectów jest możliwość posługiwania się językiem domenowym. Jeśli kwota i procent podatku to pojęcia domenowe zdefiniowane w ramach języka wszędobylskiego, to użycie ich zamiast prymitywnych typów języka może zwiększyć czytelność i zrozumienie danego fragmentu kodu.

Przy tym wszystkim nie należy jednak popadać ze skrajności w skrajność. Nie każdy typ prymitywny wymaga zmiany. Stosujmy Value Objects tam, gdzie reprezentowana wartość ma własną semantykę, reguły lub zachowanie.

Podsumowanie

Entity i Value Object to building blocks, które warto znać. Szczególnie warto zapoznać się z Value Objects, nawet jeśli na co dzień nie pracujemy w Domain-Driven Design.

Zachęcam do sprawdzenia źródeł i materiałów dodatkowych. Szczególnie polecam zwrócić uwagę na dwie pierwsze pozycje, które są dobrym źródłem wiedzy o DDD.

Osobom nowym w obszarze Domain-Driven Design polecam również prezentację Kamila Grzybka, która w kilkadziesiąt minut przystępnie wprowadza i omawia najważniejsze pojęcia.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, dobrej kawy i miłośnik jazdy na rowerze.

Inne wpisy, które mogą Cię zainteresować

Kolejna książka o Gicie — naucz się korzystać z Gita jak profesjonalista

Okładka e-booka - Kolejna książka o Gicie

"Kolejna książka o Gicie" to kompleksowy e-book, który pozwoli Ci poznać Gita od A do Z, a także liczne narzędzia dedykowane pracy z Gitem!

Dlaczego warto?

  • 👉 Git od podstaw do poziomu PRO. E-book przeprowadzi Cię krok po kroku, niezależnie od poziomu doświadczenia.
  • 👉 Zdobędziesz praktyczne umiejętności, które natychmiast wykorzystasz w prawdziwych projektach.
  • 👉 Wiedza połączona z praktyką! Oprócz masy teorii w e-booku znajdziesz też zadania praktyczne.

Okładka e-booka - Kolejna książka o Gicie

Nie przegap kolejnych artykułów

Zapisz się na mój mailing, jeśli chcesz być na bieżąco z nowymi artykułami. Wystarczy, że skorzystasz z formularza poniżej 👇
Po zapisie otrzymasz także fragment „Kolejnej książki o Gicie”.

guest

0 komentarzy
Najwięcej głosów
Najnowsze Najstarsze