value object

Value object – czyli jak nie być prymitywem

W dzisiejszym artykule chciałbym się z Wami podzielić swoimi spostrzeżeniami na temat tego w jaki sposób pisać „lepszy” kod. Na pewno każdy z nas nie raz, nie dwa miał do czynienia z wartościami prymitywnymi danego języka. Jest to wręcz nieuniknione przy codziennej pracy z kodem. Jednak nie zawsze bezpośrednie stosowanie tych wartości jest najlepszym rozwiązaniem i często prowadzi do ogromnych problemów. Aby Wam to lepiej zaprezentować posłużę się kilkoma przykładami:

Przykład pierwszy (Moduł autoryzacji)

Na początek zaczniemy od najbardziej prostego przykładu z możliwych. Wyobraźmy sobie, że tworzymy bardzo prosty moduł autoryzacji, a konkretnie funkcjonalność odpowiedzialną za rejestrację. Nasza funkcjonalność musi spełniać 3 reguły:

  1. Użytkownik o takim samym adresie nie może już istnieć w systemie
  2. Podany adres email musi być poprawny
  3. Hasło nie może być krótsze niż 4 znaki

Kod takiej funkcjonalności będzie wyglądał następująco:

interface User {
  id: string;
  email: string;
  password: string;
}

declare function isEmail(text: string): boolean
declare function getUser(email: string): User | undefined;
declare function createUser(email: string, password: string): User;

class Authorization {
  public register(email: string, password: string): void  {
    const user = getUser(email.toLowerCase());

    if (user) {
      throw new Error('User already exists')
    }

    if (password.length < 4) {
      throw new Error('Password is invalid')
    }

    if (!isEmail(email)) {
      throw new Error('Invalid email')
    }

    createUser(email, password);
  }
}

Na pierwszy rzut oka wszystko wygląda dobrze, a więc pora zarejestrować nasze pierwsze konto!

authorization.register('test@test.pl', '1234'); // OK
authorization.register('test', '123'); // NIE OK ponieważ email oraz hasło są niepoprawne
authorization.register('TEST@test.pl', '1234'); // NIE OK ponieważ użytkownik już istnieje

Jak widzimy kod działa poprawnie. W pierwszym przypadku udało się zarejestrować użytkownika, natomiast w drugim podaliśmy błędny email oraz zbyt krótkie hasło, a więc nie udało się tego zrobić.

Po jakimś czasie przychodzi do nas klient i prosi o dodanie funkcjonalności do zmiany adresu email. Na nasze nieszczęście funkcjonalność dodaje nowy deweloper, który wczesniej nie miał styczności z modułem autoryzacji i nic nie wie o istniejących regułach. Oczywiście na potrzeby artykułu przykład jest bardzo prosty i ktoś może powiedzieć, że wystarczy popatrzeć w metodę register aby je poznać, ale nie oszukujmy się w prawdziwym świecie nie wygląda to tak kolorowo 🙂 Nasza klasa po dodaniu nowej funkcjonalności będzie wyglądać mniej więcej w taki sposób:

interface User {
  id: string;
  email: string;
  password: string;
}

declare function isEmail(text: string): boolean
declare function getUser(email: string): User | undefined;
declare function createUser(email: string, password: string): User;

class Authorization {
  public register(email: string, password: string): void  {
    const user = getUser(email.toLowerCase());

    if (user) {
      throw new Error('User already exists')
    }

    if (password.length < 4) {
      throw new Error('Password is invalid')
    }

    if (!isEmail(email)) {
      throw new Error('Invalid email')
    }

    createUser(email, password);
  }

  public changeEmail(currentUser: User, newEmail: string): void {
    const user = getUser(newEmail);

    if (user && user.id !== currentUser.id) {
      throw new Error('User with this email already exists')
    }

    user.email = newEmail;
  }
}

Sprawdźmy zatem jak działa nasza nowa metoda:

const user = authorization.register('test@test.pl', 'test'); // OK
authorization.changeEmail(user, 'drycode@test.pl'); // OK
authorization.changeEmail(user, 'test'); // OK ?!?!

Jak widzimy w 3 linii mamy błąd, ponieważ nasz nowy deweloper nie uwzględnił reguły, która mówi, że email musi być poprawnym adresem email. Idźmy zatem dalej:

