DevCezz

Programistyczny blog dla Ciebie

Czy MapStruct rozleniwia?
Wskazówki i porady

Czy MapStruct rozleniwia?

Na początku bieżącego roku napisałem 3 artykuły na temat MapStruct („Podstawy biblioteki MapStruct”, „Użycie wzorca dekorator w MapStruct”, „Różne przypadki wykorzystania biblioteki MapStruct”). Wtedy za cel postawiłem sobie, aby przybliżyć Tobie ideę stojącą za tą biblioteką. Dzisiaj natomiast chciałbym przestrzec przed zagrożeniem jakie możemy napotkać podczas korzystania z tego narzędzia na bazie własnego doświadczenia.

Wprowadzenie do problemu

Załóżmy, że mamy klasę odwzorowującą model pracownika (przykład z poprzedniego artykułu). Niesie on w sobie informacje o imieniu, nazwisku i pensji. Od razu zaznaczę, że to nie jest dobry przykład jeśli chodzi o przemyślany design, ale to nie jest sedno problemu. Na GUI chcielibyśmy, aby użytkownik końcowy również widział dane o pracownikach. Co więc robimy? Dodajemy klasę DTO o nazwie EmployeeDto i zamieszczamy w nim identyczne pola jak w naszej encji.

package pl.csanecki;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Employee {

  @Id
  private Long id;
  @Column
  private String firstname;
  @Column
  private String surname;
  @Column
  private BigDecimal salary;

  protected Employee() {
  }

  public Employee(String firstname, String surname, BigDecimal salary) {
    this.firstname = firstname;
    this.surname = surname;
    this.salary = salary;
  }

  public Long getId() {
    return id;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getSurname() {
    return surname;
  }

  public BigDecimal getSalary() {
    return salary;
  }
}
package pl.csanecki;

import java.math.BigDecimal;

public class EmployeeDto {

  private final Long id;
  private final String firstname;
  private final String surname;
  private final BigDecimal salary;

  public EmployeeDto(Long id, String firstname, String surname, BigDecimal salary) {
    this.id = id;
    this.firstname = firstname;
    this.surname = surname;
    this.salary = salary;
  }

  public Long getId() {
    return id;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getSurname() {
    return surname;
  }

  public BigDecimal getSalary() {
    return salary;
  }
}

Zastanówmy się teraz co się stanie w przypadku, gdy przyjdzie nowe wymaganie, aby pracownik miał również informację o tym w jakim dziale pracuje? Rozwiązanie wydaje się proste. Dodajemy nowe pole department w obydwu klasach i fajrant!

Jaki tu jest problem?

Czy nie wygląda to trochę dziwnie? W takim modelu musimy wprowadzać identyczną zmianę w obydwu klasach. Gdyby nie MapStruct to taka poprawka miałaby miejsce również w niewygenerowanym mapperze. Jednak, gdyby nie zewnętrzna biblioteka to może wtedy szybciej dopadłaby nas frustracja i dostrzeglibyśmy pewien problem.

Wychodzi na to, że nasza encja jest po prostu anemiczna, a DTO to jej lustrzane odbicie. Projekt to po prostu zwykły CRUD i, cytując klasyka, odpowiednim podejściem powinno być “Encja na twarz i pchasz!”. Więc zamiast wykonywać dwa razy tą samą robotę (albo i trzy razy) to lepiej jest skorzystać z odpowiedniego narzędzia takiego jak np. Spring REST, ale o nim za chwilę.

Skąd taki stan rzeczy?

Właśnie podobny problem napotkałem w pracy na jednym z poprzednich projektów. Niestety nie wiem skąd taki stan rzeczy miał miejsce. Pozostają mi tylko domysły. Być może problemem było to, że ten projekt nie mógł być tak po prostu zwykłym CRUDem. Innym powodem mogła być niechęć stosowania niestandardowych konstrukcji z zewnętrznych bibliotek stąd odwzorowanie nazw pól i ich typów jeden do jednego.

Wniosek

Prawda jest taka, że gdy zrezygnujemy z DTO wywalimy po prostu bebechy encji na świat zewnętrzny. Jednak czy mając takie klasy transferujące dane jak były przedstawione powyżej nie robimy tego samego? Można to porównać do hermetyzacji w postaci pól private i publicznych getterów i setterów, prawda?

Ok, takie podejście w CRUD to nic strasznego, ale w bardziej skomplikowanych projektach staje się już problematyczne w utrzymaniu. Co w takim razie począć? Najlepiej stosować dedykowany read model. Encja wraz z tym modelem odczytowym mogłaby działać na tej samej tabeli (chociaż nie musi tak koniecznie być), tylko wyciągałaby niezbędne dane do podglądu bezpośrednio z bazy danych.

Powstaje pytanie, jak te dwa podejścia mogłyby wyglądać w praktyce?

Podejście z CRUDem

Ciekawym projektem do tego typu przypadków jest Spring REST. Dzięki niemu możemy małym sumptem stworzyć CRUD dla danej encji. Wystarczy ją tylko zakodować, wystawić repozytorium i dodać adnotację @RepositoryRestResource z odpowiednimi wartościami. Może to wyglądać w następujący sposób.

package pl.csanecki;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Employee {

  @Id
  @GeneratedValue
  private Long id;
  @Column
  private String firstname;
  @Column
  private String surname;
  @Column
  private BigDecimal salary;

  protected Employee() {
  }

  public Employee(Long id, String firstname, String surname, BigDecimal salary) {
    this.id = id;
    this.firstname = firstname;
    this.surname = surname;
    this.salary = salary;
  }

  public Long getId() {
    return id;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getSurname() {
    return surname;
  }

  public BigDecimal getSalary() {
    return salary;
  }
}
package pl.csanecki;

import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

@RepositoryRestResource(collectionResourceRel = "employees", path = "employees")
public interface EmployeeRepository extends CrudRepository<Employee, Long> {
}

I to tyle! W ten sposób dostajemy za darmo endpointy obsługujące zapis, aktualizację, usuwanie oraz odczyt danych. To wszystko dodatkowo jest na 3 poziomie dojrzałości modelu Richardsona.

Wykorzystanie Read Model

Przy bardziej skomplikowanej domenie możemy zastosować podejście z read modelem. W takim przypadku z naszej encji pozbywamy się getterów. Zostają w niej tylko metody obsługujące konkretne przypadki biznesowe chroniąc przy tym spójność danych. Natomiast na boku powstaje drugi model tylko na potrzeby odczytowe.

package pl.csanecki;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Employee {

  @Id
  @GeneratedValue
  private Long id;
  @Column
  private String firstname;
  @Column
  private String surname;
  @Column
  private BigDecimal salary;

  protected Employee() {
  }

  public Employee(Long id, String firstname, String surname, BigDecimal salary) {
    this.id = id;
    this.firstname = firstname;
    this.surname = surname;
    this.salary = salary;
  }

  // business methods
}
public record EmployeeNameDto(String firstname, String surname) {
}
@Query("SELECT new pl.csanecki.EmployeeNameDto(e.firstname, e.surname) FROM Employee e WHERE e.id = :id")
EmployeeNameDto findEmployeeNameById(@Param("id") Long id);

Jest to jeden z kilku sposobów na uzyskanie modelu odczytowego. Ten pozwala nam na odseparowanie od siebie dwóch rozłącznych pojęć. Z tego powodu nie musimy już robić niepotrzebnych mapowań zdradzających wewnętrzną strukturę naszego rozwiązania.

Podsumowanie

Mam nadzieję, że ten wpis uczuli Ciebie na tego typu problemy. Oczywiście nie musi on dotyczyć tylko użycia MapStructa. Inne rozwiązania mogą powodować ten sam problem. Z tego powodu warto zawsze być czujnym i dokonać ponownej analizy danego zagadnienia.

Podziel się tym z innymi!

ZOSTAW ODPOWIEDŹ

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *