Tampilan
Kenapa penting
Di bab sebelumnya, ProductRequest menerima apa saja: nama kosong, harga negatif, stok minus. Di production, input seperti itu datang setiap hari — dari bug di aplikasi mobile, dari integrasi partner, sampai dari orang yang sengaja mencoba-coba. Dua alat menjaga API tetap waras: DTO (Data Transfer Object) yang menentukan persis data apa yang boleh masuk dan keluar, dan Bean Validation yang menolak input cacat sebelum menyentuh logika bisnis. Hampir setiap endpoint POST/PUT yang kamu tulis di tempat kerja akan memakai keduanya.
Konsep inti
Kenapa DTO: jangan ekspos domain
Objek domain (dan nanti entity JPA) memodelkan bisnis, sedangkan API adalah kontrak dengan klien. Menyatukan keduanya menimbulkan masalah nyata:
| Masalah | Contoh |
|---|---|
| Data internal bocor | costPrice, passwordHash, catatan internal ikut terserialisasi ke JSON |
| Mass assignment | Klien mengirim "id": 1 atau "status": "PAID" dan field itu ikut terisi |
| Kontrak rapuh | Mengganti nama field di domain langsung mematahkan aplikasi mobile |
| Satu bentuk untuk semua | Data untuk membuat (tanpa id) berbeda dengan data yang dikembalikan (dengan id, hitungan) |
Solusinya: DTO terpisah per arah. Request DTO hanya berisi field yang boleh diisi klien; response DTO hanya berisi field yang boleh dilihat klien.
Record sebagai DTO dan mapping manual
record cocok untuk DTO: immutable, ringkas, dan Jackson mendukungnya tanpa konfigurasi. Mapping manual cukup lewat method kecil — toDomain() di request dan factory from(...) di response. Contoh Java murni berikut bisa langsung dijalankan:
java
import java.util.List;
public class ProductMappingDemo {
// Domain: punya data internal yang tidak boleh bocor ke klien
static final class Product {
private final String sku;
private final String name;
private final long price;
private final long costPrice; // harga modal — rahasia bisnis
private int stock;
Product(String sku, String name, long price, long costPrice, int stock) {
this.sku = sku;
this.name = name;
this.price = price;
this.costPrice = costPrice;
this.stock = stock;
}
String sku() { return sku; }
String name() { return name; }
long price() { return price; }
long costPrice() { return costPrice; }
int stock() { return stock; }
}
// Request DTO: hanya field yang boleh diisi klien
record CreateProductRequest(String sku, String name, Long price, Integer stock) {
Product toDomain(long costPrice) {
return new Product(sku.trim().toUpperCase(), name.trim(), price, costPrice, stock);
}
}
// Response DTO: hanya field yang boleh dilihat klien
record ProductResponse(String sku, String name, long price, boolean available) {
static ProductResponse from(Product product) {
return new ProductResponse(product.sku(), product.name(), product.price(), product.stock() > 0);
}
}
public static void main(String[] args) {
var request = new CreateProductRequest(" kop-0250 ", "Kopi Arabika 250 g ", 85_000L, 12);
Product product = request.toDomain(61_000);
ProductResponse response = ProductResponse.from(product);
System.out.println(request);
System.out.println(response);
List<ProductResponse> page = List.of(response,
ProductResponse.from(new Product("TEH-0100", "Teh Melati", 40_000, 25_000, 0)));
page.forEach(System.out::println);
}
}Output:
text
CreateProductRequest[sku= kop-0250 , name=Kopi Arabika 250 g , price=85000, stock=12]
ProductResponse[sku=KOP-0250, name=Kopi Arabika 250 g, price=85000, available=true]
ProductResponse[sku=KOP-0250, name=Kopi Arabika 250 g, price=85000, available=true]
ProductResponse[sku=TEH-0100, name=Teh Melati, price=40000, available=false]costPrice tidak pernah keluar, dan response bebas menyajikan bentuk yang lebih berguna bagi klien (available alih-alih angka stok mentah). Mapping manual itu eksplisit, mudah di-debug, dan cukup untuk sebagian besar project.
MapStruct sekilas
Bila DTO-mu banyak dan field-nya puluhan, library MapStruct membuat kode mapping saat kompilasi dari interface seperti @Mapper(componentModel = "spring") interface ProductMapper { ProductResponse toResponse(Product p); }. Hasilnya kode Java biasa (tanpa reflection), tetapi menambah dependency dan annotation processor. Mulailah dengan mapping manual; pertimbangkan MapStruct saat mapping benar-benar menjadi beban.
Bean Validation dengan Jakarta Validation
Bean Validation adalah spesifikasi Jakarta EE (package jakarta.validation). Implementasinya Hibernate Validator, dibawa oleh starter berikut — tanpa starter ini anotasi diabaikan diam-diam:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Constraint ditulis langsung di komponen record:
java
// Potongan
import jakarta.validation.constraints.*;
public record CreateProductRequest(
@NotBlank(message = "SKU wajib diisi") String sku,
@NotBlank @Size(max = 100, message = "nama maksimal 100 karakter") String name,
@NotNull @Positive(message = "harga harus lebih dari 0") Long price,
@NotNull @PositiveOrZero Integer stock,
@Email(message = "format email tidak valid") String supplierEmail) {}Constraint yang paling sering dipakai:
| Constraint | Menolak |
|---|---|
@NotNull | null |
@NotEmpty | null, string/collection kosong |
@NotBlank | null, string kosong, string berisi whitespace saja |
@Size(min, max) | panjang string/ukuran collection di luar rentang |
@Positive, @PositiveOrZero | angka ≤ 0 / angka < 0 |
@Min, @Max | angka di luar batas |
@Email, @Pattern(regexp) | string yang tidak cocok format |
Hampir semua constraint menganggap null valid (kecuali keluarga @Not...). Jadi @Email pada field opsional berarti "boleh kosong, tapi kalau diisi harus format email". Untuk field wajib, kombinasikan dengan @NotNull atau @NotBlank.
Mengaktifkan validasi: @Valid
Anotasi di DTO baru dijalankan bila parameter controller ditandai @Valid:
java
// Potongan
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
ProductResponse created = productService.create(request); // response dari bab sebelumnya (punya id)
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}Bila ada pelanggaran, Spring melempar MethodArgumentNotValidException dan method controller tidak dipanggil. Constraint juga bisa diletakkan langsung di parameter, misalnya @PathVariable @Positive long id atau @RequestParam @Max(100) int size. Sejak Spring Framework 6.1, ini memicu method validation bawaan Spring MVC (exception-nya HandlerMethodValidationException, juga 400) tanpa perlu @Validated di class.
Objek bersarang
@Valid hanya memvalidasi objek yang ditandai. Untuk objek di dalamnya — termasuk elemen list — tambahkan @Valid lagi:
java
// Potongan
public record OrderRequest(
@NotNull @Positive Long customerId,
@NotNull @Valid AddressRequest shippingAddress,
@NotEmpty @Size(max = 50) List<@Valid OrderItemRequest> items) {}
public record AddressRequest(@NotBlank String street, @NotBlank String city,
@NotBlank @Pattern(regexp = "\\d{5}") String postalCode) {}
public record OrderItemRequest(@NotBlank String sku, @NotNull @Positive Integer quantity) {}Tanpa @Valid pada shippingAddress dan pada elemen List<@Valid ...>, kode pos kosong atau quantity nol akan lolos meskipun constraint-nya sudah ditulis.
Constraint kustom (sekilas)
Bila aturan format tidak tercakup constraint bawaan, buat anotasi sendiri plus validator-nya:
java
// Potongan — ValidSku.java
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
String message() default "SKU harus berformat AAA-0000, mis. KOP-0250";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}java
// Potongan — SkuValidator.java
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private static final Pattern SKU = Pattern.compile("[A-Z]{3}-\\d{4}");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || SKU.matcher(value).matches(); // null diurus @NotBlank
}
}Atribut message, groups, dan payload wajib ada menurut spesifikasi. Untuk pola sesederhana ini sebenarnya @Pattern cukup; constraint kustom bernilai saat logikanya lebih dari regex (mis. digit cek) atau dipakai di banyak DTO dengan pesan yang seragam.
Validasi format vs validasi bisnis
| Jenis | Contoh | Tempat |
|---|---|---|
| Format / bentuk | wajib diisi, panjang maksimal, angka positif, email | DTO (anotasi) |
| Bisnis / state | SKU belum terdaftar, stok cukup, voucher masih aktif | Service |
Validasi bisnis butuh data yang tersimpan dan harus berlaku untuk semua entry point (REST, job, consumer pesan), jadi tempatnya di service — bukan di anotasi DTO dan bukan di controller.
Respons 400 bawaan
Tanpa penanganan khusus, pelanggaran validasi dijawab Spring Boot dengan 400 Bad Request dan body generik dari endpoint /error:
json
{
"timestamp": "2026-10-23T03:15:42.120Z",
"status": 400,
"error": "Bad Request",
"path": "/api/products"
}Detail field yang salah tidak disertakan (default spring.web.error.include-binding-errors=never), sehingga klien tidak tahu apa yang harus diperbaiki. Di bab error handling kamu akan mengubahnya menjadi ProblemDetail yang memuat daftar field dan pesannya.
Contoh di dunia backend
Endpoint checkout: format divalidasi oleh DTO, aturan bisnis oleh service.
java
// Potongan — OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> place(@Valid @RequestBody OrderRequest request) {
OrderResponse order = orderService.place(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(order.id()).toUri();
return ResponseEntity.created(location).body(order);
}
}java
// Potongan — OrderService.java
@Service
public class OrderService {
private final InventoryService inventory;
private final AtomicLong sequence = new AtomicLong();
public OrderService(InventoryService inventory) {
this.inventory = inventory;
}
public OrderResponse place(OrderRequest request) {
long total = 0;
for (OrderItemRequest item : request.items()) {
// Validasi bisnis: butuh data stok terkini, tidak bisa lewat anotasi
if (inventory.available(item.sku()) < item.quantity()) {
throw new InsufficientStockException(item.sku(), item.quantity());
}
total += inventory.price(item.sku()) * item.quantity();
}
request.items().forEach(item -> inventory.reserve(item.sku(), item.quantity()));
return new OrderResponse(sequence.incrementAndGet(), request.customerId(), total, "PENDING");
}
}
public record OrderResponse(long id, long customerId, long total, String status) {}Perhatikan: OrderResponse tidak memuat alamat lengkap atau data internal inventaris, dan status ditentukan server — klien tidak bisa mengirim "status": "PAID" karena field itu memang tidak ada di OrderRequest. Uji cepat:
bash
curl -i -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
-d '{"customerId":5,"shippingAddress":{"street":"Jl. Mawar 1","city":"Bandung","postalCode":"40111"},
"items":[{"sku":"KOP-0250","quantity":0}]}'
# → HTTP/1.1 400 (quantity melanggar @Positive, service tidak dipanggil)InsufficientStockException saat ini akan berujung 500; memetakannya ke 409 Conflict atau 422 adalah bahan bab berikutnya.
Pertanyaan interview
1. Kenapa perlu DTO terpisah, bukan memakai entity/domain langsung di controller?
DTO memisahkan kontrak API dari model internal. Tanpa DTO, field sensitif bisa bocor, klien bisa mengisi field yang seharusnya dikendalikan server (mass assignment), dan setiap refactor domain berisiko mematahkan klien. Request dan response juga jarang berbentuk sama, sehingga DTO per arah membuat kontrak lebih jelas.
2. Apa beda @NotNull, @NotEmpty, dan @NotBlank?
@NotNull menolak null saja. @NotEmpty menolak null dan nilai kosong (string panjang 0, collection tanpa elemen). @NotBlank khusus teks: menolak null, string kosong, dan string berisi whitespace saja. Untuk field teks wajib seperti nama, @NotBlank biasanya pilihan yang benar.
3. Di mana sebaiknya validasi "email sudah terdaftar" dilakukan?
Di service, karena aturan itu butuh data tersimpan dan merupakan aturan bisnis. Anotasi DTO cukup memastikan formatnya email. Validasi di service juga berlaku untuk semua jalur masuk, dan di database tetap perlu unique constraint sebagai pengaman terakhir terhadap race condition.
Jebakan umum
- Lupa
spring-boot-starter-validation. Anotasi tidak dieksekusi dan semua input lolos tanpa error. - Lupa
@Validdi parameter@RequestBody, di field objek bersarang, atau di elemenList<@Valid ...>. - Tipe primitif untuk field wajib. Dengan
int stock, field yang tidak dikirim tidak pernahnull, jadi@NotNulltidak berguna dan perilakunya bergantung pada konfigurasi Jackson. PakaiInteger/Long+@NotNullagar aturannya eksplisit. - Mengira
@Emailitu ketat. Ia hanya memeriksa bentuk umum (alamat sepertiuser@localhostpun lolos); verifikasi kepemilikan email tetap butuh alur konfirmasi. - Memaksakan aturan bisnis ke anotasi (mis. validator kustom yang memanggil database untuk cek duplikat). Lambat, sulit dites, dan tetap butuh unique constraint di database. Letakkan di service.
- Mengembalikan objek domain di response "karena praktis" — data internal ikut bocor saat field baru ditambahkan.
Ringkasan
- DTO memisahkan kontrak API dari model domain: request DTO membatasi input, response DTO membatasi output.
recordideal sebagai DTO; mapping manual lewattoDomain()/from(...)sudah cukup, MapStruct untuk skala besar.- Bean Validation butuh
spring-boot-starter-validation; aktifkan dengan@Valid, dan ulangi@Validuntuk objek bersarang serta elemen list. - Kebanyakan constraint menganggap
nullvalid; kombinasikan dengan@NotNull/@NotBlankuntuk field wajib. - Validasi format di DTO, validasi bisnis (butuh data/state) di service.
- Pelanggaran validasi menghasilkan
400dengan body generik; detail field disajikan lewat error handling terpusat.