const user = authorization.register('test@test.pl', 'test'); // OK
const user2 = authorization.register('test2@test.pl', 'test'); // OK
authorization.changeEmail(user, 'TEST2@test.pl'); // OK ?!?!

I mamy kolejny błąd, ponieważ deweloper nie użył również metody toLowerCase w momencie przekazania adresu email do funkcji getUser, a więc pomimo tego, że email był zajęty przez drugiego użytkownika, pierwszy użytkownik mógł go użyć ponownie.

A co jeśli adres email byłby używany po za tym serwisem np. do wysyłania notyfikacji? Wtedy problem eskalował by jeszcze bardziej. Co więcej trzeba by było duplikować wszędzie logikę sprawdzającą poprawność adresu email.

Ktoś mógłby powiedzieć, że przecież taką logikę można wyciągnąć do utils. Ale czy na pewno chcemy to robić? Przecież ktoś może zapomnieć o wywołaniu takiej metody i znowu będziemy mieli problem. Co więcej ważna logika biznesowa wycieknie nam do utilsów, które z definicji powinny być kodem narzędziowym, a nie domenowym. Podobny problem będziemy mieli przy dodawaniu funkcjonalności do zmiany hasła. Jeśli ktoś nie uwzględni reguły sprawdzającej czy hasło ma min. cztery znaki to użytkownik będzie mógł ustawić np. pusty ciąg znaków.

Przykład drugi (Logowanie za pomocą facebook’a)

Wyobraźmy sobie funkcjonalność logowania za pomocą facebooka. Przy takich integracjach dobrą praktyką jest aby nie używać id z zewnętrznego serwisu jako głównego id w naszym systemie. Istnieje ku temu kilka powodów, które nie są tematem tego artykułu. Przykładowy kod mógłby wyglądać następująco:

interface User {
  id: string;
  externalId: string;
}

declare function getUserById(id: string): User | undefined; // SELECT id, "externalId" FROM users WHERE user.id === {id}
declare function decodeToken(token: string): { id: string };
declare function addToActiveUsersList(id: string): void;

function loginViaFacebook(token: string): User {
  const { id } = decodeToken(token);
  const user = getUserById(id);

  if (!user) {
    throw new Error('User does not exist')
  }

  addToActiveUsersList(id);

  return user;
}

Kod znowu wydaje się być w porządku, ale przyjrzyjmy się bliżej:

  1. Funkcja getByUserId wyszukuje użytkownika po id, a nie po externalId które pochodzi z facebooka, a więc już mamy pierwszy błąd. W naszym kontekście metoda zawsze zwróci undefined. Oczywiście deweloper dodający funkcjonalność logowania przez facebooka mógł tego nie zauważyć, bo przecież typ id pochodzącego z tokenu zgadza się z typem id, które przyjmuje funkcja getByUserId. Błąd wyjdzie nam dopiero w testach lub co gorsza na produkcji.
  2. Czy funkcja addToActiveUsersList jako parametr przyjmuje id użytkownika z naszego systemu, czy może externalId? Nie wiemy tego dopóki nie popatrzymy w implementację tej metody.

Przykład trzeci (Operacje na pieniądzach)

Pójdźmy o krok dalej i zamiast skupiać się na mailu, haśle czy id, skupmy się na pieniądzach.

function addMoney(firstValue: number, secondValue: number, firstValueCurrency: string, secondValueCurrency: string): number {
  if (firstValueCurrency !== secondValueCurrency) {
    throw new Error('Invalid currency');
  }

  return firstValue + secondValue;
}

function subtractMoney(firstValue: number, secondValue: number, firstValueCurrency: string, secondValueCurrency: string): number {
  return firstValue - secondValue;
}

addMoney(1, 1, 'EUR', 'EUR'); // OK
addMoney(1, 1, 'DRYCODE', 'DRYCODE'); // OK ?!?!
subtractMoney(1, 2, 'EUR', 'PLN') // OK ?!?!

Wyobraźmy sobie sytuację, w której powyższy kod zostałby użyty w aplikacji bankowej i KTOŚ zapomniałby uwzglęnić w którymś miejscu logikę sprawdzającą czy waluta ma poprawny format oraz czy możemy dodać do siebie dwie różne waluty. Chyba nie musze mówić jak by się to skończyło…

