Obiekt wyjściowy (Response Object)

Czy zastanawiałeś/aś się kiedyś co powinien zwracać kontroler? Czyli o tym co (i dlaczego) powinna zawierać zwrotka z klasy kontrolującej. Dlaczego bezpośrednie zwracanie encji jest złym pomysłem oraz jak tego uniknąć?


Response Object

Obiekt wyjściowy, popularnie zwany: Response czy ResponseObject, jest ostateczną formą wyniku działania pewnej logiki, która zwracana jest do klienta. W różnych technologiach możemy spotkać się także z określeniem ViewModel lub ResponseModel. Wiele osób zadaje sobie pytanie, jaki jest sens tworzenia tego typu - na pierwszy rzut oka - redundatnych klas. Odpowiedź jest bardzo prosta, aby mieć pełną kontrolę nad tym, co zostaje udostępnione poza system. 

Zwracanie encji

Mamy tutaj przykład encji, która zawiera pewne pola. Klasa serwisowa odpowiedzialna za tworzenie tego typu rekordów, jako wynik, zwraca nowo utworzone obiekty. Następnie kontroler oddaje do klienta dane otrzymane bezpośrednio z serwisu, ponieważ frontend w celu uniknięcia kolejnego zapytania chce od razu wyświetlić nowo dodany rekord na pewnym listingu. Pomijając złamanie zasad SOLID, może wydawać się, że nie ma tutaj żadnego problemu. Problem jednak jest, bo wyobraźmy sobie teraz sytuację, że do wyżej pokazanej encji dołożymy jakieś bardzo wrażliwe dane, np. numer PESEL, a na naszym wspomnianym listingu nie musimy, a nawet nie możemy wyświetlić tej informacji. My jednak zwracamy dane, których raz, że nie powinniśmy zwracać, a dwa, że nie są one nikomu potrzebne. Ktoś może użyć narzędzi deweloperskich swojej przeglądarki i przejrzeć ruch sieciowy, tym samym zobaczy numer PESEL, którego tak naprawdę tam być nie powinno. Inna sytuacja to otwarte API, gdzie zewnętrzny klient może korzystać z usług naszego systemu, ale nie powinien widzieć pewnych danych. Kolejny przykład, jeżeli frontend w jakiś sposób automatycznie wyświetla wszystkie pola, które przyjdą z backendu - wyświetli wtedy numer PESEL, którego założenia biznesowe nie obejmowały. Może Ci się wydawać, że takie sytuacje to skrajność, ale uwierz mi na słowo, że tak nie jest. Zawsze, jeśli konieczne jest zwrócenie czegoś na zewnątrz, to powinniśmy określić postać "tego czegoś". 

@Entity('customers')
export class CustomerEntity extends BaseEntity {
  firstName: string;
  lastName: string;
  phoneNumber: string;
}

@Controller('customers')
export class CustomersController {
  constructor(private readonly customersService: CustomersService) {}

  @Post()
  create(@Body() request: CreateCustomerRequest): Promise<CustomerEntity> {
    return this.customersService.create(request);
  }
}

Co powinien zawierać response z Controllera?

Wiemy już więc, że obiekty wyjściowe są nam potrzebne, jednak teraz powiedzmy sobie jak je tworzyć i co najważniejsze - gdzie? Spójrzmy więc na ten przykład:

export class CustomerResponse {
  firstName: string;
  lastName: string;
  phoneNumber: string;
}

@Controller('customers')
export class CustomersController {
  constructor(private readonly customersService: CustomersService) {}

  @Post()
  async create(@Body() request: CreateCustomerRequest): Promise<CustomerResponse> {
    const customer = await this.customersService.create(request);
    
    const response = CustomerResponseMapper.mapEntity(customer);
    
    return response;
  }
}
Mamy tutaj jakąś ogólną klasę obiektu wyjściowego, dla wyniku działania logiki tworzącej nowego klienta. Klasa serwisowa zwraca nowo utworzony obiekt, ale w kontrolerze ma miejsce wywołanie mappera, który konwertuje obiekt customer do postaci obiektu wyjściowego, a następnie zwraca jego wynik.

Nasuwa się pytanie, dlaczego nie zrobić tego mapowania w klasie serwisowej i od razu zwrócić gotowy wynik. Nie jest to dobre podejście. Klasy serwisowe z założenia powinny być osobnymi bytami, które nie mogą znać swojego kontekstu wywołania - powiem o tym więcej podczas omawiania zagadnień z warstwy logiki. Niemniej jednak, wyobraźmy sobie sytuację, że ta klasa serwisowa używana jest także w innym miejscu, bo potrzebujemy stworzyć nowego klienta np. ad-hoc, podczas pewnego procesu, lecz format wyjścia tego procesu jest inny od wyjścia zaprezentowanego powyżej, wtedy ma miejsce ifologia lub duplikowanie kodu. 

Podsumowując




Polecane wpisy:

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

Command-Query Separation (CQS)

Command-Query Separation, czyli zasada o rozdzielaniu zadań metod tak, aby były jedynie komendami lub zapytaniami. Jeżeli Twoja metoda realizuje logikę i zwraca wyniki, lub podczas pobierania danych wykonuje side taski, to łamie ona CQS.

Sprawdź ten wpis

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.

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.