Co to jest DTO i czym różni się od encji w Spring Boot?

You are currently viewing Co to jest DTO i czym różni się od encji w Spring Boot?

Wstęp

Zwracasz encje JPA bezpośrednio w kontrolerach? Gratulacje, właśnie stworzyłeś sobie bombę zegarową. Co się stanie, gdy przypadkowo wyślesz hasło użytkownika w odpowiedzi API? Albo gdy lazy loading rzuci wyjątkiem podczas serializacji do JSONa? To właśnie wtedy doceniasz istnienie DTO.

Każdy programista Spring Boot prędzej czy później staje przed dylematem – używać encji bezpośrednio w API, czy może jednak stworzyć dodatkową warstwę z DTO? W tym wpisie rozwieję wszelkie wątpliwości. Pokażę ci, czym dokładnie jest DTO, jak różni się od encji i dlaczego zwracanie encji w REST API to jedna z najgorszych decyzji, jakie możesz podjąć. Na koniec zobaczysz praktyczne przykłady mapowania między tymi obiektami.

Encja – odwzorowanie tabeli w bazie danych

Encja to obiekt, który ma bezpośrednie odzwierciedlenie w strukturze tabeli w bazie danych. Jej głównym i jedynym zadaniem jest mapowanie danych relacyjnych na obiektowy model Javy. W ekosystemie Springa najczęściej mamy do czynienia z encjami JPA (Java Persistence API), które oznaczamy adnotacją @Entity.

Struktura encji jest ściśle podyktowana schematem bazy danych. Pola w klasie odpowiadają kolumnom w tabeli, a relacje między encjami (@OneToMany, @ManyToOne) odzwierciedlają powiązania między tabelami.

Zobaczmy to na prostym przykładzie:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "users")
public class UserEntity {

    @Id
    private Long id;
    private String email;
    private String passwordHash;
    private boolean active;

    // Gettery, settery, konstruktory...
}

Kluczowe cechy encji:

  • Jest oznaczona adnotacją @Entity.
  • Posiada klucz główny (@Id).
  • Jej struktura jest odzwierciedleniem tabeli w bazie danych.
  • Służy wyłącznie do operacji w warstwie dostępu do danych (persystencji).

DTO – obiekt do przenoszenia danych

DTO, czyli Data Transfer Object, to prosty obiekt, którego jedynym celem jest przenoszenie danych pomiędzy różnymi warstwami lub systemami. W przeciwieństwie do encji, struktura DTO nie jest podyktowana bazą danych, a potrzebami klienta, który te dane konsumuje (np. aplikacji frontendowej, inny mikroserwis).

DTO to elastyczny „kontener” na dane. Możemy w nim:

  • umieścić tylko te pola, które są potrzebne w danym widoku,
  • połączyć informacje z kilku encji,
  • ukryć wrażliwe dane, których nie chcemy ujawniać na zewnątrz.

Dla naszej encji UserEntity DTO mogłoby wyglądać tak:

public class UserDto {
    private String fullName;
    private String email;
    // Brak pola passwordHash!
    // Brak pola active!

    public UserDto(String fullName, String email) {
        this.fullName = fullName;
        this.email = email;
    }

    // Gettery i settery
}

// Lub w postaci recordu
public record UserDto(String fullName, String email) {
}

Kluczowe różnice między DTO a encją

Na pierwszy rzut oka DTO i encje mogą wyglądać podobnie – w końcu w obu trzymamy dane. Różnica jest jednak zasadnicza i wpływa na to, jak projektujesz aplikację.

  • Cel istnienia – Encja reprezentuje tabelę w bazie danych i jest ściśle powiązana z warstwą persystencji (czyli częścią aplikacji odpowiedzialną za zapisywanie i odczytywanie danych z bazy). DTO natomiast służy wyłącznie do transferu danych między warstwami aplikacji, najczęściej między backendem a frontendem, ale też między mikroserwisami czy innymi aplikacjami. Jakbyśmy mieli porównać to na przykładzie to encja byłaby oryginalnym dokumentem w sejfie, a DTO kopią, którą wysyłamy pocztą.
  • Logika biznesowa – Encja może zawierać metody z logiką biznesową, która dotyczy tego obiektu. Natomiast DTO to czyste struktury danych – zero logiki, tylko pola i ewentualnie walidacje.
  • Adnotacje JPA – Encje są obklejone adnotacjami typu @Entity, @Table, @OneToMany itd., żeby Hibernate wiedział, jak je zapisać i odczytać. DTO natomiast są zwykłymi prostymi klasami/rekordami Java.
  • Powiązania – Encje mogą tworzyć całe drzewa obiektów z relacjami i trybami ładowania (lazy/eager). DTO zazwyczaj jest płaskie albo zawiera tylko ID powiązań. Dzięki temu nie robisz przypadkiem pętli przy serializacji do JSON-a.
  • Cykl życia – Encją zarządza Hibernate/JPA, śledząc jej stan (persistent, detached, removed). DTO to zwykły obiekt – tworzysz, używasz, usuwasz i zapominasz.
  • Modyfikowalność – Encje są mutowalne (Hibernate musi móc zmienić stan obiektu). DTO, zwłaszcza w postaci record w nowszej Javie, jest niemutowalne – raz utworzone, nie da się zmienić wartości. To zwiększa bezpieczeństwo i przewidywalność.

