Klasa kontrolująca (Controller)

O tym jak działa kontroler, czym powinien się zajmować i dlaczego nie powinien realizować zbyt wiele. W dzisiejszym wpisie powiem o tym dlaczego nie warto budować wielkich kontrolerów będących boskimi klasami.


Klasa kontrolująca

Zwykle mówi się, że zapytania użytkowników zawierające błędne dane rozbijają się o kontrolery. Jest w tym ziarno prawdy, jednak poprawniejszym określeniem będzie, że rozbijają się one o walidację wywoływaną przez klasę kontrolera. Po tych słowach wstępu może nam się w głowach kształtować pewien obraz, czym powinna być klasa kontrolująca

Przykład Controllera

W celu uniknięcia suchych regułek, posłużmy się przykładem z życia wziętym. Wyobraźmy sobie kierownika kadr działu księgowości w pewnej firmie. Owy kierownik ma za zadanie przydzielać zleconą pracę dla podlegających mu pracowników. Z wykonanych zadań jest następnie rozliczany na spotkaniach zarządu, dlatego musi być pewien, że rezultaty przedstawi w sposób czytelny dla swoich przełożonych.  Musi on także być pewien, że jest w stanie wraz ze swoimi podwładnymi zrealizować powierzone zadanie, np. upewnić się, że wraz ze zleceniem rozliczenia rocznego załączone zostały wszystkie wymagane dokumenty. Jest to kierownik, więc pracę sprawdzenia zleci odpowiedniemu pracownikowi, a sam zajmie się tym, co robi najlepiej - kontrolowaniem przydziału zadań na tym szczeblu.

Zobrazujmy sobie ten przykład poprzez jego implementację.

@Controller('incomes')
export class IncomesController {
  constructor(private readonly incomesService: IncomesService) {}

  @Post('calculate/year')
  async calculateYearIncome(@Body() request: CalculateYearIncomeRequest): Promise<YearIncomeResponse> {
    const yearIncome = await this.incomesService.calculateYearIncome(request as CalculateYearIncomeDTO);
    
    const response = YearIncomeResponseMapper.mapIncomeEntity(yearIncome);
    
    return response;
  }
}
Mamy tutaj przyjęcie i automatyczną walidację danych, wywołanie pewnej logiki biznesowej i użycie jej wyniku do sformatowania obiektu wyjściowego (ang. response object). Oczywistym jest także fakt, iż postać tej metody będzie się różnić w zależności od użytej technologii czy frameworka. Niejednokrotnie może okazać się, iż konieczne jest ręczne wywołanie pewnej metody walidującej, czy też sam obiekt wyjściowy może tworzyć się automatycznie. 

Controller bez magii frameworka

Spójrzmy więc na inny przykład kontrolera, gdzie framework nie pozwala na aż tyle magii. Idea jest ta sama, trzeba tylko pamiętać, że zadaniem klasy kontrolera jest kontrolowanie i nie ma znaczenia to, jaka będzie jego finalna forma. Delegowanie zadań, uruchomienie walidacji i sformatowanie wyjścia, to wszystko. Kontroler nie powinien być przepełniony logiką, co jest częstym błędem niedoświadczonych programistów, którzy upychają tutaj zadania nie nadające się dla kontrolerów.

@Controller('incomes')
export class IncomesController {
  constructor(private readonly incomesService: IncomesService) {}

  @Post('calculate/year')
  async calculateYearIncome(@Body() form: CalculateYearIncomeForm): Promise<YearIncomeResponse> {
    form.validate();
    
    const dto = CalculateYearIncomeDTOMapper.map(form.getData());
    
    const yearIncome = await this.incomesService.calculateYearIncome(dto);
    
    const response = YearIncomeResponseMapper.mapIncomeEntity(yearIncome);
    
    return response;
  }
}

Anty przykład - zły controller

Pomijając fakt złamania kilku innych zasad, to jest przykład, jak kontroler NIE POWINIEN wyglądać. Może kod jest w jakimś stopniu czytelny, ale w tym przypadku implementacja aż prosi się o wdrożenie warstwy serwisowej i przeniesienie tam logiki. O tym, jak wdrożyć warstwę serwisową porozmawiamy przy innej okazji. Dodatkowo brakuje tutaj mapowania do postaci obiektu wyjściowego i zwracana jest bezpośrednio encja / model, co nie jest dobrym pomysłem z uwagi na brak kontroli formy wyjściowej, o czym już wspominałem w ostatnim wpisie.

@Controller('incomes')
export class IncomesController {
  constructor(
    private readonly incomeRepository: IncomeRepository,
    private readonly invoiceRepository: InvoiceRepository
  ) {}

  @Post('calculate/year')
  async calculateYearIncome(@Body() request: YearIncomeRequest): Promise<Income> {
    const { year, companyId } = request;
    
    const invoices = await this.invoiceRepository.findByYearAndType( 
      year, 
      invoiceType: InvoiceTypeEnum.SELL,
      companyId
    );
    
    const incomeValue = invoices.reduce((income: number, invoice: Invoice) => 
      income += invoice.netValue, 0);
      
    let income = await this.incomeRepository.findYearIncome(year, companyId);
      
    if (income) {
       income.value = incomeValue;
       
       return this.incomeRepository.save(income);
    }  
      
    income = IncomeFactory.createYearIncome({ year, companyId });
    
    return this.incomeRepository.save(income);
  }
}

Podsumowując




Polecane wpisy:

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

Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

Sprawdź ten wpis

Wzorzec adapter

Strukturalny wzorzec projektowy adapter to spore udogodnienie w walce z systemami legacy. Pozwala użyć niepasującego interfejsu w innym. Dzięki temu zabiegowi pomimo, iż nie posiadamy spójnej abstrakcji możemy zastosować pożądaną logikę.

Sprawdź ten wpis

Wzorzec dekorator

Strukturalny wzorzec projektowy dekorator, to lekarstwo na problemy z mnogością różnych podtypów. Pozwala on na dynamiczne generowanie kolejnych typów obiektów bazujących na pierwotnym typie, bez konieczności deklarowania nowych, dedykowanych klas.

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.