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:

AnotasiBerlaku padaAturan
@NotBlankStringbukan null, bukan kosong, bukan spasi saja
@NotNullsemua tipe referensibukan null
@Size(min, max)String, koleksipanjang/jumlah dalam rentang
@EmailStringformat alamat surel
@Positive / @PositiveOrZeroangkanilai > 0 / >= 0
@Min / @Maxangkabatas bawah/atas
@Past / @Futuretanggal-waktunilai di masa lalu/masa depan

Anotasi ditempelkan pada DTO — berikut DTO pendaftaran lengkap, ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
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):

java
@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:

text
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 positif

Mari kita bedah komponen simulasi ini:

  1. @interface TidakKosong dan kawan-kawan: empat anotasi penanda dengan @Retention(RetentionPolicy.RUNTIME) — retensi runtime wajib agar anotasi bertahan sampai bisa dibaca reflection saat validasi berjalan. PanjangMin punya atribut nilai(), mirror dari atribut min pada @Size.
  2. 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.
  3. Empat cabang pemeriksaan: TidakKosong menolak null dan string kosong-spasi (isBlank), PanjangMin membandingkan panjang teks, Surel mencocokkan pola surel sederhana dengan regex, dan Positif menolak angka <= 0. Setiap pelanggaran masuk daftar pelanggaran — tidak berhenti pada kesalahan pertama, sama seperti Bean Validation yang mengumpulkan semua violation.
  4. Formulir kedua: tiga pelanggaran tercetak sekaligus (nama, email, umur) — keluaran yang persis meniru isi MethodArgumentNotValidException.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 berkas
java
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();
    }
}