Co jest powodem powyższych problemów?

Głównym powodem jest oczywiście nadmierne używanie prymitywów.

  • Email to nie jest zwykły string. Jest to string, który ma określony format oraz, który chcemy porównywać w specyficzny sposób (toLowerCase).
  • Hasło to również nie jest zwykły string, ale ma swoje reguły takie jak np. minimalna ilość znaków.
  • Id też nie jest zwykłym ciągiem znaków. Identyfikator ma określony format, reguły umożliwiające porównanie dwóch id oraz powinien być rozróżnialny spośród innych identyfikatorów. Tak, żeby nie pomylić go np. z externalId, które jest zupełnie innym bytem.
  • Pieniądze również nie są prostym number . Mają swoje reguły, które pozwalają na dodanie dwóch wartości lub ich odjęcie. Wymagają również aby waluta była odpowiednia, no bo przecież do banku nie możemy przesłać wymyślonej waluty. Pieniędzmi obraca się również w specyficzny sposób, np. zaokrąglając jakieś wartości do iluś miejsc po przecinku.

Używanie prymitywów prowadzi do tego, że logika obsługująca email oraz pieniądze jest rozrzucona po całym systemie lub ląduje w folderze utils. W efekcie czego albo mamy duplikacje kodu, albo kod narzędziowy zawiera ważną logikę biznesową, która powinna być pilnowana i enkapsulowana w jednym miejscu. Możemy również zapomnieć o wywołaniu metody z utils i o błędzie dowiemy się dopiero w testach (o ile je mamy i pod warunkiem, że uwzględnimy dany przypadek) lub co gorsza na produkcji.

A co gdybym Ci powiedział, że powyższych błędów można łatwo uniknąć oraz wyłapać je na poziomie kompilacji? Co gdyby nasze IDE mogło zaświecić na czerwono, jeśli do metody przekażemy np. nieodpowiednie id? Na szczęście z pomocą przychodzi nam value object.

Czym jest value object?

Jest to po prostu klasa, która enkapsuluje w sobie logikę biznesową, czyli zachowania oraz reguły jakiegoś bytu na przykład pieniędzy. Taka klasa powinna zawierać metody odpowiedzialne np. za porównywanie dwóch monet, sprawdzanie poprawności waluty itp.

Co więcej value object w przeciwieństwie do encji (o których kiedyś również opowiem) nie posiada tożsamości. To znaczy, że charakteryzują go jedynie wartości, które posiada. Aby Wam to lepiej zobrazować posłużę się analogią.

W Polsce mamy wielu Janów Kowalskich. Pomimo tego, że wszyscy noszą takie samo imię oraz nazwisko to są różnymi osobami. Każdego z nich można łatwo zidentyfikować na przykład po numerze pesel. Więc jeden Jan Kowalski nie jest równy drugiemu Janowi Kowalskiemu. Zupełnie inaczej wygląda to w przypadku monet. Wyobraźcie sobie sytuację w której dajecie mi 1zł monetę, a ja Wam oddaję inną 1zł monetę. To którą monetę dostaniemy nie ma dla nas kompletnie żadnego znaczenia, ponieważ obie monety mają dokładnie taką samą wartość i są sobie równe, a więc bez problemu można się nimi wymieniać. Dokładnie tak samo będzie jeśli dacie mi monetę 5zł, a ja Wam oddam 5 monet o wartości 1zł każda. Tak właśnie działają value object’y.

Najłatwiej jest to zapamiętać w taki sposób, że encja to jeden rekord w bazie z jakimś unikalnym identyfikatorem, natomiast value object jest jedynie kolumną w tabeli z jakąś wartością.

Value object musi być NIEMUTOWALNY. Nie możemy zmienić wartości monety w już utworzonej instancji danej klasy, lecz musimy utworzyć nową instancję z nową wartością.

Dlaczego? Ponieważ dzięki takiemu rozwiązaniu nie musimy się przejmować, czy ktoś inny nie podmienił nam wartości. Na przykład przez przekazanie referencji do danej instancji lub użycia jej w innym wątku w przypadku języka wielowątkowego. Mamy pewność, że wartość w utworzonej instancji nigdy się nie zmieni i będziemy mogli jej bezpiecznie używać.

