Value Object (wartość)

Przybliżę dzisiaj koncept Value Objects, które w głównej mierze mogą kojarzyć się z Domain Driven Design, ale czy powinniśmy wrzucać je do tego samego worka? Dziś o tym dlaczego Value Object to nie coś dedykowanego jedynie DDD.


Value Object

Koncepcja Value Objects pochodzi od podejścia Domain Driven Design, którego twórcą jest Eric Evans. Jednak czy VO mogą nie iść w parze z DDD? Oczywiście, że tak i bardzo każdego zachęcam do ich stosowania, nawet jeśli nie implementujemy żadnego z poziomów Domain Driven. Lecz czym tak naprawdę są wartości? Tak, wartości, ponieważ jest to prawidłowe tłumaczenie nazwy tego wzorca na język polski. Można spotkać się również z tłumaczeniem "obiekt wartości", jednak, jakby to ująć, jest tu trochę za dużo powiedziane. 

Value Object jest obiektem, który nie posiada tożsamości czyli nie możemy go zidentyfikować na tle zbioru. Jest także niemutowalny, czyli niezmienny. Nie można zmodyfikować żadnej jego części po utworzeniu, ponieważ wszystkie jego podwartości tworzą integralną całość. Jak mawia Linus Torvalds: "talk is cheap, show me the code" - przejdźmy do przykładów.

Pieniądze szczęścia nie dają

Klasyką w kontekście Value Objects jest już przykład związany z pieniędzmi. Wyobraźmy sobie, że nasza koleżanka Jadzia chce pożyczyć od nas 20 złotych. Jesteśmy dobrymi znajomymi i po chwili wyciągamy banknot dwudziestozłotowy z portfela i wręczamy go Jadzi. Jak myślisz, czy w tym kontekście ten banknot posiada tożsamość (jest encją) czy też nie (jest value object-em)? To zależy! Jeżeli jest to unikatowa seria dwudziestek, a my jesteśmy związani z naszym pieniążkiem, to oczywiście chcemy odzyskać ten sam banknot i prawdopodobnie zweryfikujemy go sprawdzając jego numer seryjny - w tym przypadku dwudziestka ma tożsamość (jest encją). Teraz załóżmy, że nie zależy nam na odzyskaniu tego samego banknotu, a jedynie kwoty, którą pożyczyliśmy. Mamy wtedy do czynienia z wartością. Czym taka wartość może się cechować? Na pewno kwotą, równą 20, a także walutą: PLN.

export class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: string
  ) { }
}

const myMoney = new Money(20, 'PLN');
Zgodnie z tym, co napisałem we wstępie, wszystkie podwartości są niemutowalne, dlatego są readonly. W czym taki model Money może nam pomóc? Chociażby w utrzymaniu spójności danych, udostępnieniu przyjemnego interfejsu do manipulacji i tworzeniu samoopisującego się kodu, a przede wszystkim transparentnego przestrzegania reguł biznesowych.

export enum Currency {
  PLN = 'PLN',
  EUR = 'EUR',
  USD = 'USD',
}

export class Money {
  private static readonly allowedCurrencies = [Currency.PLN, Currency.USD];   

  constructor(
    public readonly amount: number,
    public readonly currency: Currency
  ) {
    if (amount <= 0) {
      throw new Error('Money amount needs to be greater than 0.');
    }

    if (!Money.allowedCurrencies.includes(currency)) {
      throw new Error(`Currency ${currency} is not allowed.`);
    }
  }

  static fromString(money: string): Money {
    let [amount, currency] = money.split(' ');

    if (!currency) {
      throw new Error('Currency is missing.');
    }

    amount = Number.parseFloat(amount);

    if (Number.isNaN(amount)) {
      throw new Error('Wrong amount.');
    }

    return new Money(amount, currency);
  }

  add(money: Money): Money {
    if (!this.currencyIs(money.currency)) {
      throw new Error('Currency missmatch.');
    }

    return new Money(this.amount + money.amount, this.currency)
  }

  toString = (): string => 
    `${this.getAmount().toFixed(2)} ${this.getCurrency()}`;