Dlaczego NIGDY nie powinno się wystawiać encji w API?

Dlaczego NIGDY nie powinno się wystawiać encji w API?

Wyobraź sobie, że wypuszczasz nową wersję API. Wszystko działa pięknie, testy przechodzą, deploy się udał. Nagle dostajesz telefon – frontend dostaje dane o haśle użytkownika w odpowiedzi z endpointa. Lekko niefortunna sytuacja. Niestety to jeden z wielu przykładów, które mogę Cię spotkać, gdy zwracasz encje bezpośrednio z kontrolera.

Luki bezpieczeństwa

Encje często zawierają pola, które nigdy nie powinny opuścić serwera: hasła (nawet zahashowane), tokeny, ID wewnętrzne, flagi administracyjne czy daty ostatniej modyfikacji. Jeden zapomniany @JsonIgnore i w sekundę masz wyciek danych.
W DTO sam decydujesz, które pola wysyłasz – zero przypadków „przeciekło, bo było w encji”.

Problemy z wydajnością i lazy loadingiem

Encje często korzystają z leniwego ładowania (FetchType.LAZY). Próba serializacji takiej encji poza sesją transakcyjną skończy się błędem LazyInitializationException.

API przywiązane do struktury bazy

Zmienisz nazwę kolumny w tabeli albo dodasz relację i… gratulacje, tym sposobem właśnie złamałeś kontrakt API. Twoja baza danych staje się de facto publicznym API, którego nie możesz modyfikować bez ryzyka popsucia aplikacji klienckich. Natomiast jeśli użyjesz DTO to otrzymujesz warstwę abstrakcji i pełną kontrolę nad tym, co trafia na zewnątrz.

Cykliczne zależności i pętle w serializacji

User ma Orders, Order ma User, a User ma znowu Orders… i Jackson wchodzi w nieskończoną pętlę. Jasne, można łatać @JsonManagedReference i @JsonBackReference, ale to walka z objawami, a nie przyczyną.

Brak kontroli nad rozmiarem odpowiedzi

Encja użytkownika ma 50 pól, jest powiązana z 100 zamówieniami, a każde zamówienie ma 20 produktów… i nagle zamiast 1 KB leci w świat 10 MB JSON-a. Użytkownicy na wolnym internecie mobilnym „podziękują” Ci za minutę czekania na załadowanie ekranu 😀

Problemy z wersjonowaniem API

Chcesz wprowadzić API v2 z inną strukturą danych? Z encjami musisz albo duplikować całą warstwę persystencji, albo kombinować z warunkami w kodzie. A mając odpowiedzi podział z klasą DTO? Wystarczy, że stworzysz nową wersję klasy UserDtoV2 i mapujesz dane tak jak potrzebujesz.

Złamanie zasady Single Responsibility

Encja powinna odpowiadać wyłącznie za mapowanie danych do bazy. Jeśli zaczynasz używać jej także do komunikacji w API, łączysz dwie odpowiedzialności w jednej klasie, łamiąc zasadę pojedynczej odpowiedzialności (SRP). Projektując aplikację, warto pilnować, aby każda warstwa zajmowała się tylko swoim zakresem i nie przejmowała zadań innych warstw.

Mapowanie encji na obiekt DTO

Aby oddzielić encję od DTO, potrzebujemy mechanizmu, który przetłumaczy jeden obiekt na drugi. Takie zadanie wykonuje mapper.

@Component // Lub @Service
public class UserMapper {

    public UserDto toDto(UserEntity entity) {
        if (entity == null) {
            return null;
        }
        return new UserDto(entity.getId(), entity.getEmail());
    }

    // Ewentualna metoda toEntity(UserDto dto)
}

Następnie w serwisie możemy użyć mappera przed zwróceniem danych do kontrolera:

@Service
public class UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;
    
    // ... konstruktor

    public Optional<UserDto> findUserById(Long id) {
        return userRepository.findById(id)
                .map(userMapper::toDto);
    }
}

Do automatyzacji procesu mapowania świetnie nadaje się biblioteka MapStruct, która generuje kod mappera w czasie kompilacji, oszczędzając nam pisania powtarzalnego kodu. Więcej o tej bibliotece pisałem w innym wpisie: Czym różni się MapStruct od klasycznych mapperów w Javie?

Podsumowanie

Zapamiętaj jedną rzecz: encja jest dla bazy, DTO jest dla klienta. To prosta zasada, która stoi za bezpiecznym, wydajnym i łatwym w utrzymaniu kodem w Spring Boot. Dzięki DTO masz pełną kontrolę nad tym, jakie dane wychodzą z aplikacji i w jakim formacie, a Twój kontrakt API pozostaje stabilny i przewidywalny.
Na początku może się wydawać, że to zbędny narzut, ale w każdym większym projekcie ta „dodatkowa” praca zwraca się z nawiązką.

Jeśli masz pytania lub chcesz podzielić się swoimi doświadczeniami z DTO i encjami, śmiało zostaw komentarz poniżej!

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