Aby lepiej zrozumieć w jaki sposób utworzyć value object oraz jak go użyć w praktyce posłuże się przykładami, których użyłem na początku artykułu.

Przykład pierwszy (Moduł autoryzacji)

Jak pewnie pamiętasz w przykładzie pierwszym nasz kod wyglądał następująco:

interface User {
  id: string;
  email: string;
  password: string;
}

declare function isEmail(text: string): boolean
declare function getUser(email: string): User | undefined;
declare function createUser(email: string, password: string): User;

class Authorization {
  public register(email: string, password: string): User  {
    const user = getUser(email.toLowerCase());

    if (user) {
      throw new Error('User already exists')
    }

    if (password.length < 4) {
      throw new Error('Password is invalid')
    }

    if (!isEmail(email)) {
      throw new Error('Invalid email')
    }

    return createUser(email, password);
  }

  public changeEmail(currentUser: User, newEmail: string): void {
    const user = getUser(newEmail);

    if (user && user.id !== currentUser.id) {
      throw new Error('User with this email already exists')
    }

    user.email = newEmail;
  }
}

Mieliśmy tutaj kilka problemów:

  1. Logika odpowiedzialna za porównywanie adresu email oraz sprawdzanie jego poprawności była rozrzucona po całym systemie.
  2. Logika odpowiedzialna za poprawność hasła również była rozrzucona.
  3. Co więcej metoda register zawiera w sobie walidację adresu email oraz hasła, co łamie nam zasadę SRP. Podobnie jest w przypadku metody changeEmail.

Stwórzmy zatem nasz pierwszy value object dla adresu email:

class Email {
  private readonly email: string;

  constructor(email: string) {
    if (!this.isEmail(email)) {
      throw new Error('Invalid email')
    }

    this.email = email.toLowerCase();
  }

  public getValue(): string {
    return this.email;
  }

  public equal(email: Email): boolean {
    return this.email === email.email;
  }

  private isEmail(email: string): boolean {
    // regex check
  }
}

const firstEmail = new Email('test@test.pl'); // OK
const secondEmail = new Email('test') // ERROR

Teraz nasza logika znajduje się w jednym miejscu. Nasz adres email, nie jest już prostym typem string, ale zawiera w sobie reguły oraz zachowania. Jeśli uda nam się utworzyć instancje powyższej klasy to mamy 100% pewności, że adres email wewnątrz niej jest na pewno poprawny. Dzięki temu będziemy mogli go użyć gdziekolwiek w naszym systemie, bez konieczności sprawdzania czy email na pewno jest poprawny. Co więcej będziemy mieli pewność, że nikt już nie popełni błędu na przykład podczas porównywania dwóch adresów.

Użyjmy zatem naszego nowego value object w module autoryzacji:

class Email {
  private readonly email: string;

  constructor(email: string) {
    if (!this.isEmail(email)) {
      throw new Error('Invalid email')
    }

    this.email = email.toLowerCase();
  }

  public getValue(): string {
    return this.email;
  }

  public equal(email: Email): boolean {
    return this.email === email.email;
  }

  private isEmail(email: string): boolean {
    // regex check
  }
}

interface User {
  id: string;
  email: Email;
  password: string;
}

declare function getUser(email: Email): User | undefined;
declare function createUser(email: Email, password: string): User;

class Authorization {
  public register(email: Email, password: string): User  {
    const user = getUser(email);

    if (user) {
      throw new Error('User already exists')
    }

    if (password.length < 4) {
      throw new Error('Password is invalid')
    }

    return createUser(email, password);
  }

  public changeEmail(currentUser: User, newEmail: Email): void {
    const user = getUser(newEmail);

    if (user && user.id !== currentUser.id) {
      throw new Error('User with this email already exists')
    }

    user.email = newEmail;
  }
}

const authorization = new Authorization();
const user = authorization.register(new Email('test@test.pl'), 'test'); // OK
const user2 = authorization.register(new Email('test2@test.pl'), 'test'); // OK
authorization.changeEmail(user, new Email('drycode@test.pl')); // OK
authorization.changeEmail(user, new Email('test')); // ERROR Invalid email
authorization.changeEmail(user, new Email('TEST2@test.pl')); // ERROR User with this email already exists

