Załóżmy, że przychodzisz do projektu, w którym widzisz jedną wielką encję Hibernate połączoną z innymi encjami przy pomocy adnotacji @OneToMany, @ManyToOne czy @OneToOne. Na początku myślisz, że to fajne rozwiązanie, bo wszystkie dane masz pod ręką. Jednak gdy przychodzi do napisania testu to łapiesz się za głowę. Myślisz sobie: “Jak ja mam to zrobić? Przecież muszę przygotować tak wiele niepotrzebnych danych w kontekście mojego testu!”. I faktycznie, gdy zestawiasz tylko niezbędne informacje to przy uruchomieniu testu dostajesz NullPointerException. Próbujesz więc dodawać kolejne i kolejne dane, tylko po to aby test w ogóle przeszedł do sekcji asercji. Następnego dnia, pełen frustracji, poruszyłeś ten temat w zespole i udało Ci się przekonać kolegów i koleżanki, aby coś z tym zrobić. Zdecydowaliście się na rozbicie tej wielkiej encji! Powstało natomiast następujące pytanie: “Jak to zrobić?”.

Taki mniej więcej scenariusz spotkał mnie w obecnej pracy. W tym wpisie przedstawię Ci jeden ze sposobów walki z Goliatem, z jakiego skorzystaliśmy. Przejdziemy krok po kroku po przepisie wykorzystującym do tego celu kompozycję.

Krok 0. - Przedstawienie problemu

Załóżmy, że w projekcie zaprojektowano encję Client, która ma wiele zamówień w postaci listy Order. Są one połączone ze sobą dwustronnie przez adnotacje @OneToMany i @ManyToOne. Dzięki temu bez problemu dostaniemy się do listy zamówień korzystając z klasy Client, jaki i pozyskamy klienta z każdej klasy Order. Istny Eden!

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Entity
public class Client {

  @Id
  @GeneratedValue
  private Long id;

  @OneToMany(mappedBy = "client")
  private List<Order> orders = new ArrayList<>();

  //...

  public void addOrder(Order order) {
    order.setClient(client);
    orders.add(order);
  }

  public ClientId getClientId() {
    return ClientId.of(id);
  }

  public List<Order> getOrders() {
    return orders;
  }

}

@Entity
public class Order {

  @Id
  @GeneratedValue
  private Long id;

  @ManyToOne
  private Client client;

  //...

  public void setClient(Client client) {
    this.client = client;
  }

  public OrderId getOrderId() {
    return OrderId.of(id);
  }

  public Client getClient() {
    return client;
  }

}

Dysponujemy jednym, centralnym repozytorium ClientRepository, które uzyskaliśmy za darmo od Spring Data.

1
2
3
4
5
6
7
8
9
public interface ClientRepository extends CrudRepository<Client, Long> {

  default Client findByClientIdForce(ClientId clientId) {
    return findById(clientId.toLong())
              .orElseThrow(() -> new IllegalStateException(
                  "client cannot be found for id: " + clientId.toLong()))
  }

}

W celu wykonania akcji na konkretnym zamówieniu musimy:

  • wyciągnąć klienta po id
  • wyciągnąć konkretne zamówienie z pobranego klienta
  • wykonać wybraną akcje na zamówieniu

Rozglądając się po naszym wyimaginowanym kodzie znajdziemy gdzieniegdzie właśnie takie wykorzystanie repozytorium.

1
2
3
4
5
6
7
Client client = clientRepository.findByClientIdForce(clientId);
Set<ProductCode> uniqueProductCodes = client.getOrders()
    .stream()
    .map(Order::getProducts)
    .flatMap(Collection::stream)
    .map(Product::getProductCode)
    .collect(Collectors.toUnmodifiableSet());

Na szczęście ktoś pomyślał, aby zaprojektować metodę add(Order) w klasie Client. Można było przecież skorzystać z takiego zapisu - .getOrders().add(order)

1
2
Client client = clientRepository.findByClientIdForce(clientId);
client.add(order);

Krok 1. - Projektowanie

Zawsze trzeba pamiętać jaki problem rozwiązujemy. Przypominam, że chcemy rozbić dwie encje, aby mogły żyć własnym życiem. Na poziomie bazy danych dalej będzie pomiędzy nimi takie samo powiązanie w postaci klucza obcego (brak konieczności migracji danych). W kodzie natomiast pozbędziemy się wzajemnej świadomości encji. Wszystko to z powodu lepszej testowalności aplikacji.

Jeśli Order ma żyć swoim życiem musimy stworzyć dla niego własne repozytorium. Cel w postaci ClientRepository prezentuje się następująco.

1
2
3
4
5
public interface OrderRepository extends CrudRepository<Order, Long> {

  Set<Order> findAllByClientId(ClientId clientId);

}

