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.
Dodaj komentarz