Input yang tidak divalidasi adalah pintu masuk klasik bagi data rusak dan celah keamanan: harga negatif masuk database, email tanpa domain dikirimi notifikasi, umur -1 lolos pendaftaran. Spring menjawab dengan Bean Validation — spesifikasi Java (jakarta.validation) yang menempatkan aturan sebagai anotasi langsung pada field atau record component, lalu menjalankannya otomatis di tepi aplikasi. Pada Chapter ini kita pelajari anotasi constraint yang paling sering dipakai, pengaktifannya di controller, dan bagaimana kegagalan validasi berubah menjadi respons 400 yang informatif.
Anotasi Constraint Inti #
Starter spring-boot-starter-validation membawa Hibernate Validator, implementasi referensi Bean Validation. Anotasi yang paling sering kita jumpai:
| Anotasi | Berlaku pada | Aturan |
|---|---|---|
@NotBlank | String | bukan null, bukan kosong, bukan spasi saja |
@NotNull | semua tipe referensi | bukan null |
@Size(min, max) | String, koleksi | panjang/jumlah dalam rentang |
@Email | String | format alamat surel |
@Positive / @PositiveOrZero | angka | nilai > 0 / >= 0 |
@Min / @Max | angka | batas bawah/atas |
@Past / @Future | tanggal-waktu | nilai di masa lalu/masa depan |
Anotasi ditempelkan pada DTO — berikut DTO pendaftaran lengkap, ditampilkan apa adanya untuk Spring Boot 3.4/3.5:
public record PermintaanDaftar(
@NotBlank @Size(min = 3, max = 50) String nama,
@Email @NotBlank String email,
@Positive @Max(120) int umur,
@Size(min = 8, max = 72) String kataSandi
) { }Setiap anotasi membawa pesan bawaan; pesan khusus cukup ditulis sebagai @NotBlank(message = "Nama wajib diisi"). Aturan juga bisa digabung dalam constraint composition — anotasi kustom yang merangkap beberapa anotasi bawaan untuk kebutuhan yang berulang, misalnya @KataSandiKuat.
Pengaktifan di Controller #
Validasi tidak berjalan hanya dengan menempelkan anotasi; kita harus meminta Spring menjalankannya dengan @Valid (atau @Validated pada level kelas untuk validasi parameter satuan):
@RestController
@RequestMapping("/api/pendaftaran")
public class PendaftaranController {
@PostMapping
public ResponseEntity<Akun> daftar(@Valid @RequestBody PermintaanDaftar body) {
Akun akun = service.daftar(body);
return ResponseEntity.status(HttpStatus.CREATED).body(akun);
}
}Urutan kejadiannya di balik layar: HttpMessageConverter mendeserialisasi body menjadi record, validator memeriksa seluruh constraint, dan jika ada pelanggaran, method controller tidak pernah terpanggil. Sebagai gantinya, Spring melempar MethodArgumentNotValidException yang secara bawaan menghasilkan respons 400 dengan rincian field yang gagal. Pemanggilan service dengan data tercemar tertutup rapat di tepi aplikasi.
Mensimulasikan Validator #
Validator Bean Validation bisa kita rekonstruksi dalam bentuk mini dengan Java murni: anotasi penanda dideklarasikan sendiri, dan validator membacanya lewat reflection atas field. Bentuk inilah inti mesin Hibernate Validator:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class ValidasiMini {
// Mirror Bean Validation: @NotBlank, @Size(min=), @Email, @Positive.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface TidakKosong { }
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface PanjangMin {
int nilai();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Surel { }
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Positif { }
static class FormulirPendaftaran {
@TidakKosong
@PanjangMin(nilai = 3)
String nama;
@Surel
String email;
@Positif
int umur;
FormulirPendaftaran(String nama, String email, int umur) {
this.nama = nama;
this.email = email;
this.umur = umur;
}
}
public static void main(String[] args) throws Exception {
validasi(new FormulirPendaftaran("Budi", "[email protected]", 25));
validasi(new FormulirPendaftaran("Si", "budi-example", -1));
}
static void validasi(FormulirPendaftaran formulir) throws Exception {
System.out.println("Validasi: nama='" + formulir.nama + "' email='"
+ formulir.email + "' umur=" + formulir.umur);
List<String> pelanggaran = new ArrayList<>();
for (Field f : formulir.getClass().getDeclaredFields()) {
f.setAccessible(true);
Object nilai = f.get(formulir);
if (f.isAnnotationPresent(TidakKosong.class)
&& (nilai == null || nilai.toString().isBlank())) {
pelanggaran.add(f.getName() + ": tidak boleh kosong");
}
if (f.isAnnotationPresent(PanjangMin.class) && nilai != null
&& nilai.toString().length() < f.getAnnotation(PanjangMin.class).nilai()) {
pelanggaran.add(f.getName() + ": panjang minimal "
+ f.getAnnotation(PanjangMin.class).nilai());
}
if (f.isAnnotationPresent(Surel.class)
&& (nilai == null || !nilai.toString().matches(".+@.+\\..+"))) {
pelanggaran.add(f.getName() + ": format email tidak valid");
}
if (f.isAnnotationPresent(Positif.class) && ((Integer) nilai) <= 0) {
pelanggaran.add(f.getName() + ": harus bilangan positif");
}
}
if (pelanggaran.isEmpty()) {
System.out.println(" HASIL: valid -> 200 OK");
} else {
System.out.println(" HASIL: ditolak -> 400 Bad Request");
for (String p : pelanggaran) {
System.out.println(" - " + p);
}
}
System.out.println();
}
}Output yang Dihasilkan:
Validasi: nama='Budi' email='[email protected]' umur=25
HASIL: valid -> 200 OK
Validasi: nama='Si' email='budi-example' umur=-1
HASIL: ditolak -> 400 Bad Request
- nama: panjang minimal 3
- email: format email tidak valid
- umur: harus bilangan positifMari kita bedah komponen simulasi ini:
@interface TidakKosongdan kawan-kawan: empat anotasi penanda dengan@Retention(RetentionPolicy.RUNTIME)— retensi runtime wajib agar anotasi bertahan sampai bisa dibaca reflection saat validasi berjalan.PanjangMinpunya atributnilai(), mirror dari atributminpada@Size.- Loop
getDeclaredFields(): validator memeriksa seluruh field objek satu per satu.f.isAnnotationPresent(...)menanyakan apakah constraint menempel pada field itu;f.get(formulir)mengambil nilainya untuk diuji. - Empat cabang pemeriksaan:
TidakKosongmenolak null dan string kosong-spasi (isBlank),PanjangMinmembandingkan panjang teks,Surelmencocokkan pola surel sederhana dengan regex, danPositifmenolak angka <= 0. Setiap pelanggaran masuk daftarpelanggaran— tidak berhenti pada kesalahan pertama, sama seperti Bean Validation yang mengumpulkan semua violation. - Formulir kedua: tiga pelanggaran tercetak sekaligus (
nama,email,umur) — keluaran yang persis meniru isiMethodArgumentNotValidException.getBindingResult()saat Spring menolak request yang sama.
Dari Validasi ke Respons 400 #
Secara bawaan, MethodArgumentNotValidException sudah menghasilkan 400 dengan daftar field. Untuk bentuk respons yang lebih terkontrol, kita tangkap di @RestControllerAdvice dan bentuk ulang menjadi ProblemDetail RFC 7807 — topik Chapter Exception Handling dengan Spring. Perhatikan polanya: validasi menolak di tepi, exception handling memformat penolakan, dan service tetap bersih dari kode pemeriksaan.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ValidasiMini.java
Unduh berkasimport java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class ValidasiMini {
// Mirror Bean Validation: @NotBlank, @Size(min=), @Email, @Positive.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface TidakKosong { }
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface PanjangMin {
int nilai();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Surel { }
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Positif { }
static class FormulirPendaftaran {
@TidakKosong
@PanjangMin(nilai = 3)
String nama;
@Surel
String email;
@Positif
int umur;
FormulirPendaftaran(String nama, String email, int umur) {
this.nama = nama;
this.email = email;
this.umur = umur;
}
}
public static void main(String[] args) throws Exception {
validasi(new FormulirPendaftaran("Budi", "[email protected]", 25));
validasi(new FormulirPendaftaran("Si", "budi-example", -1));
}
static void validasi(FormulirPendaftaran formulir) throws Exception {
System.out.println("Validasi: nama='" + formulir.nama + "' email='"
+ formulir.email + "' umur=" + formulir.umur);
List<String> pelanggaran = new ArrayList<>();
for (Field f : formulir.getClass().getDeclaredFields()) {
f.setAccessible(true);
Object nilai = f.get(formulir);
if (f.isAnnotationPresent(TidakKosong.class)
&& (nilai == null || nilai.toString().isBlank())) {
pelanggaran.add(f.getName() + ": tidak boleh kosong");
}
if (f.isAnnotationPresent(PanjangMin.class) && nilai != null
&& nilai.toString().length() < f.getAnnotation(PanjangMin.class).nilai()) {
pelanggaran.add(f.getName() + ": panjang minimal "
+ f.getAnnotation(PanjangMin.class).nilai());
}
if (f.isAnnotationPresent(Surel.class)
&& (nilai == null || !nilai.toString().matches(".+@.+\\..+"))) {
pelanggaran.add(f.getName() + ": format email tidak valid");
}
if (f.isAnnotationPresent(Positif.class) && ((Integer) nilai) <= 0) {
pelanggaran.add(f.getName() + ": harus bilangan positif");
}
}
if (pelanggaran.isEmpty()) {
System.out.println(" HASIL: valid -> 200 OK");
} else {
System.out.println(" HASIL: ditolak -> 400 Bad Request");
for (String p : pelanggaran) {
System.out.println(" - " + p);
}
}
System.out.println();
}
}