Jak widzimy, nasz kod znacząco się uprościł. Z metody register pozbyliśmy się logiki sprawdzającej poprawność maila. Dodatkowo metoda changeEmail nie zawiera już błędów związanych z pominięciem wczesniej wspomnianej logiki. Pójdźmy zatem o krok dalej i zaimplementujmy value object dla hasła:

class Email {
  private readonly email: string;

  constructor(email: string) {
    if (!this.isEmail(email)) {
      throw new Error('Invalid email')
    }

    this.email = email.toLowerCase();
  }

  public getValue(): string {
    return this.email;
  }

  public equal(email: Email): boolean {
    return this.email.toLowerCase() === email.email.toLowerCase();
  }

  private isEmail(email: string): boolean {
    // regex check
  }
}

class Password {
  private readonly password: string;

  constructor(password: string) {
    if (password.length < 4) {
      throw new Error('Password is invalid')
    }

    this.password = password;
  }

  public getValue(): string {
    return this.password;
  }

  public equal(password: Password): boolean {
    return this.password === password.password;
  }
}

interface User {
  id: string;
  email: Email;
  password: Password;
}

declare function getUser(email: Email): User | undefined;
declare function createUser(email: Email, password: Password): User;

class Authorization {
  public register(email: Email, password: Password): User  {
    const user = getUser(email);

    if (user) {
      throw new Error('User already exists')
    }

    return createUser(email, password);
  }

  public changeEmail(currentUser: User, newEmail: Email): void {
    const user = getUser(newEmail);

    if (user && user.id !== currentUser.id) {
      throw new Error('User with this email already exists')
    }

    user.email = newEmail;
  }
}

const authorization = new Authorization();
const user = authorization.register(new Email('test@test.pl'), new Password('test')); // OK
const user2 = authorization.register(new Email('test2@test.pl'), new Password('test')); // OK
const user3 = authorization.register(new Email('test3@test.pl'), new Password('123')); // ERROR  Password is invalid
authorization.changeEmail(user, new Email('drycode@test.pl')); // OK
authorization.changeEmail(user, new Email('test')); // ERROR Invalid email
authorization.changeEmail(user, new Email('TEST2@test.pl')); // ERROR User with this email already exists

Podobnie jak w przypadku maila, tym razem również nasze hasło zyskało reguły i zachowania. Nie jest już zwykłym typem string. Dzięki temu nasza metoda register stała się czysta jak łza, a nasza logika odpowiedzialna za sprawdzanie poprawności hasła została z enkapsulowana w jednym miejscu i to nie w utils 🙂

W przypadku dodania nowej funkcjonalności np. do zmiany hasła znacząco zmniejszamy ryzyko popełnienia błędu. Nawet jeśli ktoś zapomni o użyciu klasy Password w parametrach metody to nasz kochany TypeScript huknie błędem, ponieważ interfejs User jej wymaga. Mamy również pewność, że żadna reguła nie zostanie nigdzie pominięta oraz, że ewentualna zmiana reguł będzie wymagać edycji tylko jednego miejsca.

Przykład drugi (Logowanie za pomocą facebook’a)

Przejdźmy zatem do drugiego przykładu, a mianowicie logowania za pomocą facebook’a:

interface User {
  id: string;
  externalId: string;
}

declare function getUserById(id: string): User | undefined; // SELECT id, "externalId" FROM users WHERE user.id === {id}
declare function decodeToken(token: string): { id: string };
declare function addToActiveUsersList(id: string): void;

function loginViaFacebook(token: string): User {
  const { id } = decodeToken(token);
  const user = getUserById(id);

  if (!user) {
    throw new Error('User does not exist')
  }

  addToActiveUsersList(id);

  return user;
}

Żeby nie przedłużać stwórzmy value object dla id oraz externalId :

declare function uuid(): string;

class UserId {
  public readonly id: string;

  constructor() {
    this.id = uuid();
  }

  public equal(userId: UserId): boolean {
    return this.id === userId.id;
  }
}

class ExternalId {
  public readonly id: string;

  constructor() {
    this.id = uuid();
  }

  public equal(externalId: ExternalId): boolean {
    return this.id === externalId.id;
  }
}

interface User {
  id: UserId;
  externalId: ExternalId;
}