Jednak aby do niego dotrzeć, trzeba wykonać kilka kroków pośrednich. Pierwszym z nich będzie zlokalizowanie miejsc w kodzie, gdzie wykonywane są operacje na Order. Jest to dosyć proste zadanie o ile korzystamy z odpowiedniego IDE jakim jest na przykład IntelliJ. Teraz warto zaprojektować przejściową wersję naszego repozytorium. Chcielibyśmy przenieść wszystkie sposoby zapisywania i pobierania Order w jedno miejsce. Tutaj możemy skorzystać z wcześniej wspomnianej kompozycji. Sztuczka polega na opakowaniu encji Client, aby na jej podstawie wystawić API naszego nowego repozytorium. Może to wyglądać w następujący sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class OrderRepository {

  private final Client client;

  public OrderRepository(Client client) {
    this.client = client;
  }

  public Set<Order> findAllByClientId(ClientId clientId) {
    return client.getOrders();
  }

  public Order save(Order order) {
    client.add(order);
    return order;
  }

}

Tak przygotowani możemy przejść dalej. Wykorzystajmy nasz wrapper w kodzie produkcyjnym.

Krok 2. - Wykorzystanie

Spójrzmy jeszcze raz na pierwszy przypadek wykorzystania, który widzieliśmy wcześniej.

1
2
3
4
5
6
7
Client client = clientRepository.findByClientIdForce(clientId);
Set<ProductCode> uniqueProductCodes = client.getOrders()
    .stream()
    .map(Order::getProducts)
    .flatMap(Collection::stream)
    .map(Product::getProductCode)
    .collect(Collectors.toUnmodifiableSet());

Podepnijmy pod niego nasze nowe repozytorium.

1
2
3
4
5
6
7
8
9
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);

Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
    .stream()
    .map(Order::getProducts)
    .flatMap(Collection::stream)
    .map(Product::getProductCode)
    .collect(Collectors.toUnmodifiableSet());

O dziwo kod wygląda gorzej, co nie? Jednak aby wszystko było wykonane bezpiecznie, musimy “zabrudzić” istniejące rozwiązanie. Jednak możemy być pewni, że ten kod działa! Z tego powodu można zastosować to rozwiązanie, zrobić merge do master i puścić na produkcję!

💡 Przekaż WSZYSTKIM kolegom i koleżankom o stosowaniu tego rozwiązania. Inaczej znowu zostaniecie w kodzie z wywołaniami typu client.getOrders() czy client.add(order), rozsianymi wszędzie!

Spójrzmy na drugi przypadek.

1
2
Client client = clientRepository.findByClientIdForce(clientId);
client.add(order);

Teraz i tutaj użyjmy OrderRepository.

1
2
3
4
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);

orderRepository.save(order);

Było znowu tak prosto, a ja specjalnie utrudniam… Poczekajmy jednak na finalny efekt.

Krok 3. - Usuwanie

Pora na czyszczenie naszego kodziku. Na start usuńmy powiązania @ManyToOne i @OneToMany w naszych encjach.

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
29
30
31
32
33
34
35
36
37
@Entity
public class Client {

  @Id
  @GeneratedValue
  private Long id;

  //...

  public ClientId getClientId() {
    return ClientId.of(id);
  }

}

@Entity
public class Order {

  @Id
  @GeneratedValue
  private Long id;

  @Embedded
  @AttributeOverride(name = "value", column = @Column("client_id"))
  private ClientId clientId;

  //...

  public OrderId getOrderId() {
    return OrderId.of(id);
  }

  public ClientId getClientId() {
    return clientId;
  }

}

Pozbyliśmy się dwustronnych powiązań! Hmm… Kod się teraz nie kompiluje… Spróbujmy to naprawić. OrderRepository może stać się bez problemu interfejsem i rozszerzać CrudRepository. Przekonajmy się co można zrobić z kodem odpowiedzialnym za pobieranie unikatowych kodów produktów.

1
2
3
4
5
6
7
8
9
Client client = clientRepository.findByClientIdForce(clientId);
OrderRepository orderRepository = new OrderRepository(client);

Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
    .stream()
    .map(Order::getProducts)
    .flatMap(Collection::stream)
    .map(Product::getProductCode)
    .collect(Collectors.toUnmodifiableSet());

Po zmianie OrderRepository stanie się polem wstrzykiwanym do danej klasy, repozytorium ClientRepository stanie się zbędne, a po kliencie pozostanie nam tylko wspomnienie… i clientId.

1
2
3
4
5
6
Set<ProductCode> uniqueProductCodes = orderRepository.findAllByClientId(clientId)
    .stream()
    .map(Order::getProducts)
    .flatMap(Collection::stream)
    .map(Product::getProductCode)
    .collect(Collectors.toUnmodifiableSet());

