Prawo Demeter (The Law of Demeter)

Omawiam dzisiaj mało znaną zasadę, której stosowanie skutkuje posiadaniem łatwo utrzymywalnego i testowalnego kodu. Mowa o prawie demeter, które w najprostszym ujęciu zakłada, że obiekty powinny operować jedynie na najbliższym im otoczeniu.


The Law of Demeter

Prawo Demeter jest zasadą mówiącą, że obiekt powinien komunikować się jedynie ze swoim najbliższym otoczeniem. The Law of Demeter, zwane także zasadą minimalnej wiedzy lub regułą ograniczonej interakcji, może zostać po prostu przełożone na: komunikuj się jedynie ze swoimi przyjaciółmi lub nie rozmawiaj z nieznajomymiWyróżnić możemy kilka reguł tego prawa, których musimy przestrzegać, jeżeli chcemy mieć sztamę z Demeter. Poniższe reguły jasno mówią nam także czym jest to tzw. "najbliższe otoczenie" obiektu.
  1. Klasa może odwoływać się do swoich publicznych i prywatnych metod.
  2. Klasa może odwoływać się do metod i właściwości klas przekazanych bezpośrednio do niej (za pomocą konstruktora lub argumentu funkcji).
  3. Klasa może odwoływać się do elementów obiektów utworzonych wewnątrz niej.
  4. Klasa może używać elementów dostępnych globalnie.
Przyznam się szczerze, że prawo to zacząłem używać w swoim kodzie stosunkowo niedawno. Samą koncepcję znałem już wcześniej, jednak nie byłem do niej do końca przekonany, ponieważ skutkiem ubocznym stosowania tego prawa jest przerośnięty interfejs z wieloma małymi metodami, które służą jedynie do delegowania zadań. Jednak z biegiem lat zacząłem doceniać kod spełniający prawo Demeter. Taki kod jest nieziemsko prosty, klasy mają mniej zależności, a co za tymi idzie są mniejsze i łatwo testowalne.

Ten kod łamie prawo Demeter

Dzisiaj zacznę trochę na opak i najpierw na tapet weźmiemy "ten zły" kod. W poniższym przykładzie widzimy klasę serwisową z metodą createContract(), której zadaniem jest zawarcie pewnej umowy. Przed zapisem musimy sprawdzić czy dostarczony w DTO klient nie jest np. wpisany na czarną listę w KRS. Sprawdzenie to odbywa się na zasadzie zapytania do zewnętrznego API. Jeżeli klient nie jest "czysty", to nie pozwalamy na dopełnienie się umowy i logujemy stosowny błąd. Z kolei w przypadku powodzenia, zapisujemy kontrakt do bazy i dajemy zielone światło do fizycznego podpisania dokumentu.

export class CreateContractDto {
  constructor(
    public id: ContractId,
    public company: Company,
    public contractor: Contractor,
    public details: ContractDetails
  ) {}
}

export class ContractsService {
  constructor(
    private readonly contractRepository: ContractRepository,
    private readonly krsService: KrsServiceInterface,
    private readonly logsService: LogsService
  ) {}

  async createContract(dto: CreateContractDto): Promise<void> {
    const contractorTaxId = dto.contractor
      .getTaxId();

    const krsCheckResult = await this.krsService.checkContractor(contractorTaxId);

    if (!krsCheckResult.isOk()) {
      this.logsService
        .getLogger()
        .log(krsCheckResult.getKrsResponse().getMessage());

      throw new Error('Cannot create contract due to incorrect KRS response.');
    }

    const contract = Contract.create(dto);

    await this.contractRepository.save(contract);
  }
}
Oczywiście pomińmy w tym przykładzie takie aspekty, jak ogólnie złe rozplanowanie tej logiki, ponieważ idealnie to najpierw powinniśmy stworzyć obiekt contract, zapisać go, a później, asynchroniczne zawołać zewnętrzne API i odpowiednio obsłużyć zwrotkę w tle. Skupmy się jednak na samym prawie Demeter. Zgodnie z punktami, które przedstawiłem we wstępie, nasze piękne prawo jest tutaj łamane.

Na samym początku metody rozmawiamy z przyjacielem naszego przyjaciela. Przekazane do metody DTO znajduje się w najbliższym otoczeniu klasy serwisowej, więc zgodnie z prawem Demeter możemy wyciągnąć z niego właściwość contractor.  Natomiast wywołania getTaxId() na tym obiekcie zrobić nie możemy, ponieważ contractor nie jest najbliższym przyjacielem klasy ContractsService.

Identyczną sytuację mamy kilka linijek poniżej. Obsługujemy tam logowanie zdarzenia, a także pobranie powodu odrzucenia kontraktora przez KRS. Widoczne tutaj chain responsibility jest niezgodne z założeniami prawa Demeter.

Refaktoryzacja

Spróbujmy naprawić wcześniejszy przykład tak, aby zaczął spełniać prawo Demeter. Zrobić to możemy na naprawdę wiele sposobów, więc jeśli widzisz inne opcje na poprawienie tego kodu to koniecznie podziel się nimi w komentarzu.

Najpierw zajmijmy się samym DTO. Dołóżmy w nim metodę zwracającą taxId, które zostanie pobrane z właściwości contractor. Pozwoli nam to na uniknięcie dodatkowego wywołania w klasie serwisowej. Rozmawiamy tutaj z najbliższym przyjacielem w postaci właściwości contractor, więc spełniamy prawo Demeter.

export class CreateContractDto {
  constructor(
    public id: ContractId,
    public company: Company,
    public contractor: Contractor,
    public details: ContractDetails
  ) {}

  getContractorTaxId(): TaxId {
    return this.contractor.getTaxId();
  }
}
Kierując się tym zabiegiem zrefaktoryzujmy LogsService oraz krsResponse tak, aby udostępniały metody zwracające to, czego faktycznie potrzebujemy w naszym serwisie, zamiast udostępniać łańcuch zależności, po którym klasa kliencka musi przejść.

export class ContractsService {
  constructor(
    private readonly contractRepository: ContractRepository,
    private readonly krsService: KrsServiceInterface,
    private readonly logsService: LogsService
  ) {}

  async createContract(dto: CreateContractDto): Promise<void> {
    const krsCheckResult = await this.krsService.checkContractor(dto.getContractorTaxId());

    if (!krsCheckResult.isOk()) {
      this.logsService.log(krsCheckResult.getResponseMessage());

      throw new Error('Cannot create contract due to incorrect KRS response.');
    }

    const contract = Contract.create(dto);

    await this.contractRepository.save(contract);
  }
}
Nasz kod stał się krótszy, bardziej przejrzysty i łatwiej testowalny. Chcąc przetestować naszą klasę nie musimy teraz mockować tych wszystkich metod, które zawierały się w łańcuchu wywołań, a jedynie te konkretne, które dostarczają nam jakąś wartość w serwisie.

Doszły nam nowe metody: CreateContractDto.getContractorTaxId(), LogsService.log(), KrsCheckResult.getResponseMessage(), ktrych jedynym zadaniem jest dalsza delegacja polecenia. Są to na pozór metody redundantne, jednak ich utworzenie pozwoliło nam na posiadanie czytelnego, łatwo testowalnego kodu.

Fluent Interface vs. The Law of Demeter

Możemy spotkać się z podejściem tzw. "płynnego interfejsu", które na pierwszy rzut może całkowicie przeczyć prawu Demeter. Nikt nie powiedział jednak, że prawo Demeter sprowadza się do tego, aby używać tylko jednej kropki. Wcale tak nie jest. Z prawa Demeter wiemy, że możemy rozmawiać tylko z naszym najbliższym otoczeniem, ale jeśli do naszego otoczenia wchodzi np. kolekcja danych, na której możemy wykonać wiele operacji bezpośrednio na niej, to każda z tych operacji spełni nasze prawo. To samo tyczyć się będzie generatora zapytań w ORM czy operacji na dowolnym obiekcie, który w swoich metodach ciągle zwraca tę samą referencję (pomińmy fakt poprawności takiego rozwiązania) lub kiedy ciągle działamy na tym konkretnym, pierwotym typie danych.

const threeOldestKowalski = collection
 .filter(user => user.surname.toUpperCase() === 'KOWALSKI')
 .sort(userAgeSorter)
 .slice(0, 2);

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

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ć.

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

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.