Konkurs 100 commitów dobiegł końca… To było niesamowite i wykańczające 100 dni. Czasami trafiał się dzień, w którym udało się z zapałem dokonać wielu zmian, a czasem trzeba było zrobić commit-wydmuszkę tylko po to, aby pozostać w grze. Ostatecznie będę miło wspominał to wyzwanie z nadzieją, że nawyk robienia codziennych commitów pozostanie razem ze mną na dłużej.

Muszę przyznać, że implementacja wybranego projektu mnie przerosła, a na bank perfekcjonizm, który chciałem osiągnąć… Nie zliczę ile razy zmieniałem implementację podstawowych funkcjonalności tylko z powodu, że “pewnie da się to zrobić lepiej”. Teraz wiem, że to nie ma sensu. Plus w prototypowaniu nie warto pisać tylu testów. Można napisać sobie kilka akceptacyjnych, ale nie ma co się zmóżdzać nad testami jednostkowymi. Gdybym jednak tego nie przeżył to nie wycignąłbym z tego takich wniosków.

Dobra, ale do rzeczy. W swojej aplikacji napotkałem na taki przypadek. Klienci mogą wyrazić chęć rezerwacji miejsca postojowego na następny dzień. Powierzchnia danego miejsca postojowego jest oczywiście ograniczona. Dodatkowo każdy z klientów ma limit żądań rezerwacji jaki może dokonać. Na przykład “zwykli” klienci mogą mieć tylko jedno żądanie. Mamy więc dwie ważne reguły, które ciężko byłoby zamknąć w jednej klasie reprezentującą jednostkę spójności. Mogą się one zmieniać niezależnie.

Z powodu restrykcyjnego bronienia powyższych reguł obydwa byty chciałbym blokować optymistycznie. Wydaje się więc, że przy takim przypadku biznesowym jakim jest żądanie rezerwacji miejsca postojowego mamy problem modyfikacji dwóch agregatów w jednej transakcji. Ale czy to właściwie jest w ogóle problem?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public record ReservationRequester(
    @NonNull ReservationRequesterId requesterId,
    int currentUsage,
    int limit,
    @NonNull Version version
) {

  public ReservationRequester {
    if (currentUsage > limit) {
      throw new IllegalStateException("current usage cannot exceed limit");
    }
    if (limit <= 0) {
      throw new IllegalStateException("limit must be positive");
    }
  }

  public Try<ReservationRequestId> append(ReservationRequestId reservationRequestId) {
    if (willBeTooManyRequests(reservationRequestId)) {
      return Try.failure(new IllegalStateException("too many reservation requests"));
    }
    return Try.of(() -> reservationRequestId);
  }

  private boolean willBeTooManyRequests(ReservationRequestId reservationRequestId) {
    return currentUsage + 1 > limit;
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public record ReservationRequestsTimeSlot(
    @NonNull ReservationRequestsTimeSlotId timeSlotId,
    int currentUsage,
    int capacity,
    @NonNull Version version) {

  public ReservationRequestsTimeSlot {
    if (currentUsage > capacity) {
      throw new IllegalStateException("current usage cannot exceed capacity");
    }
    if (capacity <= 0) {
      throw new IllegalStateException("capacity must be positive");
    }
  }

  public Try<SpotUnits> append(SpotUnits spotUnits) {
    if (exceedsAllowedSpace(spotUnits)) {
      return Try.failure(new IllegalStateException("not enough space"));
    }
    return Try.of(() -> spotUnits);
  }

  private boolean exceedsAllowedSpace(SpotUnits spotUnits) {
    return currentUsage + spotUnits.getValue() > capacity;
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RequiredArgsConstructor
public class ReservationRequests {

  @NonNull
  private final ReservationRequestsTimeSlot timeSlot;
  @NonNull
  private final ReservationRequester requester;

  public Try<ReservationRequestMade> makeRequest(SpotUnits spotUnits) {
    ReservationRequest reservationRequest = new ReservationRequest(
        requester.requesterId(),
        timeSlot.timeSlotId(),
        spotUnits);

    var requesterResult = requester.append(reservationRequest.getReservationRequestId());
    if (requesterResult.isFailure()) {
      return Try.failure(requesterResult.getCause());
    }

    var timeSlotResult = timeSlot.append(spotUnits);
    if (timeSlotResult.isFailure()) {
      return Try.failure(timeSlotResult.getCause());
    }

    return Try.of(() -> new ReservationRequestMade(reservationRequest, requester.version(), timeSlot.version()));
  }

}

Długo nad tym myślałem i wydaje mi się, że nie. Taka jest po prostu specyfika tego przypadku. Daje nam to dobre zabezpieczenie przed przekroczeniem fizycznego limitu miejsca i limitu klienta. W tym celu wykorzystałem implementację, którą opisałem we wcześniejszym artykule, aby zastosować blokowanie optymistycznie z wykorzystaniem zdarzeń (eksperymentuję z tym podejściem).

W cely weryfikacji poprawności powyższego modelu możemy rozważyć następujący przypadek. Co się stanie, gdy dane miejsce postojowe będzie musiało być zarezerwowane z powodu renowacji? Nasz model się broni. Do tego przypadku biznesowego zaprzęgniemy tylko klasę ReservationRequestsTimeSlot. Klient nic nie musi o tym wiedzieć. Masz może jakieś inne pomysły, aby sprawdzić prawidłowe działanie tego modelu?

Dobra, ale powstaje teraz pytanie - skąd miejsce postojowe w tym modelu wie kto zażądał na nie rezerwację? Odbijając tą piłeczkę, to czy ono musi to wiedzieć? Dla niego bardziej istotne jest to ile ma jeszcze miejsca wolnego. To kto je zajmuje powinień wiedzieć inny byt jakim jest właśnie żądanie. Przy persystencji wykonywania żądania rezerwacji wyglądałoby to tak.

  • Sprawdź blokowanie optymistyczne dla miejsca postojowego
  • Podbij zajętość miejsca postojowego
  • Sprawdź blokowanie optymistyczne dla klienta
  • Podbij licznik żądań klienta
  • Dodaj żądanie posiadające informację - kto, co i ile?

Czyli wyłonił nam się byt ReservationRequest, który można anulować. I to jest najfajniejsza część. Patrząć od strony technicznej, w relacyjnej bazie danych mielibyśmy relację pomiędzy miejscem postojowym a żądaniami oraz klientem a żądaniami. Usuwając żądanie nie musimy blokować optymistycznie np. miejsca postojowego. My je przecież zwalniamy. Reguła z nieprzekroczeniem ilości miejsca tutaj nie obowiązuje. Wtedy model całości znacznie nam się upraszcza. ReservationRequestsTimeSlot nie musi nic wiedzieć o tym jak usuwać żądanie. Ta klasa służyła nam tylko do ich “produkcji”.

W ten oto sposób prezentują się moje ostatnie rozterki. Daj znać czy widzisz w tych rozważaniach jakikolwiek sens. Być może czegoś jeszcze nie przewidziałem a powinienem.