Posprzątane! Wygląda to o niebo lepiej. Jeszcze przypisywanie nowego zamówienia dla klienta. Zobaczmy sam efekt finalny.

1
orderRepository.save(order);

W tym przypadku jedna linijka robi już całą robotę. Order ma w sobie referencję do ClientId. Wychodzi na to, że zadanie wykonane! Zweryfikujmy to poprzez napisanie testu. Zobaczymy czy faktycznie lepiej testuje się naszą aplikację.

Krok 4. - Testowanie

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
29
30
31
32
class OrderSpec extends Specification {

  ClientRepository clientRepository = new InMemoryClientRepository()
  
  @Subject
  OrderService orderService = new OrderService(clientRepository)

  def "get unique products code for all client orders"() {
    given:
    Product abcProduct = new Product(ProductCode.ABC)
    Product xyzProduct = new Product(ProductCode.XYZ)
    and:
    Order firstOrder = new Order(1, ...)
    firstOrder.addProduct(abcProduct, 3)
    and:
    Order secondOrder = new Order(2, ...)
    secondOrder.addProduct(abcProduct, 2)
    secondOrder.addProduct(xyzProduct, 4)
    and:
    Client client = new Client(1, ...)
    client.add(firstOrder)
    client.add(secondOrder)
    and:
    clientRepository.save(client)

    when:
    Set<ProductCode> uniqueProductCodes = orderService.getAllUniqueProductCodes(client.getClientId())

    then:
    uniqueProductCodes == Set.of(ProductCode.XYZ, ProductCode.ABC)
  }
}

Przykładowy test dla starego rozwiązania mógłby wyglądać w powyższy sposób. Zasadność zależności pomiędzy Product i Order pozostawiam Tobie. Przyjmijmy, że aktualnie to nie stanowi dla nas problemu. Z punktu widzenia testu musieliśmy utworzyć instancję klasy Client, chociaż biznesowo nie jest ona nam do niczego potrzebna. Chcemy pobrać tylko zamówienia klienta na podstawie jego id. Zobaczmy więc jak będzie to wyglądało w przypadku nowego podejścia.

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
29
30
31
class OrderSpec extends Specification {

  OrderRepository orderRepository = new InMemoryOrderRepository()
  
  @Subject
  OrderService orderService = new OrderService(orderRepository)

  def "get unique products code for all client orders"() {
    given:
    ClientId clientId = ClientId.of(0)
    and:
    Product abcProduct = new Product(ProductCode.ABC)
    Product xyzProduct = new Product(ProductCode.XYZ)
    and:
    Order firstOrder = new Order(1, clientId, ...)
    firstOrder.addProduct(abcProduct, 3)
    and:
    Order secondOrder = new Order(2, clientId, ...)
    secondOrder.addProduct(abcProduct, 2)
    secondOrder.addProduct(xyzProduct, 4)
    and:
    orderRepository.saveAll(List.of(firstOrder, secondOrder))
    and:

    when:
    Set<ProductCode> uniqueProductCodes = orderService.getAllUniqueProductCodes(clientId)

    then:
    uniqueProductCodes == Set.of(ProductCode.XYZ, ProductCode.ABC)
  }
}

Nieznacznie, ale test uległ uproszczeniu. Jeśli klasa Client posiadałaby wiele zależności to na pewno tym ruchem oszczędziliśmy sobie sporo pracy. W przypadku gdy zajdzie zmiana w tej encji to test "get unique products code for all client orders" na tym nie ucierpi. Nie będziemy musieli do niego wchodzić i w razie czego dostosowywać do nieistotnej, z jego punktu widzenia, zmiany.

Podsumowanie

Mam nadzieję, że ten przypadek “z placu boju” był dla Ciebie ciekawy oraz przydatny. Oczywiście w kodzie produkcyjnym nie wyglądało to tak pięknie. Trzeba było jeszcze przenieść przysłowiowy OrderRepository do odpowiedniego modułu. Z racji łatwego dostępu do metody getOrders() była ona wykorzystywana w wielu miejscach poza pakietem order… Powstaje, więc pytanie - “w jaki sposób moduły mogą wymieniać ze sobą informacje poza wywołaniem metod takich jak getOrders”? Ja znalazłem 4 takie sposoby, ale domyślam się, że może być ich więcej. Chcesz się o nich dowiedzieć? Daj znać w komentarzu.

Na koniec dodam, że w przypadku naszego projektu ważne było zwiększenie testowalności, ponieważ powstawało za dużo błędów. Ta sztuczka bardzo nam w tym pomogła. Jeśli u was też jest to tak istotne to może warto ją rozważyć? Warto do tego dołożyć jeszcze zasadę ‘First Class Collections’ z Object Calisthenics. Zachęcam mocno do spróbowania!