Jak używać Java Records – nowoczesny sposób na DTO

You are currently viewing Jak używać Java Records – nowoczesny sposób na DTO

Wstęp

Jeśli piszesz w Javie, to na pewno znasz ten schemat – tworzysz klasę DTO, dodajesz pola, generujesz gettery, toString()equals()hashCode(), konstruktor… i tak za każdym razem. Przy kilkunastu DTO w projekcie robi się z tego sporo powtarzalnego kodu, który tak naprawdę nic nie wnosi. Pewnie powiesz – „no dobra, ale od tego mam Lomboka i @Value załatwia sprawę„. I masz rację, ale Lombok to dodatkowa zależność i magia w tle, a Java od jakiegoś czasu ma na to swoje, wbudowane rozwiązanie.

Od Javy 16 mamy do dyspozycji Records – mechanizm wbudowany w język, który pozwala zamknąć całe DTO w jednej linijce. W tym wpisie pokażę Ci, czym są Records, jak używać ich jako DTO w Spring Boot, czym różnią się od Lomboka i na co uważać.

A jeśli nie wiesz dokładnie, czym są DTO i czym różnią się od encji – zajrzyj do jednego z wcześniejszych wpisów Co to jest DTO i czym różni się od encji w Spring Boot.

Czym są Java Records?

Record to specjalny typ klasy wprowadzony w Javie 14 jako preview i oficjalnie dostępny od Javy 16. Jego zadanie jest proste – przechowywanie danych bez zbędnego boilerplate’u.

Spójrz na klasyczne DTO:

public class UserDto {
    private final String name;
    private final String email;
    private final int age;

    public UserDto(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }

    @Override
    public boolean equals(Object o) { /* ... */ }

    @Override
    public int hashCode() { /* ... */ }

    @Override
    public String toString() { /* ... */ }
}

Ponad 20 linii kodu, a jedyne co ta klasa robi, to trzyma trzy pola. A teraz to samo jako Record:

public record UserDto(String name, String email, int age) {}

Jedna linijka. Kompilator generuje za Ciebie konstruktor, metody dostępowe (name()email()age() – bez prefiksu „get”), equals()hashCode() i toString(). Wszystkie pola są finalne i ustawiane przez konstruktor, więc Record jest z definicji niemutowalny.

Różnica w czytelności jest spora, prawda?

A co z Lombokiem?

Pewnie zastanawiasz się, czy w ogóle warto przesiadać się z Lomboka na Records. Spójrzmy na to samo DTO napisane na trzy sposoby, żeby było widać różnice na pierwszy rzut oka.

Klasyczna klasa z Lombokiem (np. @Value dla niemutowalnego DTO):

@Value
public class UserDto {
    String name;
    String email;
    int age;
}

Klasa z Lombokiem i @Data (mutowalna):

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
    private String name;
    private String email;
    private int age;
}

To samo jako Record:

public record UserDto(String name, String email, int age) {}

Wszystkie trzy warianty robią podobną robotę – dają Ci klasę do trzymania danych bez ręcznego pisania getterów, equals() czy toString(). Różnice są jednak w detalach i to one decydują o tym, co wybrać.

Lombok to zewnętrzna zależność. Działa na poziomie kompilacji przez tzw. annotation processing – generuje kod podczas budowania projektu. Brzmi super, ale w praktyce oznacza to kilka rzeczy: musisz mieć wtyczkę do IDE (inaczej IntelliJ czy Eclipse pokażą Ci błędy w kodzie, którego „nie widać”), przy nowej wersji Javy czasem trzeba czekać na update Lomboka, a debugowanie wygenerowanego kodu bywa upierdliwe.

Records są częścią języka. Nie potrzebujesz nic instalować, niczego konfigurować, żadnej wtyczki, żadnej zależności w pom.xml. Działa od razu, w każdym IDE i z każdym narzędziem, które wspiera odpowiednią wersję Javy. Dostajesz też lepszą integrację z innymi mechanizmami języka – np. pattern matching w switch od Javy 21.

Lombok robi więcej niż Records. I to jest argument w drugą stronę. @Builder, @Slf4j, @RequiredArgsConstructor, @SneakyThrows – to rzeczy, których Records nie zastąpią. Jeśli używasz Lomboka do entity JPA, klas serwisów czy konfiguracji – on dalej tam zostaje.

Z mojego doświadczenia w nowych projektach na Javie 17+ Records są domyślnym wyborem dla DTO – prostszy zapis, mniej magii, mniej zależności. Lomboka zostawiam do entity, klas wymagających buildera albo gdy projekt już go używa wszędzie indziej i nie ma sensu mieszać podejść. To nie musi być wybór „albo-albo” – oba narzędzia spokojnie współistnieją w jednym projekcie.

Record jako DTO w Spring Boot

W praktyce Records świetnie sprawdzają się jako DTO w aplikacjach Spring Boot. Osobiście używam ich praktycznie w każdym nowym projekcie, gdzie mam Javę 17+. Zobaczmy kilka typowych zastosowań.

Request DTO z walidacją

Połączenie Records z Bean Validation działa bez problemu. Adnotacje walidacyjne dodajesz bezpośrednio przy parametrach:

public record CreateUserRequest(
    @NotBlank(message = "Imię jest wymagane")
    String name,

    @Email(message = "Niepoprawny format email")
    String email,

    @Min(value = 18, message = "Musisz mieć min. 18 lat")
    int age
) {}

