Usługa - Service Class

Skupimy się dzisiaj na klasach typu service, które najczęściej stosowane są do obsługi logiki biznesowej w różnych wzorcach architektonicznych. Co należy do kompetencji serwisu? Jak poprawnie wydzielić zadania takiej klasy? Sprawdź ten wpis.


Logika biznesowa

W większości wzorców architektonicznych w ten czy inny sposób stosujemy podejście warstwowe. Dzielimy cykl życia zapytania na kilka kroków - warstw przez które musi ono przejść. Wspominałem już o warstwie UI czyli początkowym miejscu, w którym rozbija się zapytanie do naszego systemu. Warstwa UI po zwalidowaniu danych i ich przeparsowaniu deleguje zazwyczaj kolejne zadania do niższych warstw. Ilość tych poziomów jest różna, w zależności od zastosowanego wzorca architektonicznego, ale w większości przypadków mamy miejsce w którym realizowana jest logika biznesowa. Takim miejscem mogą być klasy typu usługa: service.

Problem z Service Classes

Na początek, czym jest klasa serwisowa? Odpowiedź jest bardzo prosta: to zależy. Dla jednych jest to pośrednik do warstwy dostępu do danych, dla innych odzwierciedlenie pewnej grupy problemów za pomocą metod. Ci pierwsi bardzo często dedykują rozwiązania serwisów per encja, per repozytorium. Jest dla nich czymś oczywistym, iż dostęp do warstwy danych musi zostać otoczony klasą serwisową. Takie rozwiązanie może wyglądać następująco:

export class ReservationsService {
  constructor(
    private readonly reservationRepository: ReservationRepository,
  ) {}

  delete = (reservation: ReservationEntity): Promise<ReservationEntity> =>
    this.reservationRepository.remove(reservation);

  getOneById = (id: string): Promise<ReservationEntity> =>
    this.reservationRepository.findOne(id);

  getAll = (): Promise<ReservationEntity[]> =>
    this.reservationRepository.get();

  create(createReservationDTO: CreateReservationDTO): Promise<ReservationEntity> {
    const reservation = ReservationEntity.create(createReservationDTO);

    return this.reservationRepository.save(reservation);
  }

  update(reservation: ReservationEntity, updateReservationDTO: UpdateReservationDTO): Promise<ReservationEntity> {
    const updated = { ...reservation, ...updateReservationDTO };

    return this.reservationRepository.save(updated);
  }
}
Pomijając brak spełnienia reguły CQS, mamy tutaj pośrednika, który w ciałach metod realizuje jeszcze jakieś dodatkowe, proste działania, oprócz używania repozytorium. Problem z takimi serwisami pojawia się, gdy logika, którą trzeba zaimplementować jest troszkę bardziej skomplikowana. Powiedzmy, że przed utworzeniem rekordu rezerwacji, chcemy sprawdzić czy wybrane miejsce jest wciąż dostępne, a w przypadku udanego procesu poinformować klienta o tym fakcie.

export class ReservationsService {
  constructor(
    private readonly reservationRepository: ReservationRepository,
    private readonly resourceReservationsService: ResourceReservationsService,
    private readonly notificationsService: NotificationsService,
    private readonly messagesService: MessagesService,
  ) {}

  async create(createReservationDTO: CreateReservationDTO): Promise<void> {
    const resourceReservations = await this.resourceReservationsService.getReservations(
      createReservationDTO.resourceId,
      { from: createReservationDTO.from, to: createReservationDTO.to },
    );

    if (resourceReservations.length > 0) {
      throw ReservationException.resourceNotAvailable();
    }

    const reservation = ReservationEntity.create(createReservationDTO);

    await this.reservationRepository.save(reservation);

    const notification = new ReservationMadeNotification(reservation);

    await this.notificationsService.notify(createReservationDTO.userId, notification);
  }
}
Widoczny tutaj problem jest bardzo powszechny - potrzeba dodać nowy fragment logiki do istniejącego kodu? Pyk, nowa zależność w konstruktorze i zwiększanie objętości oraz złożoności usługi. Takie serwisy łamią zasady SOLID - głównie, mam na myśli tutaj podatność na modyfikacje w przypadku chęci dodania czegoś do procesu, a także brak pojedynczej odpowiedzialności.

Ładna usługa