declare function getUserById(id: UserId): User | undefined; // SELECT id, "externalId" FROM users WHERE user.id === {id}
declare function decodeToken(token: string): { id: ExternalId };
declare function addToActiveUsersList(id: UserId): void;

function loginViaFacebook(token: string): User {
  const { id } = decodeToken(token);
  const user = getUserById(id);

  if (!user) {
    throw new Error('User does not exist')
  }

  addToActiveUsersList(id);

  return user;
}

I voilà, od teraz już na pierwszy rzut oka widzimy jakie parametry przyjmują nasze funkcje. Co więcej TypeScript nie pozwoli nam przekazać złego id. Ale czy na pewno?

Stety niestety ale TypeScript w przeciwieństwie do na przykład javy jest językiem typowanym strukturalnie. Więc jeśli posiadamy dwie klasy o „takiej samej” strukturze to ich typy są ze sobą kompatybilne:

class UserId {
  public readonly id: string;
}

class ExternalId {
  public readonly id: string;
}

const id: UserId = new ExternalId(); // OK
const externalId: ExternalId = new UserId(); // OK

Musimy zatem nieco z hakować nasz język w celu zapewnienia unikalności typów. Można to zrobić na kilka sposobów. Przed kontynuacją dalszego czytania zachęcam do zapoznania się z tym artykułem https://betterprogramming.pub/nominal-typescript-eee36e9432d2

Ja posłuże się najprostszym z nich i po prostu „o branduje” nasze klasy:

declare function uuid(): string;

class UserId {
  public brand: 'userId' as const;
  public readonly id: string;

  constructor() {
    this.id = uuid();
  }

  public equal(userId: UserId): boolean {
    return this.id === userId.id;
  }
}

class ExternalId {
  public brand: 'externalId' as const;
  public readonly id: string;

  constructor() {
    this.id = uuid();
  }

  public equal(externalId: ExternalId): boolean {
    return this.id === externalId.id;
  }
}

interface User {
  id: UserId;
  externalId: ExternalId;
}

declare function getUserById(id: UserId): User | undefined; // SELECT id, "externalId" FROM users WHERE user.id === {id}
declare function getUserByExternalId(id: ExternalId): User | undefined; // SELECT id, "externalId" FROM users WHERE user.externalId === {id}
declare function decodeToken(token: string): { id: ExternalId };
declare function addToActiveUsersList(id: UserId): void;

function loginViaFacebook(token: string): User {
  const { id } = decodeToken(token);
  const user = getUserByExternalId(id);

  if (!user) {
    throw new Error('User does not exist')
  }

  addToActiveUsersList(user.id);

  return user;
}


Dzięki temu zabiegowi mamy pewność, że nikt już nie będzie się zastanawiał jakie id przekazać do danej funkcji. Oszczędzimy dzięki temu czas, który poświęcilibyśmy na debugowanie oraz analizę implementacji funkcji, które nie są znaczące w kontekscie obecnie tworzonej funkcjonalności. Jeśli przekażemy złe id to TypeScript od razu rzuci błędem.

Przykład trzeci (Operacje na pieniądzach)

Przejdźmy zatem do ostatniego przykładu.

function addMoney(firstValue: number, secondValue: number, firstValueCurrency: string, secondValueCurrency: string): number {
  if (firstValueCurrency !== secondValueCurrency) {
    throw new Error('Invalid currency');
  }

  return firstValue + secondValue;
}

function subtractMoney(firstValue: number, secondValue: number, firstValueCurrency: string, secondValueCurrency: string): number {
  return firstValue - secondValue;
}

addMoney(1, 1, 'EUR', 'EUR'); // OK
addMoney(1, 1, 'DRYCODE', 'DRYCODE'); // OK ?!?!
subtractMoney(1, 2, 'EUR', 'PLN') // OK ?!?!

Aby nie przedłużać zaimplementujmy value object dla naszych pieniędzy:

class Money {
  private readonly value: number;
  private readonly currency: string;
  
  constructor(value: number, currency: string) {
    if (value <= 0) {
      throw new Error('Invalid value');
    }
    
    if (!this.checkIsCurrencyValid(currency)) {
      throw new Error('Invalid currency');
    }
    
    this.currency = currency;
    this.value = value;
  }
  