Użycie w kontrolerze wygląda tak samo jak z klasycznym DTO:

@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
        @Valid @RequestBody CreateUserRequest request) {
    // request.name(), request.email(), request.age()
    return ResponseEntity.ok(userService.create(request));
}

Jackson (biblioteka do serializacji JSON w Spring Boot) obsługuje Records od wersji 2.12, więc deserializacja request body działa automatycznie. Nie musisz nic dodawać ani konfigurować.

Response DTO

Records równie dobrze sprawdzają się po stronie odpowiedzi:

public record UserResponse(Long id, String name, String email) {

    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail()
        );
    }
}

Statyczna metoda fabrykująca from() to wygodny wzorzec mapowania – trzymasz logikę konwersji blisko klasy, która jej potrzebuje.

Compact constructor

Records dają też możliwość dodania logiki w tak zwanym kompaktowym konstruktorze. Przydaje się, gdy chcesz przetworzyć dane przed zapisaniem do pól – np. przyciąć białe znaki:

public record CreateUserRequest(String name, String email, int age) {
    public CreateUserRequest {
        if (name != null) {
            name = name.strip();
        }
        if (email != null) {
            email = email.toLowerCase().strip();
        }
    }
}

Zwróć uwagę na brak nawiasów po nazwie konstruktora i przypisań this.name = name – kompilator doda je automatycznie na końcu.

Zagnieżdżone Records

Records świetnie komponują się ze sobą. Możesz spokojnie wrzucić jeden Record jako pole drugiego – to częsty wzorzec, gdy DTO ma strukturę bardziej złożoną niż płaski zestaw pól:

public record AddressDto(String city, String street, String postalCode) {}

public record UserDto(
    String name,
    String email,
    AddressDto address
) {}

Jackson poradzi sobie z taką strukturą bez dodatkowej konfiguracji – zarówno przy serializacji do JSON-a, jak i przy odczycie request body. Walidacja Bean Validation też działa, wystarczy oznaczyć zagnieżdżone pole adnotacją @Valid.

Customizacja JSON-a

Czasem chcesz, żeby pole w JSON-ie nazywało się inaczej niż w kodzie – na przykład w Javie masz fullName, a frontend oczekuje name. Adnotacje Jacksona działają na polach Record tak samo jak w zwykłych klasach:

public record UserResponse(
    Long id,
    @JsonProperty("name") String fullName,
    @JsonIgnore String internalCode
) {}

@JsonIgnore zostawia pole w obiekcie, ale Jackson pominie je przy serializacji. Przydaje się, gdy potrzebujesz dodatkowego pola wewnątrz Recordu do operacji w kodzie, ale nie chcesz go wystawiać na zewnątrz w API.

Uwaga na kolekcje w Records

Records dają niemutowalność, ale tylko taką „płytką”. Jeśli wrzucisz do Recordu listę albo mapę, sama referencja do kolekcji jest finalna, ale zawartość nadal można modyfikować z zewnątrz.

public record OrderDto(String id, List<String> items) {}

List<String> items = new ArrayList<>(List.of("Java", "Spring"));
OrderDto order = new OrderDto("123", items);

items.add("Hibernate");
System.out.println(order.items()); // [Java, Spring, Hibernate]

Niby Record, niby niemutowalny, a stan obiektu zmienił się po jego utworzeniu. Brzmi groźnie, ale rozwiązanie jest proste – zrób kopię w compact constructorze:

public record OrderDto(String id, List<String> items) {
    public OrderDto {
        items = List.copyOf(items);
    }
}

Teraz items w środku Recordu jest niezależną, niemutowalną kopią. Modyfikacja oryginalnej listy z zewnątrz nic nie zmieni, a próba wywołania order.items().add(…) rzuci UnsupportedOperationException. Ten jeden sposób warto zapamiętać i stosować wszędzie, gdzie Record trzyma kolekcję.

Kiedy Record nie zadziała?

Records mają swoje ograniczenia i warto je znać przed użyciem.

Nie nadają się jako JPA Entity. Hibernate wymaga bezargumentowego konstruktora, setterów i mutowalności. Record nie spełnia żadnego z tych wymagań. Do encji dalej używaj zwykłych klas. Record to DTO, nie entity – trzymaj się tego podziału.

Nie obsługują dziedziczenia. Record nie może rozszerzać innej klasy, bo domyślnie rozszerza java.lang.Record. Może za to implementować interfejsy, więc wspólny kontrakt dla różnych DTO da się zrobić.

Pola są zawsze finalne. Nie ma setterów, nie zmienisz stanu po utworzeniu obiektu. Jeśli potrzebujesz budować obiekt krok po kroku (builder pattern), Record nie jest dobrym wyborem.

Sprawdź kompatybilność ze swoim stackiem. Starsze wersje Lomboka czy MapStructa mogą mieć problemy z Records. Przy Spring Boot 3.x i Javie 17+ z mojego doświadczenia nie ma problemów. Natomiast przy starszych projektach warto to sprawdzić.

Podsumowanie

Jeśli masz zapamiętać z tego wpisu jedną rzecz, niech to będzie ta – Record do DTO, zwykła klasa do encji. Jak się tego trzymasz, oszczędzasz sobie nerwów z Hibernate i masz mniej kodu do czytania.

A jak Wy to macie u siebie? Przeszliście już na Records, czy dalej głównie wykorzystujecie Lomboka w projekcie? Napisz w komentarzu, chętnie poczytam co u Was działa.

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze