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(), których 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, pierwotnym typie danych.

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

Podsumowanie




Polecane wpisy:

KISS: keep it simple, stupid!

Zasada KISS: "Keep it simple, stupid", może zostać dosłownie przetłumaczona na: "rób to prosto, głupku". Mówi ona o tym, abyśmy tworzyli kod w jak najprostszy i najbardziej czytelny sposób. Już dziś sprawdź, czego się wystrzegać, aby spełniać KISS.

Sprawdź ten wpis

Wzorce projektowe

Wzorce projektowe zostały stworzone po to, aby nie wymyślać przysłowiowego koła na nowo. Znajomość wzorców projektowych i umiejętność ich stosowania pozwala na szybkie rozwiązywanie problemów. Wpis ten radzi, jakie wzorce zastosować u siebie.

Sprawdź ten wpis

Wzorzec singleton

Kolejny kreacyjny wzorzec projektowy omawiany na łamach tego bloga: singleton. Wzorzec dookoła którego narosło wiele mitów i legend. Dziś o tym dlaczego singleton jest antywzorcem, jakie problemy powoduje oraz kiedy warto po niego sięgnąć.

Sprawdź ten wpis

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

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.