  public subtract(money: Money): Money {
    if (!this.checkIsTheSameCurrency(money)) {
      throw new Error('Invalid currency');
    }

    const newValue = this.value - money.value;
    
    return new Money(newValue, this.currency);
  }

  public add(money: Money): Money {
    if (!this.checkIsTheSameCurrency(money)) {
      throw new Error('Invalid currency');
    }
    
    const newValue = this.value + money.value;

    return new Money(newValue, this.currency);
  }
  
  private checkIsTheSameCurrency(money: Money): boolean {
    return this.currency === money.currency;
  }
  
  private checkIsCurrencyValid(currency: string): boolean {
    //..
  }
}

const firstMoney = new Money(2, 'EUR');
const secondMoney = new Money(4, 'EUR');
const thirdMoney = new Money(2, 'PLN');

firstMoney.add(secondMoney) // OK => 6
firstMoney.subtract(secondMoney) // ERROR => -2 Invalid value
firstMoney.add(thirdMoney) // ERROR => Invalid currency

Nasz value object enkapsuluje reguły oraz zachowania dotyczące pieniędzy. Ten przykład może Wam się wydać bardzo podobny do poprzednich, ale chciałem tutaj zwrócić uwagę na jedną bardzo ważną rzecz. Wcześniej wspomniałem, że value object musi być niemutowalny. Popatrzcie zatem na metody add oraz subtract. Nie modyfikujemy w nich obecnej wartości, lecz tworzymy nową instancję z nową wartością. Dzięki temu zapewniamy niemutowalnosć.

Asynchroniczność

Jest jeszcze jedna kwestia, którą chciałbym poruszyć, a mianowicie zapytania asynchroniczne. Czasami przy tworzeniu value object’u potrzebujemy wysłać nasze dane do zewnętrznego serwisu, w celu ich sprawdzenia. Na przykład możemy sobie wyobrazić, że nasza metoda sprawdzająca poprawność waluty checkIsCurrencyValid strzela do API, które zwraca nam informację na temat tego czy dana waluta jest poprawna czy nie.

Tak jak wspomniałem wcześniej nasz value object zawiera logikę domenową, a więc nie powinien zależeć od warstwy infrastruktury. Innymi słowy nie możemy bezpośrednio z naszego value object’u strzelić do API, ponieważ jeśli interfejs API się zmieni, zmienimy API na inne lub w ogóle z niego zrezygnujemy to będziemy zmuszeni do edycji naszego value object’u. Takie zmiany nie powinny być istotne z punktu widzenia logiki domenowej. Istnieje również jeszcze jeden problem, a mianowicie taki, że constructor nie może być asynchroniczny, a więc nie możemy w nim wywołać naszej metody. Nie możemy również sprawdzić poprawności waluty po za naszym value object, ponieważ jeśli ktoś użyje value object’u w innym miejscu to może o tym zapomnieć i również będziemy mieli błąd. Co więcej wycieknie nam wtedy logika, która powinna znajdować się wewnątrz naszego value object’u.

A więc co możemy zrobić? Istnieje bardzo proste rozwiązanie. Po pierwsze możemy stworzyć metodę statyczną create, która wywoła naszą asynchroniczną metodę i bazując na jej wyniku podejmie decyzję na temat tego czy zwrócić value object czy też rzucić wyjątek. Po drugie zamiast implementować strzał do API w naszym value object, możemy „wstrzyknąć” funkcję sprawdzającą poprawność waluty w metodę create:

// WARSTWA LOGIKI DOMENOWEJ
// Interfejs definiujemy w warstwie logiki biznesowej, a więc to inne warstwy będą zależeć od nas
type CheckIsCurrencyValid = (currency: string) => Promise<boolean>;

class Money {
  private readonly value: number;
  private readonly currency: string;

  // Od teraz nasz constructor jest private, a więc nie jest dostępny z zewnątrz
  private constructor(value: number, currency: string) {
    if (value <= 0) {
      throw new Error('Invalid value');
    }

    this.currency = currency;
    this.value = value;
  }

  // Metoda tworząca value object
  public static async create(value: number, currency: string, checkIsCurrencyValid: CheckIsCurrencyValid): Money {
    const isCurrencyValid = await checkIsCurrencyValid(currency);

    if (!isCurrencyValid) {
      throw new Error('Invalid currency');
    }

    return new Money(value, currency);
  }

