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 nieznajomymi. Wyróż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.
- Klasa może odwoływać się do swoich publicznych i prywatnych metod.
- 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).
- Klasa może odwoływać się do elementów obiektów utworzonych wewnątrz niej.
- 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
- Stosowanie prawa Demeter pozwala na posiadanie czytelnego i łatwo testowalnego kodu.
- Na pozór redundatne metody powstałe w zależnościach kompensowane są prostym kodem.
- Fluent interface nie przeczy prawu Demeter, dopóki operacje prowadzone są w obrębie pierwotnego interfejsu.
Dodaj komentarz