Data Transfer Object (DTO)
Sissejuhatus
Kui rakendusel on olemas domeenimudel, tekib loomulik küsimus: kas samu objekte tuleks edastada kuni kutsujani välja?
Esmapilgul tundub see mugav - domeenimudel sisaldab juba vajalike andmeid, miks siis mitte seda otse kasutada? Probleem seisneb selles, et domeeniobjektid on loodud väljendama ja kaitsma ärireegleid, mitte toimima andmekandjatena. Need võivad sisaldada sisemisi välju, mida kutsuja ei tohiks kunagi näha, rakendada valideerimist, mis muudab deserialiseerimise keeruliseks, või sisaldada meetodeid, millel puudub tähendus väljaspool domeeni.
Data Transfer Object (DTO) muster lahendab selle, tuues sisse eraldi, kindla eesmärgiga objekti, mille ainus ülesanne on andmete edastamine - kihtide vahel, teenuste vahel või rakenduse ja selle kasutajate vahel.
Probleemist lähemalt
Eelmistes näidetes loodud raamatukogu rakendusel on Book-nimeline domeeniobjekt:
public class Book {
private final int id; // internal system identifier, should not be exposed
private final String isbn;
private String title;
private String author;
private BigDecimal price;
private LocalDateTime createdAt;
public Book(int id, String isbn, String title, String author, BigDecimal price, LocalDateTime createdAt) {
if (id <= 0) throw new IllegalArgumentException("Id must be positive");
if (isbn == null || isbn.isBlank()) throw new IllegalArgumentException("ISBN must not be blank");
// ... further validation
this.id = id;
this.isbn = isbn;
this.title = title;
this.author = author;
this.price = price;
this.createdAt = createdAt;
}
// getters, domain methods...
public void applyDiscount(int percent) {
// business logic
}
}
Kui kontroller tagastab Book objekte otse, tekib mitu probleemi:
- Sisemine id lekib välja. See on teostuse detail, millele kutsujad ei tohiks tugineda.
- Valideeriv konstruktor rikub serialiseerimise. Tööriistad, mis taastavad objekte JSON-ist või failidest, kasutavad tavaliselt parameetriteta konstruktorit või seavad väljad otse. Valideeriv konstruktor teeb selle võimatuks.
- Domeenimeetodid (
applyDiscount) on kättesaadavad seal, kus neil puudub mõte. Vastus peaks kandma andmeid, mitte äriloogika operatsioone.
DTO loomine
DTO on lihtne andmekonteiner - ei mingit äriloogikat, valideerimist ega muud käitumist peale väärtuste hoidmise ja kättesaadavaks tegemise.
Raamatuandmete lugemiseks mõeldud DTO esitab ainult seda, mida kutsuja vajab:
public record BookDto(
String isbn,
String title,
String author,
BigDecimal price,
LocalDateTime createdAt
) {}
Sisemist id-välja siin ei esine.
record on sobiv valik lugemiseks mõeldud DTO jaoks: see on muutumatu, lakooniline ning kõik väljad määratakse loomise hetkel.
Uue raamatu loomiseks loome ka eraldi DTO.
Erinevus siin seisneb selles, et createdAt määratakse serveri poolt, mitte kasutaja poolt:
public record BookCreationDto(
String isbn,
String title,
String author,
BigDecimal price
) {}
Domeeniobjektide ja DTO-de vahel teisendamine
Kontroller saab BookCreationDto objekti, edastab selle edasi domeeniobjekti loomiseks ning saab vastuseks omakorda BookDto objekti:
public class BookController {
private final BookService service;
public BookController(BookService service) {
this.service = service;
}
public BookDto getByIsbn(String isbn) {
Book book = service.findByIsbn(isbn)
.orElseThrow(() -> new IllegalArgumentException("Book not found: " + isbn));
return toDto(book);
}
public List<BookDto> getAll() {
return service.findAll().stream()
.map(this::toDto)
.toList();
}
public void addBook(BookCreationDto dto) {
service.create(dto);
}
private BookDto toDto(Book book) {
return new BookDto(book.getIsbn(), book.getTitle(), book.getAuthor(), book.getPrice(), book.getCreatedAt());
}
}
Visuaalselt:
Teisendusmeetod toDto on privaatne abimeetod.
Selle paigutamine kontrollerisse hoiab teisendusloogika kihis, mis vastutab väljapoole suunatud suhtluse eest.
Avalikud vs sisemised DTO-d
DTO-del on kaks erinevat kasutusviisi, mida tasub eristada:
Avalikud DTO-d esindavad välist lepingut, mida kutsujad saadavad ja vastu saavad.
Ülaltoodud näite kontekstis oleksid nendeks BookDto ja BookCreationDto.
Need peaksid muutuma ainult siis, kui väline leping teadlikult muutub.
Avalike API-de puhul, kus kutsujaid ei saa koheselt uuendada, on tavaks lisada uus versioon (/v2, /v3) ja hoida vana mõnda aega alles.
Sisemised DTO-d (mida mõnikord nimetatakse ka data mapper'iteks või vahepealseteks kirjeteks) kasutatakse siis, kui tehniline tööriist - näiteks JSON-teek - ei saa domeeniobjektiga otse töötada.
Domeeniobjektil võivad olla final väljad, valideeriv konstruktor või väljad, mis täidetakse pärast objekti loomist.
Sisemine DTO lahendab selle, pakkudes lihtsat, kaardistatavat struktuuri:
// Used only for JSON serialization / deserialization — not exposed to callers
public class BookRecord {
public int id;
public String isbn;
public String title;
public String author;
public String price;
}
Andmevoog näeks välja selliselt:
Pange tähele, et andmed liiguvad läbi mitme kihi.
Domeenimudel ei ole teadlik nii serialiseerimisvormingust kui ka välisest liidesest.
Jacksoni ObjectMapper on populaarne Java teek JSON-i lugemiseks ja kirjutamiseks.
Vaikimisi kaardistab see JSON-i väljad Java objekti väljadega nime järgi, kasutades parameetriteta konstruktorit ja settereid.
Just seetõttu töötavad lihtsad sisemised DTO-d sellega hästi, samas kui domeeniobjektid sageli mitte.
ObjectMapper mapper = new ObjectMapper();
// Reading: JSON string → internal DTO → domain object
String json = "{\"id\":1,\"isbn\":\"978-0-13-468599-1\",\"title\":\"The Pragmatic Programmer\",\"author\":\"Hunt & Thomas\",\"price\":\"45.00\"}";
BookRecord record = mapper.readValue(json, BookRecord.class);
Book book = new Book(record.id, record.isbn, record.title, record.author, new BigDecimal(record.price));
// Writing: domain object → internal DTO → JSON string
BookRecord output = new BookRecord();
output.id = book.getId();
output.isbn = book.getIsbn();
output.title = book.getTitle();
output.author = book.getAuthor();
output.price = book.getPrice().toString();
String written = mapper.writeValueAsString(output);
ObjectMapper-it käsitletakse põhjalikumalt failisisendi/-väljundi ja JSON-i töötlemise kontekstis.
record vs klass
Java record-tüübid sobivad hästi DTO-de jaoks, mis on muutumatud ja täies mahus konstruktoris initsialiseeritud:
public record BookDto(String isbn, String title, String author, BigDecimal price) {}
Tavaline klass on sobivam, kui:
- Mõned väljad on valikulised ja neid täidetakse pärast objekti loomist (nt samm-sammult täidetav vorm).
- DTO-d kasutatakse mapper-tööriista sihtobjektina, mis vajab parameetriteta konstruktorit ja setter-meetodeid.
- Väljad peavad töötlemise ajal olema muudetavad.
// A regular class where fields are set one by one
public class BookUpdateDto {
private String title;
private String author;
private BigDecimal price;
// no-arg constructor for frameworks that set fields via setters
public BookUpdateDto() {}
public void setTitle(String title) { this.title = title; }
public void setAuthor(String author) { this.author = author; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public BigDecimal getPrice() { return price; }
}
Kokkuvõtteks
| Murekoht | Domeenimudel | DTO |
|---|---|---|
| Sisaldab äriloogikat | Jah | Ei |
| Avaldab sisemisi välju | Võib | Ainult vajalike välju |
| Töötab serialiseerimistööriistadega | Sageli mitte (final väljad, valideerimine) | Jah |
| Saab muutuda sõltumatult välisest lepingust | Ei | Jah |
Nende lahus hoidmine tähendab, et sisemised ümberkorraldused - välja ümbernimetamine, valideerimise lisamine, klassi tükeldamine - ei lõhu välist liidest.