  // W naszych metodach nie musimy korzystać ze statycznej metody
  // ponieważ jako parametr przyjmują Money, a więc mamy pewność, że waluta jest poprawna
  public subtract(money: Money): Money {
    if (!this.checkIsTheSameCurrency(money)) {
      throw new Error('Invalid currency');
    }

    const newValue = this.value - money.value;

    return new Money(newValue, this.currency);
  }

  public add(money: Money): Money {
    if (!this.checkIsTheSameCurrency(money)) {
      throw new Error('Invalid currency');
    }

    const newValue = this.value + money.value;

    return new Money(newValue, this.currency);
  }

  private checkIsTheSameCurrency(money: Money): boolean {
    return this.currency === money.currency;
  }
}

// WARSTWA INFRASTRUKTURY
// Pierwsza implementacja
const checkIsCurrencyValid: CheckIsCurrencyValid = async (currency: string) => {
  return true;
}

// Druga implementacja
const checkIsCurrencyValid2: CheckIsCurrencyValid = async (currency: string) => {
  return true;
}

// WYWOŁANIE
const firstValue = await Money.create(10, 'USD', checkIsCurrencyValid);
const secondValue = await Money.create(10, 'USD', checkIsCurrencyValid2);

Jak widzimy jeśli zdecydujemy się na zmianę naszego API, to nasz value object pozostaje bez zmian. Co więcej taki value object możemy bardzo łatwo przetestować jednostkowo i to bez konieczności mockowania. Wystarczy, że w testach zamiast wstrzyknąć prawdziwą funkcję checkIsCurrencyValid wstrzykniemy naszą testową implementację:

const firstValue = await Money.create(10, 'USD', async (currency: string) => true);

Jeśli zauważymy, że zbudowanie naszego value object jest bardziej skomplikowane i wymaga podjęcia większej ilości akcji warto wtedy pomyśleć nad zastosowaniem wzorca fabryki zamiast statycznej metody create. Istnieje również duże prawdopodobieństwo, że łamiemy zasadę SRP i nasz value object posiada zbyt duża odpowiedzialność. Wtedy należy sie zastanowić, czy nie należy rozbić naszego value objectu na kilka mniejszych.

Podsumowanie

Jak pewnie zauważyliście value object niesie za sobą wiele zalet. Między innymi:

  • Enkapsuluje w jednym miejscu zachowania oraz reguły. Dzięki temu nasza logika biznesowa nie jest rozrzucona po całym systemie, ani nie siedzi w folderze utils, lecz przynależy do konkretnego bytu. Jeśli będziemy musieli wprowadzić jakąś zmianę to wystarczy, że zrobimy to tylko w jednym miejscu.
  • Używając go w kodzie mamy pewność, że zawiera poprawną wartość.
  • Funkcje, metody czy klasy używające value object są czystsze, ponieważ nie muszą zawierać w sobie reguł oraz zachowań danego bytu.
  • Zapewniamy niemutowalność.
  • Niektóre błędy jesteśmy w stanie wychwycić już na etapie kompilacji kodu.
  • Istnieje o wiele większa szansa, że nowy deweloper użyje, np. klasy Money, niż metody z folderu utils. Co więcej nawet jeśli nie zauważy tej klasy i spróbuje gdzieś użyć po prostu typu number to TypeScript przypomni mu o jej istnieniu. Oczywiście o ile będzie jej wymagał jakiś interfejs.
  • Value object’y testuje się w bardzo łatwy sposób.

Czy zatem zawsze warto tworzyć value object’y?

W większości przypadków tak, ale należy być pragmatycznym. Jeśli mamy jakiś byt, który nie posiada żadnych zachować, ani logiki biznesowej to nie ma sensu tworzyć dla niego value object’u, ponieważ nic na tym nie zyskamy. Stracimy jedynie czas na napisanie zbędnego kodu. Idealnym przykładem są tutaj aplikacje typu CRUD.

Value object jest to koncept pochodzący z Domain Driven Design, ale jest to tylko jeden z building block’ów i spokojnie możemy go używać w oderwaniu od reszty. Jeśli chciałbyś się bardziej wdrożyć w ten temat to zachęcam Cię do przeczytania książki Erica Evans pod tytułem Domain Driven design.

Żródła:
https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215
https://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577