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:

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.