Przedstawiony powyżej kod możemy poprawić na bardzo wiele sposobów i nie ma złotego środka na to, jak to zrobić. Możemy użyć wzorca fasady (powiem o nim w przyszłości) i wywołać w niej operacje na usługach, a ją samą w kontrolerze. Możemy także przyjąć, że klasa serwisowa będzie odzwierciedlała konkretny problem biznesowy, a do jego rozwiązania użyje innych mechanizmów. 

Serwisy odpowiedzialne są za zadania, które zostały im zlecone. Fasada wywołuje usługi, a następnie - w przypadku udanej rezerwacji - wywołuje event, który zostanie obsłużony w tle, aby wysłać powiadomienie.

export class ResourceReservationsService {
  constructor(
    private readonly resourceReservationRepository: ResourceReservationRepository,
  ) {}

  async validateResourceAvailability(id: string, range: ResourceDatesRange): Promise<void> {
    const resourceReservations = await this.resourceReservationRepository.findByResourceIdAndDatesRange(
      id,
      range,
    );

    if (resourceReservations.length > 0) {
      throw ResourceException.resourceNotAvailable(id, range);
    }
  }
}

export class ReservationsService {
  constructor(
    private readonly reservationRepository: ReservationRepository,
  ) {}

  create(createReservationDTO: CreateReservationDTO): Promise<void> {
    const reservation = ReservationEntity.create(createReservationDTO);

    await this.reservationRepository.save(reservation);
  }
}

export class ReservationFacade {
  constructor(
    private readonly reservationsService: ReservationsService,
    private readonly resourceReservationsService: ResourceReservationsService,
    private readonly eventBus: EventBus
  ) {}

  async create(createReservationDTO: CreateReservationDTO): Promise<void> {
    await this.resourceReservationsService.validateResourceAvailability(createReservationDTO.resourceId, {
      from: createReservationDTO.from,
      to: createReservationDTO.to,
    });

    await this.reservationsService.create(createReservationDTO);

    await this.eventBus.publish(new ReservationMade(createReservationDTO.id)); 

} }
Jeżeli nie chcemy używać fasady, możemy pozostać przy service classes. Ta wersja zakłada utworzenie prywatnej metody walidującej, która wywoływana jest przed kreacją rezerwacji. 

export class ReservationsService {
  constructor(
    private readonly reservationRepository: ReservationRepository,
    private readonly resourceReservationRepository: ResourceReservationRepository,
    private readonly eventBus: EventBus,
  ) {}

  async create(createReservationDTO: CreateReservationDTO): Promise<void> {
    await this.validateResourceAvailability(createReservationDTO.resourceId, {
      from: createReservationDTO.from,
      to: createReservationDTO.to,
    });

    const reservation = ReservationEntity.create(createReservationDTO);

    await this.reservationRepository.save(reservation);

    await this.eventBus.publish(new ReservationMade(reservation));
  }

  private async validateResourceAvailability(id: string, range: ResourceDatesRange): Promise<void> {
    const resourceReservations = await this.resourceReservationRepository.findByResourceIdAndDatesRange(
      id,
      range,
    );

    if (resourceReservations.length > 0) {
      throw ResourceException.resourceNotAvailable(id, range);
    }
  }
}

Clue serwisów

Tworzenie małych usług zorientowanych na jeden, konkretny problem biznesowy, ułatwia proces testowania, a także utrzymanie takiego kodu w przyszłości. Pozostaje jeszcze jedna kwestia, używanie jednego serwisu w drugim. Powinniśmy się wystrzegać takiego kodu, jednak zależy to oczywiście od ortodoksyjności programisty. Argumenty za tym, aby nie łączyć ze sobą serwisów są proste: niższa złożoność i łatwe testy, a także transparentność procesów biznesowych.  Starajmy się postrzegać klasy serwisowe, jako odizolowane jednostki obliczeniowe do których wstrzykujemy jak najmniej zależności, a za ich pomocą realizujemy logicznie spójną część operacji biznesowej. 

Podsumowując

  • Usługa to klasa realizująca założenia logiki biznesowej.
  • Klasy serwisowe stosowane są w wielu wzorcach architektonicznych.
  • Powinniśmy tworzyć małe, atomowe serwisy, które skupione będą na jednym problemie, bądź grupie połączonych ze sobą problemów biznesowych.



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.