  private currencyIs = (currency: Currency): boolean =>
    this.currency === currency;
}
Podczas tworzenia nowej instancji sprawdzamy czy wartość jest większa od zera oraz czy podana waluta jest poprawna. W przypadku nieprawidłowości rzucamy wyjątkiem, może to być np. RuntimeException. Stworzyłem także metodę fromString(), która zamienia wartości zapisane w stringu do postaci obiektu Money. Może nie jest to do końca prawidłowe rozwiązanie, ponieważ lepsze wydaje się zwracanie całego obiektu, jednak przyjmijmy, że ktoś lub coś tego od nas wymaga. Mamy także metodę toString(), zamieniającą Money do adekwatnego ciągu znaków. Poza tym, prywatna metoda do porównywania dwóch walut. Oprócz tego widoczna jest metoda add(), która sumuje dwie wartości i zwraca nową (Value Objects są niemutowalne!), jednocześnie sprawdzając, czy ktoś nie próbuje dodać 20 PLN do 15 EUR.

Światła, kamera, akcja!

Znamy już wartość pieniądza i jak mogliśmy zauważyć, musimy pamiętać o bardzo wielu aspektach podczas implementacji Money VO. Czym jednak byłby ten blog bez życiowego przykładu? Wyobraźmy sobie, że posiadamy prosty system skarbonki, do którego możemy wpłacać dowolne kwoty i chcemy mieć wgląd w historię transakcji oraz móc je rozróżniać. Musimy więc skonwertować nasz Value Object do postaci, którą akceptuje baza danych przy zapisie, zaś przy odczycie utworzyć obiekt Money.

export class Transaction {
   constructor(
     private readonly id: TransactionId,
     private readonly amount: Money,
     private readonly createdAt: Date,
   ) { }
}

export class TransactionRepository {
  constructor(
    private readonly transactionDao: TransactionDao,
  ) { }  

  save = (transaction: Transaction): Promise<void> =>
    this.dao.save(transaction);

  async findOne(id: TransactionId): Promise<Transaction> {
    const { amount, createdAt } = await this.transactionDao.findOne(id.toString());

    return new Transaction(
       id,
       Money.fromString(amount),
       new Date(createdAt)
    );
  }
}

Inny przykład Value Object

Oczywiście, Value Objects to nie tylko pieniądze. W codziennym życiu spotykamy wiele wartości, które możemy zaimplementować w ten sposób. Mogą to być np. współrzędne posiadające długość i szerokość geograficzną, adres zawierający ulicę i numer domu, czy cokolwiek innego, co posiada cechę niemutowalności i braku tożsamości. Poniżej przedstawiam jeszcze przykład punktu na płaszczyźnie kartezjańskiej, który zawiera dwie pod-wartości: współrzędną x oraz y. Może on udostępniać np. metodę do porównywania z innym punktem.

export class Point {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}

  static fromString(coordinates: string): Point {
    let [x, y] = coordinates.split(',');

    x = Number.parseFloat(x);

    y = Number.parseFloat(y);

    if (Number.isNaN(x) || Number.isNaN(y)) {
       throw new Error(`Incorrect coordinates: ${coordinates}`);
    }

    return new Point(x, y);
  }

  equals = (point: Point): boolean =>
    point.x === this.x && point.y === this.y;

  toString = (): string =>
   `${this.x},${this.y}`
}

Podsumowując




Polecane wpisy:

Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

Sprawdź ten wpis

Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii 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.

Sprawdź ten wpis

Wzorzec adapter

Strukturalny wzorzec projektowy adapter to spore udogodnienie w walce z systemami legacy. Pozwala użyć niepasującego interfejsu w innym. Dzięki temu zabiegowi pomimo, iż nie posiadamy spójnej abstrakcji możemy zastosować pożądaną logikę.

Sprawdź ten wpis

Wzorzec dekorator

Strukturalny wzorzec projektowy dekorator, to lekarstwo na problemy z mnogością różnych podtypów. Pozwala on na dynamiczne generowanie kolejnych typów obiektów bazujących na pierwotnym typie, bez konieczności deklarowania nowych, dedykowanych klas.

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.