Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~46 دقیقه مطالعه~40 min read
اعتبارسنجی (Bean Validation) و MapStructBean Validation & MapStruct
از صفر تا سنیور با اعتبارسنجی: چطور با اَنوتیشنهای Jakarta Bean Validation ورودیِ کثیف را دمِ در بگیری، اعتبارسنجیِ سفارشی و گروهی و سطحِ متد بنویسی، و چرا MapStruct که مَپِرها را در زمانِ کامپایل میسازد از مَپِرهای مبتنی بر reflection هم سریعتر است و هم امنتر.A zero-to-senior tour of validation: how Jakarta Bean Validation annotations catch dirty input at the door, how to write custom, grouped, and method-level constraints, and why MapStruct — which generates mappers at compile time — is both faster and safer than reflection-based mappers.
سلام. این فصل دربارهی دو دردی است که هر توسعهدهندهای در هر برنامهی جدیِ بکاند با آنها روبهرو میشود، اما کمتر کسی دربارهشان درست حرف میزند. درد اول: دادهی ورودیِ کثیف. کاربر یک فرمِ ثبتنام پر میکند و ایمیل را جا میگذارد، سنش را -۳ مینویسد، یا رمزی میفرستد که سه حرف است. اگر این دادهی خراب را همینطور به دلِ منطقِ برنامه و دیتابیس راه بدهی، فاجعه از همانجا شروع میشود. درد دوم: کدِ چسبیِ تبدیل. یک شیء از دیتابیس میآوری (Entity)، اما نمیخواهی همان را عیناً به بیرون بدهی؛ یک شیءِ دیگر (DTO) میخواهی. پس مینشینی و دهها خط dto.setName(entity.getName()) مینویسی — کدی که هیچ منطقی ندارد، فقط داده را از یک جعبه به جعبهی دیگر میریزد و در همین ریختن پر از فرصتِ خطاست.
جاوا برای هر دو درد یک درمانِ استاندارد و بالغ دارد: Jakarta Bean Validation برای دردِ اول، و MapStruct برای دردِ دوم. در این فصل قرار نیست فهرستی از اَنوتیشنها را حفظ کنی؛ قرار است بفهمی هر کدام چه میکند، کجای معماری باید بنشیند، و مهمتر از همه — چرا MapStruct که مَپِرها را در زمانِ کامپایل میسازد از رقیبانی که در زمانِ اجرا با reflection کار میکنند هم سریعتر است و هم امنتر.
با هم این مسیر را میرویم:
- دو درد، دو درمان — چرا اعتبارسنجی و مَپینگ دو رویِ یک سکهاند (لبهی برنامه).
- Bean Validation چیست — استاندارد در برابرِ پیادهسازی (Jakarta Validation در برابرِ Hibernate Validator)، و وصلکردنش.
- اَنوتیشنهای پایه —
@NotNull،@NotBlank،@Size،@Pattern،@Email،@Min/@Maxو خانوادهشان. @Validو آبشار (cascading) — چطور اعتبارسنجی به اشیاءِ تودرتو سرایت میکند.- گروههای اعتبارسنجی (groups) — یک شیء، قانونهای متفاوت در موقعیتهای متفاوت.
- اعتبارسنجیِ سفارشی — نوشتنِ
ConstraintValidatorخودت از صفر. - اعتبارسنجیِ سطحِ متد — روی پارامترها و مقدارِ بازگشتی.
- کجای لایهها؟ — اعتبارسنجی دقیقاً باید کجا بنشیند.
- MapStruct — مَپِرهای زمانِ کامپایل در برابرِ reflection، و چرا این تفاوت همهچیز است.
- پرسشهای مصاحبه با پاسخِ کامل، و یک جمعبندی.
بخش صفر — چند واژه که پیش از شروع باید حسشان کنی
قبل از هر کد، چند اصطلاح هست که در کلِ فصل برمیگردند. بگذار همین حالا جا بیندازمشان.
- اعتبارسنجی (validation): بررسیِ اینکه داده «معتبر» است یا نه — یعنی با قانونهایی که ما تعریف کردهایم میخواند. مثلاً «ایمیل باید شکلِ ایمیل داشته باشد» یا «سن نمیتواند منفی باشد».
- قید (constraint): یک قانونِ مشخصِ اعتبارسنجی که به شکلِ یک اَنوتیشن روی فیلد یا پارامتر مینشیند، مثل
@NotNullیا@Size(min=8). هر قید یک برچسبِ قانون است. - DTO: مخففِ Data Transfer Object، یعنی «شیءِ انتقالِ داده». شیئی که فقط برای جابهجاییِ داده بین لایهها ساخته میشود — مثلاً بینِ کنترلر و کلاینت. با Entity (شیئی که مستقیم به جدولِ دیتابیس نگاشته میشود) فرق دارد.
- مَپِر (mapper): کدی که یک شیء (مثلاً
Entity) را به شیءِ دیگری (مثلاًDTO) تبدیل میکند، فیلد به فیلد. - reflection: توانایی جاوا برای بررسی و دستکاریِ کلاسها و فیلدها در زمانِ اجرا، بدونِ اینکه در زمانِ کامپایل نامشان را بدانی. قدرتمند اما کُند و ناامن؛ در ادامه میبینیم چرا این کلمه در بحثِ MapStruct کلیدی است.
تصور کن یک باشگاهِ شبانه داری. اگر بگذاری هر کسی از هر دری وارد شود و بعد وسطِ سالن بفهمی که این آدم بلیط ندارد یا زیرِ سن است، کار خراب شده — باید وسطِ شلوغی دنبالش بگردی و بیرونش کنی. کارِ درست این است که یک نگهبانِ قوی دمِ در بگذاری: هر کس میخواهد وارد شود، همانجا بلیط و کارتِ شناساییاش چک میشود. اگر مشکلی هست، همانجا و با یک پیامِ روشن برمیگردد، و هرگز پایش به داخل نمیرسد. Bean Validation همان نگهبانِ دمِ در است: دادهی نامعتبر را در لبهی برنامه میگیرد، پیش از آنکه به منطق و دیتابیس آلوده شود.
Bean Validation چیست؟ استاندارد در برابرِ پیادهسازی
اینجا اولین نکتهای است که سنیورها را از تازهکارها جدا میکند. وقتی میگوییم «Bean Validation»، از یک استاندارد حرف میزنیم، نه یک کتابخانهی مشخص. جاوا یک مشخصات (specification) رسمی دارد به اسم Jakarta Validation (که قبلاً زیرِ نامِ javax.validation و با شمارهی JSR 380 شناخته میشد و حالا بعد از انتقالِ Java EE به بنیادِ Eclipse شده Jakarta). این استاندارد فقط قرارداد را تعریف میکند: اَنوتیشنهایی مثل @NotNull، و اینترفیسهایی مثل Validator. اما خودش هیچ کدِ اجرایی ندارد.
کدِ واقعیای که این قرارداد را پیاده میکند، یک پیادهسازی (implementation) است، و مشهورترین و مرجعترینش Hibernate Validator است (که ربطی به دیتابیسِ Hibernate ندارد، فقط همخانواده است).
دو چیز را با هم قاطی نکن:
- Jakarta Validation استاندارد است؛ نسخهی پایدارِ فعلی 3.1 است (نسخهی دقیقِ artifact: 3.1.1) و بخشی از Jakarta EE 11 است.
- Hibernate Validator پیادهسازیِ مرجع است؛ نسخهی پایدارِ فعلی سری 9.1 است (مثلاً 9.1.2.Final) و حداقل به جاوا ۱۷ نیاز دارد.
نکتهی مهمِ تاریخی: با انتقال از Java EE به Jakarta EE، نامِ بستهها از javax.validation.* به jakarta.validation.* تغییر کرد. اگر در پروژهای قدیمی import javax.validation.constraints.NotNull میبینی، آن نسخهی قدیمی است؛ در پروژههای امروزی همهچیز jakarta. است. این تغییرِ نام یکی از رایجترین منابعِ گیجیِ مهاجرت است.
برای وصلکردن در یک پروژهی Maven (خارج از Spring Boot):
<!-- خودِ استاندارد (API) -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.1.1</version>
</dependency>
<!-- پیادهسازیِ مرجع -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.2.Final</version>
</dependency>
<!-- پیادهسازیِ Expression Language، برای درستشدنِ پیامهای خطا -->
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>6.0.0</version>
</dependency>
اگر با Spring Boot کار میکنی، اینهمه دنگوفنگ لازم نیست؛ فقط spring-boot-starter-validation را اضافه کن و هر سه وابستگیِ بالا (بهعلاوهی نسخههای هماهنگ) خودکار میآیند. آن EL که اضافه کردیم به این خاطر است که پیامهای خطا میتوانند از عبارتهای پویا استفاده کنند (مثل درجکردنِ مقدارِ min داخلِ متن)؛ بدونِ آن، در محیطِ Java SE پیامها خام میمانند یا خطا میدهند.
اَنوتیشنهای پایه: قانونها را روی فیلد بچسبان
هستهی Bean Validation این است که قانونها را همانجا که داده تعریف میشود، یعنی روی خودِ فیلد، بچسبانی. بیا یک DTO ثبتنام بسازیم:
import jakarta.validation.constraints.*;
import java.time.LocalDate;
public class RegistrationRequest {
@NotBlank(message = "نام کاربری الزامی است")
@Size(min = 3, max = 20, message = "نام کاربری باید بین ۳ تا ۲۰ کاراکتر باشد")
private String username;
@NotBlank
@Email(message = "قالب ایمیل معتبر نیست")
private String email;
@NotBlank
@Size(min = 8, message = "رمز عبور حداقل ۸ کاراکتر")
@Pattern(regexp = ".*\\d.*", message = "رمز عبور باید حداقل یک رقم داشته باشد")
private String password;
@NotNull
@Min(value = 18, message = "سن باید حداقل ۱۸ باشد")
@Max(150)
private Integer age;
@Past(message = "تاریخ تولد باید در گذشته باشد")
private LocalDate birthDate;
// getter/setter ...
}
هر خط را بخوان: قانون کنارِ داده نشسته، خواناست، و هیچجای دیگری از کد نیست که «یادت برود» چکش کنی. حالا مهمترین تمایز که خیلیها اشتباه میکنند:
@NotNullفقط میگوید مقدارnullنباشد. رشتهی خالیِ""از نظرش کاملاً معتبر است.@NotEmptyمیگویدnullنباشد و طولش صفر نباشد. رویString،Collection،Mapو آرایه کار میکند. پس""را رد میکند، اما" "(فقط فاصله) را میپذیرد.@NotBlankفقط برایStringاست و میگویدnullنباشد و بعد ازtrim(حذفِ فاصلهها) خالی نباشد. پس" "را هم رد میکند.
قاعدهی عملی: برای رشتههای متنی که کاربر پر میکند (نام، عنوان)، @NotBlank را بخواه؛ برای مجموعهها @NotEmpty؛ و @NotNull را برای هر چیزِ غیر-رشتهای که فقط نباید نبود (مثل یک عدد یا تاریخ).
فهرستِ پرکاربردترین قیدهای آماده:
| قید | روی چه نوعی | چه چیزی را تضمین میکند |
|---|---|---|
@NotNull |
هر نوع | null نباشد |
@NotEmpty |
String/Collection/Map/Array | null نباشد و طولش > 0 |
@NotBlank |
String | بعد از trim خالی نباشد |
@Size(min,max) |
String/Collection/Map/Array | طول در بازه باشد |
@Min / @Max |
اعداد صحیح | حداقل/حداکثرِ مقدار |
@DecimalMin / @DecimalMax |
اعداد اعشاری | حداقل/حداکثر با دقتِ اعشاری |
@Positive / @Negative |
اعداد | مثبت/منفیِ اکید |
@Digits(integer,fraction) |
اعداد | تعدادِ رقمهای صحیح و اعشار |
@Email |
String | قالبِ ایمیل |
@Pattern(regexp) |
String | مطابقتِ regex دلخواه |
@Past / @Future |
تاریخ/زمان | در گذشته/آینده |
@AssertTrue / @AssertFalse |
boolean | مقدارِ true/false |
اما این اَنوتیشنها بهتنهایی هیچکاری نمیکنند — فقط برچسباند. کسی باید آنها را بخواند و اجرا کند. آن کس یا خودِ فریمورک است (Spring آن را خودکار انجام میدهد) یا خودت با یک Validator:
import jakarta.validation.*;
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
RegistrationRequest req = new RegistrationRequest();
req.setUsername("ab"); // خیلی کوتاه
req.setEmail("not-an-email"); // بیقالب
Set<ConstraintViolation<RegistrationRequest>> violations = validator.validate(req);
for (ConstraintViolation<RegistrationRequest> v : violations) {
System.out.println(v.getPropertyPath() + " : " + v.getMessage());
// username : نام کاربری باید بین ۳ تا ۲۰ کاراکتر باشد
// email : قالب ایمیل معتبر نیست
}
validator.validate(...) یک Set از نقضها (violations) برمیگرداند. اگر خالی باشد، یعنی همهچیز معتبر است. هر نقض به تو میگوید کدام فیلد و چرا رد شده.
در یک کنترلرِ Spring، لازم نیست خودت Validator بسازی. کافی است روی پارامتر @Valid بگذاری:
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegistrationRequest req) {
// اگر req نامعتبر باشد، این متد اصلاً اجرا نمیشود؛
// Spring یک MethodArgumentNotValidException پرتاب میکند که
// بهطور پیشفرض به پاسخِ 400 Bad Request تبدیل میشود.
return ResponseEntity.ok(userService.register(req));
}
این یعنی نگهبانِ دمِ در خودکار فعال است و بدنهی متد فقط وقتی اجرا میشود که داده تمیز باشد.
@Valid و آبشار: اعتبارسنجیِ اشیاءِ تودرتو
تا حالا فیلدهای ساده را دیدیم. اما اگر یک شیء، شیءِ دیگری را در دلش داشته باشد چه؟ مثلاً یک Order که یک Address دارد. بهطور پیشفرض، اعتبارسنجی آبشاری نیست — یعنی اگر Order را validate کنی، Bean Validation به داخلِ Address سرک نمیکشد و قیدهای آن را نادیده میگیرد. برای اینکه اعتبارسنجی به شیءِ تودرتو سرایت کند، باید روی آن فیلد @Valid بگذاری:
public class Order {
@NotNull
private String orderId;
@NotNull
@Valid // ← این کلید است
private Address shippingAddress; // بدون @Valid، قیدهای داخلِ Address چک نمیشوند
@NotEmpty
@Valid // روی مجموعه هم کار میکند: هر عضو validate میشود
private List<OrderLine> lines;
}
public class Address {
@NotBlank private String street;
@NotBlank private String city;
@Pattern(regexp = "\\d{10}") private String postalCode;
}
تصور کن یک کامیونِ محموله در مرز است. بازرسِ گمرک اول کاغذهای خودِ کامیون را چک میکند (پلاک، راننده). اما اگر بگویی «فقط کاغذِ کامیون کافی است»، هرگز نمیفهمی داخلِ جعبهها چیست. @Valid روی یک فیلد مثل دستورِ «این جعبه را هم باز کن و محتوایش را بازرسی کن» است. و اگر داخلِ جعبه، جعبهی کوچکترِ دیگری باشد، باز باید روی آن هم @Valid بزنی تا بازرسی تا تهِ زنجیره برود. آبشار فقط تا جایی میرود که تو با @Valid علامت زدهای.
نکتهی ظریف: مسیرِ خطا (property path) هم تودرتو میشود. اگر postalCode داخلِ shippingAddress نامعتبر باشد، مسیر میشود shippingAddress.postalCode — که برای ساختنِ پیامهای خطای دقیق برای کاربر عالی است.
گروههای اعتبارسنجی: یک شیء، قانونهای متفاوت
اینجا یک مشکلِ واقعیِ دنیای حرفهای است. فرض کن یک User داری. هنگامِ ساختِ کاربر، id باید null باشد (چون هنوز ساخته نشده) و رمز الزامی است. هنگامِ بهروزرسانی، id باید وجود داشته باشد و رمز اختیاری است. همان کلاس، اما دو مجموعهی قانونِ متفاوت. اگر همهی قیدها را بیقیدوشرط بگذاری، نمیتوانی این دو حالت را از هم جدا کنی.
راهحل: گروههای اعتبارسنجی. یک گروه فقط یک اینترفیسِ خالیِ نشانهای (marker) است:
public interface OnCreate {}
public interface OnUpdate {}
حالا هر قید را به گروه (یا گروههایش) نسبت میدهی:
public class UserDto {
@Null(groups = OnCreate.class) // هنگام ساخت باید null باشد
@NotNull(groups = OnUpdate.class) // هنگام آپدیت باید مقدار داشته باشد
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String username;
@NotBlank(groups = OnCreate.class) // فقط هنگام ساخت الزامی است
@Size(min = 8, groups = {OnCreate.class, OnUpdate.class})
private String password;
}
و هنگامِ اعتبارسنجی، تعیین میکنی کدام گروه اجرا شود:
// فقط قیدهای گروه OnCreate را چک کن
Set<ConstraintViolation<UserDto>> v = validator.validate(dto, OnCreate.class);
در Spring هم بهجای @Valid از @Validated استفاده میکنی که میتواند گروه بگیرد:
@PostMapping("/users")
public User create(@Validated(OnCreate.class) @RequestBody UserDto dto) { ... }
@PutMapping("/users/{id}")
public User update(@Validated(OnUpdate.class) @RequestBody UserDto dto) { ... }
@Validاز خودِ استانداردِ Jakarta است (jakarta.validation.Valid) و گروه نمیگیرد؛ فقط اعتبارسنجی/آبشار را روشن میکند.@Validatedمالِ Spring است (org.springframework.validation.annotation.Validated) و میتواند گروه بگیرد، و برای اعتبارسنجیِ سطحِ متد هم لازم است.
نکتهی مهم: برای آبشارِ تودرتو (سرایت به فیلدهای داخلی) همیشه باید @Valid روی خودِ فیلد باشد؛ @Validated این کار را نمیکند. پس در عمل اغلب هر دو را میبینی: @Validated روی متد/کلاس برای گروه، و @Valid روی فیلدهای تودرتو برای آبشار.
گروهها ترتیب هم میتوانند داشته باشند: با @GroupSequence میگویی «اول این گروه را چک کن؛ فقط اگر پاس شد، سراغِ گروهِ بعدی برو». این برای اعتبارسنجیِ مرحلهای مفید است — اول ساختار، بعد قوانینِ گرانقیمتِ تجاری.
اعتبارسنجیِ سفارشی: ConstraintValidator خودت را بنویس
اَنوتیشنهای آماده عالیاند، اما دیر یا زود قانونی میخواهی که هیچکدام پوششش نمیدهند: «نام کاربری فقط حروف و اعداد و زیرخط»، «کدِ ملیِ ایران معتبر باشد»، «این ایمیل قبلاً در دیتابیس ثبت نشده باشد». اینجاست که قیدِ سفارشیِ خودت را میسازی. کارِ آن دو تکه است:
تکهی ۱ — خودِ اَنوتیشن. یک اَنوتیشن تعریف میکنی و با @Constraint به آن میگویی کدام کلاس منطقِ چکش را دارد:
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = UsernameValidator.class) // ← کلاسِ منطق
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidUsername {
String message() default "نام کاربری فقط میتواند شامل حروف، عدد و زیرخط باشد";
Class<?>[] groups() default {}; // برای پشتیبانی از گروهها — الزامی
Class<? extends Payload>[] payload() default {}; // متادیتای اضافی — الزامی
}
هر اَنوتیشنِ اعتبارسنجیِ سفارشی باید این سه عضو را داشته باشد وگرنه Bean Validation آن را نمیپذیرد:
message()— پیامِ پیشفرضِ خطا.groups()— تا با سیستمِ گروهها سازگار باشد.payload()— برای حملِ متادیتای دلخواه (مثلاً سطحِ شدت). اغلب خالی میماند اما باید باشد.
این سه عضو قراردادِ استاندارد است؛ اگر یکی را جا بیندازی، خطای زمانِ راهاندازی میگیری.
تکهی ۲ — منطقِ اعتبارسنجی. کلاسی که ConstraintValidator<A, T> را پیاده میکند: A نوعِ اَنوتیشن است و T نوعِ دادهای که چک میشود.
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class UsernameValidator implements ConstraintValidator<ValidUsername, String> {
private static final java.util.regex.Pattern PATTERN =
java.util.regex.Pattern.compile("^[a-zA-Z0-9_]+$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// قرارداد مهم: null را «معتبر» بشمار و کارِ nullبودن را به @NotNull بسپار
if (value == null) {
return true;
}
return PATTERN.matcher(value).matches();
}
}
استفاده حالا تمیز است:
public class SignupForm {
@NotBlank
@ValidUsername // قیدِ سفارشیِ ما، دقیقاً مثل قیدهای آماده
private String username;
}
یک اصلِ طلاییِ طراحیِ قید: هر قید فقط یک نگرانی داشته باشد. نگرانیِ «نبودن مقدار» کارِ @NotNull/@NotBlank است، نه کارِ @ValidUsername. اگر ولیدیتورت روی null مقدارِ false برگرداند، آنگاه نمیتوانی یک فیلدِ اختیاری بسازی که «اگر پر شد، باید قالبِ درست داشته باشد ولی خالیبودنش هم اشکالی ندارد». با برگرداندنِ true برای null، قیدهایت قابلِ ترکیب میمانند. تقریباً همهی قیدهای آمادهی خودِ استاندارد هم همین کار را میکنند (بهجز خودِ @NotNull).
اگر بخواهی برای اعتبارسنجی به دیتابیس دسترسی داشته باشی (مثلاً «ایمیل تکراری نباشد»)، چون ConstraintValidator در Spring یک bean است، میتوانی وابستگی تزریق کنی:
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository repository;
public UniqueEmailValidator(UserRepository repository) { // constructor injection
this.repository = repository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
if (email == null) return true;
return !repository.existsByEmail(email);
}
}
قیدهایی که به دیتابیس میزنند (مثل یکتاییِ ایمیل) وسوسهانگیزند اما دو دام دارند: (۱) شرایطِ رقابتی (race condition) — بین لحظهی چکِ «ایمیل نیست» و لحظهی INSERT، ممکن است کاربرِ دیگری همان ایمیل را ثبت کند؛ پس این چک هرگز جایگزینِ یک unique constraint در خودِ دیتابیس نمیشود، فقط برای پیامِ خطای بهتر است. (۲) کارایی — هر اعتبارسنجی یک کوئریِ اضافه میزند. اینها را در جای درست (لایهی سرویس) و آگاهانه بهکار ببر.
اعتبارسنجیِ سطحِ متد
تا حالا فیلدهای یک شیء را دیدیم. اما Bean Validation قدرتِ دیگری هم دارد: میتواند مستقیم روی پارامترهای متد و مقدارِ بازگشتی قید بگذارد. این یعنی میتوانی قراردادِ یک متد را همانجا در امضایش اعلام کنی:
import jakarta.validation.constraints.*;
import org.springframework.validation.annotation.Validated;
@Service
@Validated // ← بدونِ این، قیدهای سطحِ متد در Spring نادیده گرفته میشوند
public class AccountService {
public Account findById(@NotNull @Positive Long id) {
// اگر id null یا <= 0 باشد، یک ConstraintViolationException
// پیش از ورود به بدنهی متد پرتاب میشود.
return repository.findById(id).orElseThrow();
}
public @NotNull Account create(@Valid AccountForm form) {
// @Valid روی پارامترِ شیء = آبشار به داخلِ form
// @NotNull روی مقدارِ بازگشتی = تضمینِ خروجیِ غیرِ null
return repository.save(new Account(form));
}
}
چرا @Validated روی کلاس لازم است؟ چون Spring اعتبارسنجیِ سطحِ متد را با یک پروکسی (AOP proxy) پیاده میکند: بهجای شیءِ اصلی، یک لایهی میانی میگذارد که قبل و بعدِ فراخوانیِ متد قیدها را چک میکند. دو پیامدِ عملیِ مهم:
- فراخوانیِ درونی (self-invocation) کار نمیکند. اگر یک متدِ همان کلاس، متدِ دیگری از خودش را مستقیم صدا بزند، از پروکسی رد نمیشود و اعتبارسنجی اجرا نمیشود.
- خطای اینجا
ConstraintViolationExceptionاست، نهMethodArgumentNotValidExceptionی که کنترلرها میدهند؛ پس اگر میخواهی پاسخِ HTTP خوب بدهی، باید این استثنا را جداگانه در@ControllerAdviceمدیریت کنی.
کجای لایهها اعتبارسنجی کنیم؟
این یکی از پرسشهای محبوبِ مصاحبه است، و پاسخِ سطحی («در کنترلر») ناقص است. پاسخِ سنیور: اعتبارسنجی یک لایه نیست، چند نوع است، و هر نوع جای خودش را دارد.
- اعتبارسنجیِ نحوی/قالبی (syntactic) — «ایمیل شکلِ ایمیل دارد؟ رمز ۸ کاراکتر هست؟». اینها را در لبهی برنامه، روی DTOِ ورودی و با اَنوتیشنهای Bean Validation بگیر. هرچه زودتر، بهتر — پیش از آنکه دادهی خراب واردِ سیستم شود.
- اعتبارسنجیِ منطقِ تجاری (business rules) — «آیا این کاربر اجازهی این خرید را دارد؟ آیا موجودی کافی است؟». اینها با یک اَنوتیشن روی فیلد قابلِ بیان نیستند؛ به context و چند شیء نیاز دارند. جای درستشان لایهی سرویس (دامین) است.
- اعتبارسنجیِ یکپارچگیِ داده (integrity) — «کلیدِ یکتا، not-null در سطحِ ستون». این آخرین خطِ دفاع است و باید در خودِ دیتابیس (constraintهای SQL) باشد، چون تنها جایی است که در برابرِ شرایطِ رقابتی واقعاً تضمین میدهد.
جمعبندیِ ذهنی: Bean Validation نگهبانِ دمِ در است (قالب و ساختار)؛ منطقِ تجاری داخلِ ساختمان بررسی میشود؛ و دیتابیس گاوصندوقِ آخر است. هیچکدام جایگزینِ دیگری نیست.
حالا که ورودی تمیز شد، میرسیم به دردِ دوم: تبدیلِ این اشیاءِ تمیز بینِ لایهها.
دردِ دوم: چرا اصلاً DTO و مَپینگ؟
سوالِ منصفانه: چرا همان Entity را مستقیم به کلاینت ندهیم و خودمان را از این تبدیل خلاص کنیم؟ چند دلیلِ جدی:
- امنیت.
Entityکاربر ممکن است فیلدِpasswordHashیاisAdminداشته باشد؛ نمیخواهی اشتباهاً آن را در پاسخِ JSON لو بدهی. DTO فقط چیزی را نشان میدهد که باید. - جداسازیِ لایهها. اگر کلاینت مستقیم به
Entityوصل باشد، هر تغییرِ کوچکِ ساختارِ دیتابیس، API عمومیات را میشکند. DTO یک ضربهگیر بینِ مدلِ داخلی و قراردادِ بیرونی است. - شکلِ متفاوت. گاهی خروجی باید ترکیبی از چند entity یا با نامها و ساختارِ متفاوت باشد. مثلاً
firstNameوlastNameدر entity، اماfullNameدر DTO. - مشکلاتِ سریالسازیِ lazy. دادنِ مستقیمِ entity به JSON اغلب به
LazyInitializationExceptionیا کوئریهای ناخواسته میرسد (رابطههای lazy را وسطِ سریالسازی میکشد).
پس DTO لازم است. اما تبدیلِ دستی خستهکننده و خطاخیز است:
// مَپینگِ دستی — منطقی ندارد، فقط داده جابهجا میکند و پر از فرصتِ خطاست
public UserDto toDto(User user) {
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
// ... و اگر فیلدِ جدیدی به User اضافه شد، «یادت برود» اینجا هم اضافه کنی → باگِ خاموش
return dto;
}
تصور کن دو اتاق داری: اتاقِ داخلی (تیمِ مهندسی، زبانِ فنی، جزئیاتِ خام) و اتاقِ بیرونی (مشتری، زبانِ ساده، فقط چیزهای لازم). یک مترجم بینشان مینشیند و پیامها را برمیگرداند. اگر خودت هر جمله را دستی ترجمه کنی، هم خسته میشوی و هم دیر یا زود یک جمله را جا میاندازی. MapStruct آن مترجمی است که یک بار به او میگویی «این کلمه به آن کلمه»، و او یک دفترچهی ترجمهی کامل و بیغلط (کدِ جاوای واقعی) برایت میسازد — پیش از اینکه برنامه حتی اجرا شود.
MapStruct: مَپِر در زمانِ کامپایل
MapStruct یک پردازشگرِ اَنوتیشن (annotation processor) است. تو فقط یک اینترفیس تعریف میکنی و میگویی «از این نوع به آن نوع»، و MapStruct در زمانِ کامپایل یک کلاسِ پیادهسازیِ واقعیِ جاوا برایت تولید میکند که همان set/getهای دستی را دارد — اما تو ننوشتهای، پس نمیتوانی خرابش کنی.
وصلکردن در Maven:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
و مهمتر، پردازشگر را به کامپایلر معرفی میکنی:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
نسخهی پایدارِ MapStruct 1.6.3 است (نوامبر ۲۰۲۴). سری بعدی، 1.7.0 (فعلاً در فازِ Beta2، اواسطِ ۲۰۲۶)، یک قابلیتِ بزرگ میآورد: پشتیبانیِ بومی از Optional — یعنی میتوانی Optional<T> را بهعنوانِ نوعِ مبدأ یا مقصد داشته باشی و MapStruct خودش حضور/غیابِ مقدار را مدیریت میکند (بهجای null-check). همچنین پشتیبانیِ nullness به سبکِ JSpecify اضافه شده. برای پروژهی پروداکشن فعلاً 1.6.3 امنترین انتخاب است.
حالا یک مَپِر بنویسیم. فقط یک اینترفیس با اَنوتیشنِ @Mapper:
import org.mapstruct.*;
@Mapper(componentModel = "spring") // تا بهعنوان یک Spring bean قابلِ تزریق باشد
public interface UserMapper {
UserDto toDto(User user); // MapStruct پیادهسازیاش را میسازد
User toEntity(UserDto dto); // جهتِ عکس هم همینطور
List<UserDto> toDtoList(List<User> users); // مجموعهها هم خودکار
}
همین. برای فیلدهایی که نامشان یکی است (username به username)، MapStruct خودش نگاشت را کشف میکند. کدی که تولید میشود چیزی شبیهِ این است — یک کلاسِ کاملاً معمولی، خوانا و بدونِ reflection:
// کدِ تولیدشده توسط MapStruct (خلاصهشده) — تو این را نمینویسی، اما میتوانی ببینیاش
@Component
public class UserMapperImpl implements UserMapper {
@Override
public UserDto toDto(User user) {
if (user == null) return null;
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
return dto;
}
// ...
}
به این کد دقت کن: دقیقاً همان چیزی است که خودت دستی مینوشتی، اما بیغلط و خودکار و همیشه هماهنگ با فیلدها.
وقتی نامها یکی نیستند: @Mapping
اگر نامِ فیلدها فرق کند یا بخواهی چند فیلد را ترکیب کنی، با @Mapping راهنمایی میکنی:
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "username", target = "login") // نامِ متفاوت
@Mapping(target = "fullName", expression = "java(user.getFirstName() + \" \" + user.getLastName())")
@Mapping(target = "password", ignore = true) // این را اصلاً map نکن
@Mapping(source = "createdAt", target = "registeredOn", dateFormat = "yyyy-MM-dd")
UserDto toDto(User user);
}
بهطور پیشفرض اگر فیلدی در مقصد map نشود، MapStruct فقط یک هشدار میدهد که راحت گم میشود. یک تنظیمِ طلایی این است که آن را به خطا تبدیل کنی تا هیچ فیلدی «اتفاقی» جا نماند:
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserMapper { ... }
حالا اگر فردا فیلدی به UserDto اضافه کنی و نگاشتش را فراموش کنی، کامپایل نمیشود — باگ را در زمانِ کامپایل گرفتی، نه در پروداکشن. این دقیقاً همان «امنتر» بودنِ MapStruct است.
بهروزرسانیِ یک شیءِ موجود و مَپِرهای تودرتو
// بهجای ساختنِ شیءِ جدید، فیلدهای یک شیءِ موجود را بهروز کن
@Mapping(target = "id", ignore = true)
void updateEntityFromDto(UserDto dto, @MappingTarget User entity);
// استفاده از مَپِرهای دیگر برای فیلدهای تودرتو
@Mapper(componentModel = "spring", uses = { AddressMapper.class })
public interface OrderMapper {
OrderDto toDto(Order order); // AddressMapper خودکار برای فیلدِ address استفاده میشود
}
@MappingTarget میگوید «شیءِ جدید نساز، این یکی را پر کن» — عالی برای عملیاتِ آپدیت. و uses = {...} به MapStruct میگوید برای انواعِ تودرتو از مَپِرهای دیگر کمک بگیرد، تا مجبور نشوی همهچیز را یکجا بنویسی.
چرا زمانِ کامپایل بهتر از reflection است؟
اینجا قلبِ فصل است. رقیبانِ قدیمیترِ MapStruct (مثل کتابخانههایی که در زمانِ اجرا با reflection کار میکنند) شیءِ مبدأ را در زمانِ اجرا بررسی میکنند: با reflection فیلدها را پیدا میکنند، نامشان را تطبیق میدهند، و مقدار را کپی میکنند. این کار جواب میدهد، اما سه هزینهی جدی دارد که MapStruct با تولیدِ کدِ زمانِ کامپایل، همه را حذف میکند.
| جنبه | MapStruct (زمانِ کامپایل) | مَپِرِ مبتنی بر reflection (زمانِ اجرا) |
|---|---|---|
| کِی نگاشت حل میشود | یک بار، هنگامِ کامپایل | هر بار، هنگامِ هر فراخوانی |
| سرعتِ اجرا | مثلِ کدِ دستی (get/set مستقیم) |
کندتر (reflection سربار دارد) |
| خطاها کِی پیدا میشوند | زمانِ کامپایل (فیلدِ ناموجود = خطای build) | زمانِ اجرا (شاید در پروداکشن بترکد) |
| دیباگپذیری | کدِ تولیدشده را میتوانی بخوانی و در آن step بزنی | جعبهی سیاه؛ منطق داخلِ کتابخانه است |
| وابستگیِ زمانِ اجرا | تقریباً صفر (کدِ سادهی جاوا) | خودِ کتابخانهی reflection همیشه لازم است |
| سازگاری با کامپایلِ Native (GraalVM) | عالی (کدِ ایستا، بدونِ reflection) | دردسرساز (reflection نیاز به پیکربندیِ خاص دارد) |
سریعتر، چون کارِ سختِ «کدام فیلد به کدام فیلد» یک بار در زمانِ کامپایل انجام میشود و کدِ نهایی فقط get/setهای مستقیم است — بدونِ هیچ reflectionی در زمانِ اجرا؛ در حالیکه مَپِرِ reflection این تطبیق را در هر فراخوانی دوباره انجام میدهد. امنتر، چون اگر نگاشتی اشتباه یا ناقص باشد، MapStruct در زمانِ build خطا میدهد و برنامه اصلاً کامپایل نمیشود؛ اما مَپِرِ reflection همان اشتباه را تا زمانِ اجرا پنهان نگه میدارد و شاید سرِ کاربرِ واقعی در پروداکشن بترکد. خلاصه: MapStruct خطا را از «شبِ پروداکشن» به «صبحِ کامپایل» جابهجا میکند.
اگر فصلِ Lombok را خواندهای، این الگو آشنا میآید: هر دو annotation processorاند و در زمانِ کامپایل کد میسازند. اما یک تفاوتِ ظریفِ فنی هست: Lombok به APIهای داخلیِ کامپایلر دست میبرد و AST را دستکاری میکند (کارِ غیررسمی)، در حالیکه MapStruct از API رسمیِ Annotation Processing استفاده میکند و فایلهای جدید (کلاسهای ...Impl) میسازد — که کارِ کاملاً استاندارد و پایدار است. برای همین MapStruct در برابرِ ارتقای نسخهی جاوا خیلی کمدردسرتر از Lombok است.
اگر هم Lombok و هم MapStruct را با هم بهکار ببری (که خیلی رایج است، چون Lombok get/set را میسازد و MapStruct از همانها استفاده میکند)، ترتیبِ اجرای پردازشگرها مهم میشود: MapStruct باید بعد از Lombok اجرا شود وگرنه getterها را نمیبیند و مَپینگ خالی میشود. راهحل: وابستگیِ پُلِ lombok-mapstruct-binding را اضافه کن تا این هماهنگی تضمین شود. این یکی از رایجترین گیرهای پیکربندیِ این دو با هم است.
کنار هم: اعتبارسنجی + مَپینگ در یک جریان
حالا هر دو ابزار را در یک جریانِ واقعیِ درخواست کنار هم بگذاریم تا ببینی هرکدام کجای زنجیره مینشیند:
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
@PostMapping
public UserDto register(@Valid @RequestBody RegistrationRequest req) {
// ۱) نگهبانِ دمِ در: @Valid همینجا req را اعتبارسنجی میکند.
// اگر نامعتبر باشد، بدنه اصلاً اجرا نمیشود → 400.
// ۲) مَپینگ: DTO تمیز را به Entity تبدیل کن.
User entity = userMapper.toEntity(req);
// ۳) منطقِ تجاری در لایهی سرویس.
User saved = userService.register(entity);
// ۴) مَپینگِ برگشت: Entity را به DTOِ امن برای پاسخ تبدیل کن
// (بدونِ passwordHash و فیلدهای حساس).
return userMapper.toDto(saved);
}
}
این جریان ترتیبِ درست را نشان میدهد: اول اعتبارسنجی (لبه)، بعد مَپینگ به مدلِ داخلی، بعد منطق، بعد مَپینگِ برگشت به مدلِ بیرونی. هر ابزار در جای خودش، بدونِ کدِ چسبیِ دستنویس.
چند بهترینشیوه که در ذهن نگه دار
- قیدها را روی DTOهای ورودی بگذار، نه روی entity؛ لبهی برنامه جای اعتبارسنجیِ قالبی است.
@NotBlankرا برای رشتههای کاربر،@NotEmptyرا برای مجموعهها، و@NotNullرا برای بقیه بهکار ببر — تفاوتشان را قاطی نکن.- برای اشیاءِ تودرتو
@Validرا فراموش نکن؛ بدونِ آن آبشار اتفاق نمیافتد. - در قیدهای سفارشی،
isValid(null)راtrueبرگردان تا قابلِ ترکیب بماند. - اعتبارسنجیِ یکتاییِ دیتابیسی جایگزینِ unique constraint نمیشود؛ فقط برای پیامِ بهتر است.
- در MapStruct از
unmappedTargetPolicy = ReportingPolicy.ERRORاستفاده کن تا هیچ فیلدی اتفاقی جا نماند. componentModel = "spring"را بگذار تا مَپِر یک bean قابلِ تزریق شود.- اگر Lombok و MapStruct را با هم داری،
lombok-mapstruct-bindingرا اضافه کن. - منطقِ تجاری را داخلِ مَپِر نگذار؛ مَپِر فقط شکلِ داده را تغییر میدهد، تصمیم نمیگیرد.
پرسشهای مصاحبه
Jakarta Validation یک استاندارد/مشخصات (specification) است: مجموعهای از اَنوتیشنها (@NotNull و…) و اینترفیسها (Validator، ConstraintValidator) که فقط قرارداد را تعریف میکنند و هیچ کدِ اجرایی ندارند. Hibernate Validator یک پیادهسازیِ آن استاندارد است — و مرجعترین و پرکاربردترین آن. یعنی وقتی jakarta.validation.constraints.NotNull را import میکنی داری با استاندارد کد میزنی، اما موتوری که واقعاً آن قید را چک میکند Hibernate Validator است. فایدهی این جدایی: کدت به استاندارد وابسته است نه به یک پیادهسازی، پس قابلِ حمل میماند. نکتهی نسخه: نسخهی پایدارِ استاندارد 3.1 و پیادهسازی سری 9.1 است که به جاوا ۱۷+ نیاز دارد.
@NotNull فقط چک میکند مقدار null نباشد؛ رشتهی خالیِ "" را معتبر میداند. @NotEmpty میگوید null نباشد و طول > 0 باشد؛ روی String، Collection، Map و آرایه کار میکند، پس "" را رد اما " " را قبول میکند. @NotBlank فقط برای String است و میگوید null نباشد و بعد از trim هم خالی نباشد، پس حتی " " را رد میکند. قاعدهی عملی: برای رشتههای متنیِ کاربر @NotBlank، برای مجموعهها @NotEmpty، و برای انواعِ غیر-رشتهای (عدد، تاریخ، شیء) @NotNull.
اعتبارسنجی بهطور پیشفرض آبشاری نیست: اگر یک شیء، شیءِ دیگری را در فیلدش داشته باشد، Bean Validation به داخلش سرک نمیکشد. @Valid روی آن فیلد میگوید «اعتبارسنجی را به داخلِ این شیء هم سرایت بده». روی مجموعهها هم کار میکند: @Valid List<Item> هر عضو را validate میکند. آبشار فقط تا جایی میرود که با @Valid علامت زده باشی، پس در ساختارهای عمیق باید در هر سطح تکرارش کنی. مزیتِ جانبی: مسیرِ خطا هم تودرتو میشود (address.postalCode) که برای پیامهای دقیق عالی است.
مشکلِ «یک کلاس، قوانینِ متفاوت در موقعیتهای متفاوت». مثلاً هنگامِ ساختِ کاربر id باید null باشد و رمز الزامی، اما هنگامِ آپدیت برعکس. یک گروه فقط یک اینترفیسِ خالیِ نشانهای است (interface OnCreate {})، و هر قید را با groups = OnCreate.class به یک یا چند گروه نسبت میدهی. هنگامِ اعتبارسنجی تعیین میکنی کدام گروه اجرا شود (validator.validate(dto, OnCreate.class) یا در Spring @Validated(OnCreate.class)). با @GroupSequence میتوانی ترتیب هم بدهی تا گروهها مرحلهای اجرا شوند.
@Valid از خودِ استانداردِ Jakarta است، گروه نمیگیرد، و برای آبشارِ تودرتو (سرایت به فیلدهای داخلی) لازم است. @Validated مالِ Spring است، میتواند گروه بگیرد، و برای فعالکردنِ اعتبارسنجیِ سطحِ متد (روی پارامترها/خروجی) لازم است. نکتهی کلیدی: @Validated آبشار به فیلدهای تودرتو را انجام نمیدهد؛ برای آن همیشه باید @Valid روی خودِ فیلد باشد. در عمل اغلب هر دو را با هم میبینی: @Validated برای گروه/سطحِ متد، و @Valid روی فیلدهای تودرتو.
دو تکه: (۱) یک اَنوتیشن که با @Constraint(validatedBy = XxxValidator.class) علامتگذاری شده و باید سه عضوِ message()، groups() و payload() را داشته باشد. (۲) یک کلاس که ConstraintValidator<A, T> را پیاده میکند، که A نوعِ اَنوتیشن و T نوعِ داده است، و منطق در متدِ isValid(value, context) است. قرارداد مهم: برای null معمولاً true برگردان تا nullبودن را به @NotNull بسپاری و قیدت قابلِ ترکیب بماند. اگر به دیتابیس یا سرویس نیاز داری، چون ولیدیتور در Spring یک bean است، میتوانی وابستگی را تزریق کنی.
تا هر قید فقط یک نگرانی داشته باشد. نگرانیِ «مقدار وجود دارد یا نه» کارِ @NotNull/@NotBlank است، نه کارِ قیدِ قالبیِ تو. اگر ولیدیتورت روی null مقدارِ false بدهد، دیگر نمیتوانی فیلدِ اختیاریای بسازی که «اگر پر شد باید قالبِ درست داشته باشد، ولی خالیبودنش هم مجاز است». با true برگرداندنِ null، قیدها قابلِ ترکیب میمانند و میتوانی الزامیبودن را جدا با @NotNull کنترل کنی. تقریباً همهی قیدهای آمادهی استاندارد هم همینطورند (بهجز خودِ @NotNull).
یعنی گذاشتنِ قید مستقیم روی پارامترها و مقدارِ بازگشتیِ یک متد (مثل findById(@NotNull @Positive Long id))، تا قراردادِ متد در امضایش اعلام شود. در Spring باید کلاس را @Validated بزنی تا فعال شود. محدودیتِ مهم: چون با پروکسیِ AOP پیاده میشود، (۱) فراخوانیِ درونی (وقتی متدی از همان کلاس متدِ دیگرِ خودش را مستقیم صدا میزند) از پروکسی رد نمیشود و اعتبارسنجی اجرا نمیشود؛ (۲) خطای این حالت ConstraintViolationException است نه MethodArgumentNotValidException، پس باید جداگانه در @ControllerAdvice مدیریتش کنی تا پاسخِ HTTP خوب بدهد.
پاسخِ درست «چند لایه، هر نوع جای خودش» است: اعتبارسنجیِ قالبی/نحوی (ایمیل، طول، الگو) در لبه، روی DTOِ ورودی و با Bean Validation — هرچه زودتر بهتر. اعتبارسنجیِ منطقِ تجاری (مجوز، موجودی، قوانینِ دامنه) در لایهی سرویس، چون به context و چند شیء نیاز دارد و با یک اَنوتیشن بیان نمیشود. اعتبارسنجیِ یکپارچگی (کلیدِ یکتا، not-null ستونی) در خودِ دیتابیس، چون تنها جایی است که در برابرِ شرایطِ رقابتی واقعاً تضمین میدهد. هیچکدام جایگزینِ دیگری نیست؛ لایههای دفاعیِ مکملاند.
چند دلیل: امنیت (entity ممکن است فیلدِ حساس مثل passwordHash/isAdmin داشته باشد که نباید در JSON لو برود)؛ جداسازی (اگر کلاینت مستقیم به entity وصل باشد، هر تغییرِ ساختارِ دیتابیس API عمومی را میشکند؛ DTO ضربهگیر است)؛ شکلِ متفاوت (خروجی گاهی ترکیب یا نامِ متفاوت میخواهد، مثل fullName بهجای firstName+lastName)؛ و مشکلاتِ سریالسازیِ lazy (دادنِ مستقیمِ entity به JSON به LazyInitializationException یا کوئریِ ناخواسته میرسد). DTO اینها را حل میکند و MapStruct تبدیلش را خودکار.
MapStruct یک پردازشگرِ اَنوتیشن است: تو یک اینترفیسِ @Mapper با متدهای تبدیل تعریف میکنی و MapStruct در زمانِ کامپایل یک کلاسِ پیادهسازیِ واقعیِ جاوا تولید میکند که فقط get/setهای مستقیم دارد. برتریاش نسبت به مَپِرهای reflection دو چیز است: سرعت (نگاشت یک بار در کامپایل حل میشود و در زمانِ اجرا هیچ reflectionی نیست؛ مثلِ کدِ دستی سریع است، در حالیکه reflection در هر فراخوانی سربار دارد) و امنیت (اگر نگاشتی ناقص/اشتباه باشد، در زمانِ build خطا میگیری، نه در پروداکشن). بهعلاوه کدِ تولیدشده قابلِ خواندن و دیباگ است و با Native Image (GraalVM) هم سازگارتر است چون reflection ندارد.
تنظیمی روی @Mapper که تعیین میکند اگر فیلدی در مقصد توسط هیچ نگاشتی پر نشود، MapStruct چه واکنشی نشان دهد. پیشفرض WARN است (فقط هشدار، که راحت گم میشود). با ReportingPolicy.ERROR آن را به خطای زمانِ کامپایل تبدیل میکنی؛ در نتیجه اگر فردا فیلدی به DTO اضافه شود و نگاشتش فراموش شود، پروژه کامپایل نمیشود. این دقیقاً تجسمِ «امنتر»بودنِ MapStruct است: باگِ فراموششدنِ یک فیلد را از زمانِ اجرا به زمانِ build جابهجا میکند.
@MappingTarget روی یک پارامتر میگوید «شیءِ جدید نساز، همین شیءِ موجود را با مقادیرِ مبدأ بهروزرسانی کن» — دقیقاً برای عملیاتِ آپدیت (مثلاً پرکردنِ یک entity از روی DTO بدونِ ساختِ entityِ تازه). componentModel = "spring" به MapStruct میگوید کلاسِ پیادهسازی را بهعنوانِ یک Spring bean (با @Component) تولید کند تا بتوانی مَپِر را مثل هر سرویسِ دیگری تزریق کنی؛ بدونِ آن، مدلِ پیشفرض یک نمونهی static از طریقِ Mappers.getMapper(...) است که در Spring کمتر مطلوب است.
هر دو annotation processorاند و ترتیبِ اجرایشان مهم است: MapStruct برای نگاشت به getter/setterها تکیه میکند، اما آنها را Lombok در زمانِ کامپایل میسازد. اگر MapStruct قبل از Lombok اجرا شود، getterها هنوز وجود ندارند و مَپینگ خالی یا ناقص میشود. راهحلِ رسمی افزودنِ وابستگیِ پُلِ org.projectlombok:lombok-mapstruct-binding است که این هماهنگی را تضمین میکند. این یکی از رایجترین گیرهای پیکربندی است و نشان میدهد که هر دو ابزار در فازِ کامپایل زندگی میکنند، نه اجرا.
این فصل دو دردِ لبهی هر برنامهی بکاند را درمان کرد. Bean Validation نگهبانِ دمِ در است: با اَنوتیشنهای استانداردِ Jakarta (@NotNull، @NotBlank، @Size، @Email، @Pattern و…) دادهی نامعتبر را روی DTOِ ورودی و پیش از ورود به منطق میگیرد؛ با @Valid اعتبارسنجی را به اشیاءِ تودرتو آبشار میکند؛ با گروهها یک کلاس را در موقعیتهای متفاوت با قوانینِ متفاوت میسنجد؛ و با ConstraintValidator قیدِ سفارشیِ خودت را میسازی (یادت باشد isValid(null)=true). اعتبارسنجی یک لایه نیست: قالبی در لبه، تجاری در سرویس، یکپارچگی در دیتابیس. MapStruct دردِ دوم — تبدیلِ DTO↔Entity — را با تولیدِ کدِ مَپینگ در زمانِ کامپایل حل میکند؛ همین «زمانِ کامپایل» آن را هم سریعتر میکند (بدونِ reflection در زمانِ اجرا، مثلِ کدِ دستی) و هم امنتر (خطای نگاشت در build، نه در پروداکشن). با unmappedTargetPolicy=ERROR هیچ فیلدی اتفاقی جا نمیماند و با componentModel="spring" مَپِر یک bean قابلِ تزریق میشود. هر دو ابزار در فازِ کامپایل زندگی میکنند، هر دو کدِ دستنویسِ خطاخیز را حذف میکنند، و هر دو یک اصل را فریاد میزنند: خطا را هرچه زودتر و هرچه نزدیکتر به لبه بگیر.
This chapter is about two pains every developer meets in any serious backend, yet few people talk about correctly. The first pain: dirty input. A user fills out a signup form, leaves the email blank, types their age as -3, or sends a three-character password. If you let that broken data flow straight into your business logic and database, disaster starts right there. The second pain: glue code for conversion. You fetch an object from the database (an Entity), but you don't want to hand it out as-is; you want a different object (a DTO). So you sit down and write dozens of lines of dto.setName(entity.getName()) — code with no logic at all, just pouring data from one box into another, and full of chances to slip.
Java has a standard, mature cure for both pains: Jakarta Bean Validation for the first, and MapStruct for the second. In this chapter you won't memorize a list of annotations — you'll understand what each one does, where in the architecture it belongs, and most importantly, why MapStruct — which generates mappers at compile time — is both faster and safer than rivals that work at runtime with reflection.
Here's the path we'll walk together:
- Two pains, two cures — why validation and mapping are two sides of the same coin (the edge of your app).
- What Bean Validation is — spec vs implementation (Jakarta Validation vs Hibernate Validator), and wiring it up.
- The core annotations —
@NotNull,@NotBlank,@Size,@Pattern,@Email,@Min/@Max, and family. @Validand cascading — how validation propagates into nested objects.- Validation groups — one object, different rules in different situations.
- Custom validation — writing your own
ConstraintValidatorfrom scratch. - Method-level validation — on parameters and return values.
- Which layer? — exactly where validation belongs.
- MapStruct — compile-time mappers vs reflection, and why that difference is everything.
- Interview questions with full answers, and a wrap-up.
Part 0 — a few words you must feel before we start
Before any code, a few terms recur throughout this chapter. Let me plant them now.
- Validation: checking whether data is "valid" — i.e. it obeys the rules we define. For example, "email must look like an email" or "age can't be negative."
- Constraint: a specific validation rule expressed as an annotation on a field or parameter, like
@NotNullor@Size(min=8). Each constraint is a rule label. - DTO: short for Data Transfer Object. An object built purely to move data between layers — say, between the controller and the client. It differs from an Entity (an object mapped directly to a database table).
- Mapper: code that converts one object (say an
Entity) into another (say aDTO), field by field. - Reflection: Java's ability to inspect and manipulate classes and fields at runtime, without knowing their names at compile time. Powerful, but slow and unsafe; you'll see why this word is central to the MapStruct discussion.
Imagine you run a nightclub. If you let anyone in through any door and only discover mid-floor that a person has no ticket or is underage, the damage is done — you have to hunt them down in the crowd and eject them. The right approach is a strong bouncer at the door: everyone who wants in has their ticket and ID checked right there. If something's wrong, they're turned away on the spot with a clear message, and never set foot inside. Bean Validation is that bouncer: it catches invalid data at the edge of your app, before it can contaminate your logic and database.
What is Bean Validation? Spec vs implementation
Here's the first thing that separates seniors from beginners. When we say "Bean Validation," we mean a standard, not a specific library. Java has an official specification called Jakarta Validation (formerly known under the javax.validation name as JSR 380, now renamed after Java EE moved to the Eclipse Foundation and became Jakarta). This standard only defines the contract: annotations like @NotNull, and interfaces like Validator. But it contains no executable code itself.
The actual code that implements that contract is an implementation, and the most famous and reference one is Hibernate Validator (which has nothing to do with the Hibernate ORM database — just the same family).
Don't conflate two things:
- Jakarta Validation is the standard; the current stable version is 3.1 (exact artifact version: 3.1.1), part of Jakarta EE 11.
- Hibernate Validator is the reference implementation; the current stable line is 9.1 (e.g. 9.1.2.Final), and it requires at least Java 17.
An important historical note: when moving from Java EE to Jakarta EE, package names changed from javax.validation.* to jakarta.validation.*. If you see import javax.validation.constraints.NotNull in an old project, that's the legacy version; in today's projects everything is jakarta.. This rename is one of the most common sources of migration confusion.
To wire it up in a plain Maven project (outside Spring Boot):
<!-- the standard itself (API) -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.1.1</version>
</dependency>
<!-- the reference implementation -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.2.Final</version>
</dependency>
<!-- an Expression Language implementation, so error messages work -->
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>6.0.0</version>
</dependency>
If you use Spring Boot, you don't need all this ceremony; just add spring-boot-starter-validation and all three dependencies above (with aligned versions) come automatically. That EL we added is because error messages can use dynamic expressions (like injecting the min value into the text); without it, in a Java SE environment messages stay raw or throw.
The core annotations: stick rules onto fields
The heart of Bean Validation is that you attach rules right where the data is defined — on the field itself. Let's build a signup DTO:
import jakarta.validation.constraints.*;
import java.time.LocalDate;
public class RegistrationRequest {
@NotBlank(message = "username is required")
@Size(min = 3, max = 20, message = "username must be 3-20 characters")
private String username;
@NotBlank
@Email(message = "invalid email format")
private String email;
@NotBlank
@Size(min = 8, message = "password must be at least 8 characters")
@Pattern(regexp = ".*\\d.*", message = "password must contain at least one digit")
private String password;
@NotNull
@Min(value = 18, message = "age must be at least 18")
@Max(150)
private Integer age;
@Past(message = "birth date must be in the past")
private LocalDate birthDate;
// getters/setters ...
}
Read each line: the rule sits next to the data, is readable, and there's nowhere else in the code where you can "forget" to check it. Now the most important distinction that many people get wrong:
@NotNullonly says the value isn'tnull. An empty string""is perfectly valid to it.@NotEmptysays notnulland length isn't zero. It works onString,Collection,Map, and arrays. So it rejects""but accepts" "(spaces only).@NotBlankis forStringonly and says notnulland not empty aftertrim(whitespace removed). So it rejects" "too.
Practical rule: for text strings a user fills in (name, title), demand @NotBlank; for collections @NotEmpty; and @NotNull for anything non-string that simply must be present (like a number or date).
The most commonly used built-in constraints:
| Constraint | On which type | What it guarantees |
|---|---|---|
@NotNull |
any type | not null |
@NotEmpty |
String/Collection/Map/Array | not null and length > 0 |
@NotBlank |
String | not empty after trim |
@Size(min,max) |
String/Collection/Map/Array | length within range |
@Min / @Max |
integer numbers | minimum/maximum value |
@DecimalMin / @DecimalMax |
decimal numbers | min/max with decimal precision |
@Positive / @Negative |
numbers | strictly positive/negative |
@Digits(integer,fraction) |
numbers | count of integer and fraction digits |
@Email |
String | email format |
@Pattern(regexp) |
String | matches a custom regex |
@Past / @Future |
date/time | in the past/future |
@AssertTrue / @AssertFalse |
boolean | value is true/false |
But these annotations do nothing on their own — they're just labels. Someone has to read and enforce them. That someone is either the framework (Spring does it automatically) or you, with a Validator:
import jakarta.validation.*;
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
RegistrationRequest req = new RegistrationRequest();
req.setUsername("ab"); // too short
req.setEmail("not-an-email"); // wrong format
Set<ConstraintViolation<RegistrationRequest>> violations = validator.validate(req);
for (ConstraintViolation<RegistrationRequest> v : violations) {
System.out.println(v.getPropertyPath() + " : " + v.getMessage());
// username : username must be 3-20 characters
// email : invalid email format
}
validator.validate(...) returns a Set of violations. If it's empty, everything is valid. Each violation tells you which field and why it was rejected.
In a Spring controller you don't build a Validator yourself. Just put @Valid on the parameter:
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegistrationRequest req) {
// if req is invalid, this method never runs;
// Spring throws a MethodArgumentNotValidException, which by default
// becomes a 400 Bad Request response.
return ResponseEntity.ok(userService.register(req));
}
This means the bouncer at the door is automatic and the method body runs only when the data is clean.
@Valid and cascading: validating nested objects
So far we've seen simple fields. But what if one object holds another object inside it? For example an Order that has an Address. By default, validation is not cascading — if you validate Order, Bean Validation won't peek inside Address and ignores its constraints. To make validation propagate into a nested object, you must put @Valid on that field:
public class Order {
@NotNull
private String orderId;
@NotNull
@Valid // ← this is the key
private Address shippingAddress; // without @Valid, Address's constraints aren't checked
@NotEmpty
@Valid // works on collections too: each element is validated
private List<OrderLine> lines;
}
public class Address {
@NotBlank private String street;
@NotBlank private String city;
@Pattern(regexp = "\\d{10}") private String postalCode;
}
Imagine a cargo truck at the border. The customs officer first checks the truck's own papers (plate, driver). But if you say "just the truck's paperwork is enough," you'll never know what's inside the boxes. @Valid on a field is like the order to "open this box too and inspect its contents." And if inside the box there's yet another smaller box, you must put @Valid on that as well, so the inspection goes all the way down the chain. Cascading only goes as far as you've marked with @Valid.
A subtle point: the error path becomes nested too. If postalCode inside shippingAddress is invalid, the path becomes shippingAddress.postalCode — which is great for building precise error messages for the user.
Validation groups: one object, different rules
Here's a real-world problem. Suppose you have a User. On create, the id should be null (it doesn't exist yet) and the password is required. On update, the id must be present and the password is optional. Same class, but two different sets of rules. If you apply all constraints unconditionally, you can't tell these two cases apart.
The solution: validation groups. A group is just an empty marker interface:
public interface OnCreate {}
public interface OnUpdate {}
Now you assign each constraint to a group (or groups):
public class UserDto {
@Null(groups = OnCreate.class) // must be null on create
@NotNull(groups = OnUpdate.class) // must be present on update
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String username;
@NotBlank(groups = OnCreate.class) // required only on create
@Size(min = 8, groups = {OnCreate.class, OnUpdate.class})
private String password;
}
And at validation time you choose which group to run:
// check only the OnCreate group's constraints
Set<ConstraintViolation<UserDto>> v = validator.validate(dto, OnCreate.class);
In Spring you use @Validated instead of @Valid, which can take a group:
@PostMapping("/users")
public User create(@Validated(OnCreate.class) @RequestBody UserDto dto) { ... }
@PutMapping("/users/{id}")
public User update(@Validated(OnUpdate.class) @RequestBody UserDto dto) { ... }
@Validis from the Jakarta standard itself (jakarta.validation.Valid) and takes no groups; it just turns on validation/cascading.@Validatedis Spring's (org.springframework.validation.annotation.Validated) and can take groups, and is also required for method-level validation.
Key point: for nested cascading (propagating into inner fields) you always need @Valid on the field itself; @Validated doesn't do that. So in practice you often see both: @Validated on the method/class for groups, and @Valid on nested fields for cascading.
Groups can also be ordered: with @GroupSequence you say "check this group first; only if it passes, move on to the next." This is useful for staged validation — structure first, then expensive business rules.
Custom validation: write your own ConstraintValidator
The built-in annotations are great, but sooner or later you'll want a rule none of them covers: "username may contain only letters, digits, and underscores," "must be a valid national ID," "this email isn't already registered." That's when you build your own custom constraint. It has two pieces:
Piece 1 — the annotation itself. You define an annotation and, with @Constraint, tell it which class holds the checking logic:
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = UsernameValidator.class) // ← the logic class
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidUsername {
String message() default "username may only contain letters, digits, and underscores";
Class<?>[] groups() default {}; // to support groups — mandatory
Class<? extends Payload>[] payload() default {}; // extra metadata — mandatory
}
Every custom validation annotation must have these three members or Bean Validation rejects it:
message()— the default error message.groups()— so it's compatible with the groups system.payload()— to carry custom metadata (e.g. a severity level). Usually left empty but it must be there.
These three are the standard contract; omit one and you get a startup error.
Piece 2 — the validation logic. A class implementing ConstraintValidator<A, T>: A is the annotation type and T is the type of data being checked.
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class UsernameValidator implements ConstraintValidator<ValidUsername, String> {
private static final java.util.regex.Pattern PATTERN =
java.util.regex.Pattern.compile("^[a-zA-Z0-9_]+$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// important contract: treat null as "valid" and leave null-ness to @NotNull
if (value == null) {
return true;
}
return PATTERN.matcher(value).matches();
}
}
Usage is now clean:
public class SignupForm {
@NotBlank
@ValidUsername // our custom constraint, exactly like the built-in ones
private String username;
}
A golden design principle: each constraint should have one concern. The "is a value present" concern belongs to @NotNull/@NotBlank, not to @ValidUsername. If your validator returned false on null, you could never build an optional field that says "if filled, it must have the right format, but being empty is fine too." By returning true for null, your constraints stay composable. Nearly all the standard built-in constraints do the same (except @NotNull itself).
If you need database access for validation (e.g. "email must be unique"), since a ConstraintValidator is a bean in Spring, you can inject dependencies:
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository repository;
public UniqueEmailValidator(UserRepository repository) { // constructor injection
this.repository = repository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
if (email == null) return true;
return !repository.existsByEmail(email);
}
}
Constraints that hit the database (like email uniqueness) are tempting but have two traps: (1) a race condition — between the "email isn't taken" check and the INSERT, another user might register the same email; so this check never replaces a unique constraint in the database itself, it's only for a nicer error message. (2) Performance — each validation issues an extra query. Use these in the right place (the service layer) and deliberately.
Method-level validation
So far we've validated an object's fields. But Bean Validation has another power: it can put constraints directly on method parameters and return values. This means you can declare a method's contract right in its signature:
import jakarta.validation.constraints.*;
import org.springframework.validation.annotation.Validated;
@Service
@Validated // ← without this, method-level constraints are ignored in Spring
public class AccountService {
public Account findById(@NotNull @Positive Long id) {
// if id is null or <= 0, a ConstraintViolationException
// is thrown before entering the method body.
return repository.findById(id).orElseThrow();
}
public @NotNull Account create(@Valid AccountForm form) {
// @Valid on the object parameter = cascade into form
// @NotNull on the return value = guarantee a non-null output
return repository.save(new Account(form));
}
}
Why is @Validated on the class required? Because Spring implements method-level validation with a proxy (an AOP proxy): instead of the real object, it inserts a middle layer that checks constraints before and after the method call. Two important practical consequences:
- Self-invocation doesn't work. If a method in the same class calls another method of itself directly, it doesn't go through the proxy and validation doesn't run.
- The error here is a
ConstraintViolationException, not theMethodArgumentNotValidExceptioncontrollers produce; so if you want a nice HTTP response, you must handle this exception separately in@ControllerAdvice.
Which layer should validate?
This is a favorite interview question, and the shallow answer ("in the controller") is incomplete. The senior answer: validation isn't one layer, it's several kinds, and each kind has its place.
- Syntactic/format validation — "is the email shaped like an email? is the password 8 chars?" Catch these at the edge, on the input DTO, with Bean Validation annotations. The earlier the better — before dirty data enters the system.
- Business-rule validation — "is this user allowed to make this purchase? is there enough balance?" These can't be expressed with a field annotation; they need context and multiple objects. Their right place is the service (domain) layer.
- Data-integrity validation — "unique key, column-level not-null." This is the last line of defense and must live in the database itself (SQL constraints), because it's the only place that truly guarantees against race conditions.
The mental summary: Bean Validation is the bouncer at the door (format and structure); business logic is checked inside the building; and the database is the final vault. None replaces the others.
Now that the input is clean, we reach the second pain: converting these clean objects between layers.
The second pain: why DTOs and mapping at all?
A fair question: why not hand the Entity straight to the client and spare ourselves this conversion? Several serious reasons:
- Security. A user
Entitymight have apasswordHashorisAdminfield; you don't want to accidentally leak it in a JSON response. A DTO exposes only what it should. - Layer decoupling. If the client is coupled directly to the
Entity, every small database-structure change breaks your public API. A DTO is a shock absorber between the internal model and the external contract. - Different shape. Sometimes the output must combine several entities or use different names and structure. For example
firstNameandlastNamein the entity, butfullNamein the DTO. - Lazy-serialization problems. Handing an entity straight to JSON often leads to
LazyInitializationExceptionor unwanted queries (it pulls lazy relations mid-serialization).
So a DTO is needed. But manual conversion is tedious and error-prone:
// manual mapping — no logic, just moves data, full of chances to slip
public UserDto toDto(User user) {
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
// ... and if a new field is added to User, "forgetting" to add it here → silent bug
return dto;
}
Imagine you have two rooms: an internal room (the engineering team, technical language, raw details) and an external room (the customer, plain language, only what's needed). A translator sits between them and relays messages. If you translate every sentence by hand, you'll both tire out and, sooner or later, drop a sentence. MapStruct is that translator to whom you say once "this word maps to that word," and who builds you a complete, flawless phrasebook (real Java code) — before the app even runs.
MapStruct: a mapper at compile time
MapStruct is an annotation processor. You only define an interface saying "from this type to that type," and MapStruct generates a real Java implementation class for you at compile time — one that does exactly the same manual set/get — but you didn't write it, so you can't break it.
Wiring it in Maven:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
And, more importantly, you register the processor with the compiler:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
The stable version of MapStruct is 1.6.3 (November 2024). The next line, 1.7.0 (currently in Beta2, mid-2026), brings a big feature: native Optional support — you can use Optional<T> as a source or target type and MapStruct handles presence/absence itself (instead of null-checks). It also adds JSpecify-style nullness support. For a production project, 1.6.3 is the safest choice for now.
Now let's write a mapper. Just an interface with the @Mapper annotation:
import org.mapstruct.*;
@Mapper(componentModel = "spring") // so it can be injected as a Spring bean
public interface UserMapper {
UserDto toDto(User user); // MapStruct generates the implementation
User toEntity(UserDto dto); // the reverse direction likewise
List<UserDto> toDtoList(List<User> users); // collections too, automatically
}
That's it. For fields whose names match (username to username), MapStruct discovers the mapping itself. The generated code looks something like this — a perfectly ordinary class, readable and reflection-free:
// code generated by MapStruct (abbreviated) — you don't write this, but you can see it
@Component
public class UserMapperImpl implements UserMapper {
@Override
public UserDto toDto(User user) {
if (user == null) return null;
UserDto dto = new UserDto();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
return dto;
}
// ...
}
Look at that code: it's exactly what you'd have written by hand, but flawless, automatic, and always in sync with the fields.
When names differ: @Mapping
If field names differ or you want to combine several fields, you guide it with @Mapping:
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "username", target = "login") // different name
@Mapping(target = "fullName", expression = "java(user.getFirstName() + \" \" + user.getLastName())")
@Mapping(target = "password", ignore = true) // don't map this at all
@Mapping(source = "createdAt", target = "registeredOn", dateFormat = "yyyy-MM-dd")
UserDto toDto(User user);
}
By default, if a target field isn't mapped, MapStruct only emits a warning that's easy to miss. A golden setting is to turn it into an error so no field is ever "accidentally" left out:
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserMapper { ... }
Now if tomorrow you add a field to UserDto and forget to map it, it won't compile — you caught the bug at compile time, not in production. This is exactly what makes MapStruct "safer."
Updating an existing object and nested mappers
// instead of creating a new object, update the fields of an existing one
@Mapping(target = "id", ignore = true)
void updateEntityFromDto(UserDto dto, @MappingTarget User entity);
// use other mappers for nested fields
@Mapper(componentModel = "spring", uses = { AddressMapper.class })
public interface OrderMapper {
OrderDto toDto(Order order); // AddressMapper is used automatically for the address field
}
@MappingTarget says "don't create a new object, fill this one" — perfect for update operations. And uses = {...} tells MapStruct to lean on other mappers for nested types, so you don't have to write everything in one place.
Why is compile-time better than reflection?
Here's the heart of the chapter. MapStruct's older rivals (libraries that work at runtime with reflection) inspect the source object at runtime: they find fields via reflection, match names, and copy values. That works, but has three serious costs that MapStruct eliminates by generating code at compile time.
| Aspect | MapStruct (compile time) | Reflection-based mapper (runtime) |
|---|---|---|
| When the mapping is resolved | Once, at compile | Every time, on every call |
| Execution speed | Like hand-written code (direct get/set) |
Slower (reflection has overhead) |
| When errors are found | Compile time (missing field = build error) | Runtime (may blow up in production) |
| Debuggability | You can read the generated code and step into it | A black box; logic is inside the library |
| Runtime dependency | Almost none (plain Java code) | The reflection library is always needed |
| Native (GraalVM) compatibility | Excellent (static code, no reflection) | Painful (reflection needs special config) |
Faster, because the hard work of "which field to which field" is done once at compile time and the final code is just direct get/set — with no reflection at runtime; whereas a reflection mapper redoes that matching on every call. Safer, because if a mapping is wrong or incomplete, MapStruct errors at build time and the app doesn't compile at all; a reflection mapper hides the same mistake until runtime and might blow up on a real user in production. In short: MapStruct moves the error from "production night" to "compile morning."
If you've read the Lombok chapter, this pattern feels familiar: both are annotation processors that generate code at compile time. But there's a subtle technical difference: Lombok reaches into the compiler's internal APIs and mutates the AST (unofficial work), whereas MapStruct uses the official Annotation Processing API and produces new files (the ...Impl classes) — which is completely standard and stable. That's why MapStruct is far less troublesome than Lombok across Java version upgrades.
If you use both Lombok and MapStruct together (very common, since Lombok generates the get/set and MapStruct uses them), the processors' execution order matters: MapStruct must run after Lombok, or it won't see the getters and mapping comes out empty. The fix: add the lombok-mapstruct-binding bridge dependency to guarantee that coordination. This is one of the most common configuration snags with these two together.
Together: validation + mapping in one flow
Now let's put both tools side by side in a real request flow so you see where each sits in the chain:
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
@PostMapping
public UserDto register(@Valid @RequestBody RegistrationRequest req) {
// 1) bouncer at the door: @Valid validates req right here.
// if invalid, the body never runs → 400.
// 2) mapping: convert the clean DTO into an Entity.
User entity = userMapper.toEntity(req);
// 3) business logic in the service layer.
User saved = userService.register(entity);
// 4) return mapping: convert the Entity into a safe response DTO
// (without passwordHash and sensitive fields).
return userMapper.toDto(saved);
}
}
This flow shows the correct order: validate first (edge), then map to the internal model, then logic, then map back to the external model. Each tool in its place, with no hand-written glue code.
A few best practices to keep in mind
- Put constraints on input DTOs, not on entities; the app's edge is the place for format validation.
- Use
@NotBlankfor user strings,@NotEmptyfor collections, and@NotNullfor everything else — don't confuse their differences. - Don't forget
@Validfor nested objects; without it, cascading doesn't happen. - In custom constraints, return
truefromisValid(null)so they stay composable. - Database uniqueness validation doesn't replace a unique constraint; it's only for a better message.
- In MapStruct, use
unmappedTargetPolicy = ReportingPolicy.ERRORso no field is accidentally left out. - Set
componentModel = "spring"so the mapper becomes an injectable bean. - If you use Lombok and MapStruct together, add
lombok-mapstruct-binding. - Don't put business logic inside a mapper; a mapper only reshapes data, it doesn't decide.
Interview questions
Jakarta Validation is a standard/specification: a set of annotations (@NotNull etc.) and interfaces (Validator, ConstraintValidator) that define only the contract and contain no executable code. Hibernate Validator is an implementation of that standard — the reference and most-used one. So when you import jakarta.validation.constraints.NotNull you're coding against the standard, but the engine that actually checks the constraint is Hibernate Validator. The benefit of this separation: your code depends on the standard, not on an implementation, so it stays portable. Version note: the stable standard is 3.1 and the implementation is the 9.1 line, which requires Java 17+.
@NotNull only checks the value isn't null; it considers "" valid. @NotEmpty says not null and length > 0; it works on String, Collection, Map, and arrays, so it rejects "" but accepts " ". @NotBlank is String-only and says not null and not empty after trim, so it even rejects " ". Practical rule: for user text strings use @NotBlank, for collections @NotEmpty, and for non-string types (number, date, object) @NotNull.
Validation is not cascading by default: if an object holds another object in a field, Bean Validation won't peek inside. @Valid on that field says "propagate validation into this object too." It works on collections as well: @Valid List<Item> validates each element. Cascading only goes as far as you've marked with @Valid, so in deep structures you must repeat it at each level. A side benefit: the error path becomes nested too (address.postalCode), which is great for precise messages.
The problem of "one class, different rules in different situations." For example, on create id must be null and password required, but on update it's the opposite. A group is just an empty marker interface (interface OnCreate {}), and you assign each constraint to one or more groups with groups = OnCreate.class. At validation time you choose which group runs (validator.validate(dto, OnCreate.class), or in Spring @Validated(OnCreate.class)). With @GroupSequence you can even order them, so groups run in stages.
@Valid is from the Jakarta standard itself, takes no groups, and is required for nested cascading (propagation into inner fields). @Validated is Spring's, can take groups, and is required to enable method-level validation (on parameters/return values). Key point: @Validated does not cascade into nested fields; for that you always need @Valid on the field itself. In practice you often see both together: @Validated for groups/method-level, and @Valid on nested fields for cascading.
Two pieces: (1) an annotation marked with @Constraint(validatedBy = XxxValidator.class) that must have the three members message(), groups(), and payload(). (2) a class implementing ConstraintValidator<A, T>, where A is the annotation type and T is the data type, with the logic in isValid(value, context). Important contract: usually return true for null so you leave null-ness to @NotNull and keep your constraint composable. If you need a database or service, since the validator is a bean in Spring, you can inject the dependency.
So each constraint has one concern. The "is a value present or not" concern belongs to @NotNull/@NotBlank, not your format constraint. If your validator returned false on null, you could no longer build an optional field that says "if filled it must have the right format, but being empty is allowed too." Returning true for null keeps constraints composable, and you control requiredness separately with @NotNull. Nearly all standard built-in constraints do the same (except @NotNull itself).
It means putting constraints directly on a method's parameters and return value (like findById(@NotNull @Positive Long id)), so the method's contract is declared in its signature. In Spring you must annotate the class with @Validated to enable it. Important limitation: because it's implemented with an AOP proxy, (1) self-invocation (when a method calls another method of the same class directly) doesn't go through the proxy and validation doesn't run; (2) the error here is a ConstraintViolationException, not the MethodArgumentNotValidException controllers produce, so you must handle it separately in @ControllerAdvice to give a nice HTTP response.
The right answer is "several layers, each kind in its place": format/syntactic validation (email, length, pattern) at the edge, on the input DTO, with Bean Validation — the earlier the better. Business-rule validation (authorization, balance, domain rules) in the service layer, because it needs context and multiple objects and can't be expressed with an annotation. Integrity validation (unique key, column not-null) in the database itself, because it's the only place that truly guarantees against race conditions. None replaces the others; they're complementary lines of defense.
Several reasons: security (an entity may have sensitive fields like passwordHash/isAdmin that mustn't leak into JSON); decoupling (if the client is coupled to the entity, every database-structure change breaks the public API; a DTO is a shock absorber); different shape (output sometimes needs combination or different names, like fullName instead of firstName+lastName); and lazy-serialization problems (handing an entity straight to JSON leads to LazyInitializationException or unwanted queries). A DTO solves these, and MapStruct automates the conversion.
MapStruct is an annotation processor: you define a @Mapper interface with conversion methods, and MapStruct generates a real Java implementation class at compile time that does only direct get/set. Its edge over reflection mappers is two things: speed (the mapping is resolved once at compile time and there's no reflection at runtime; it's as fast as hand-written code, whereas reflection has overhead on every call) and safety (if a mapping is incomplete/wrong, you get an error at build time, not in production). On top of that, the generated code is readable and debuggable and is more compatible with Native Image (GraalVM) since it has no reflection.
A setting on @Mapper that determines how MapStruct reacts if a target field isn't filled by any mapping. The default is WARN (just a warning, which is easy to miss). With ReportingPolicy.ERROR you turn it into a compile-time error; as a result, if tomorrow a field is added to the DTO and its mapping is forgotten, the project won't compile. This is exactly the embodiment of MapStruct being "safer": it moves the forgotten-field bug from runtime to build time.
@MappingTarget on a parameter says "don't create a new object, update this existing one with the source values" — exactly for update operations (e.g. filling an entity from a DTO without creating a fresh entity). componentModel = "spring" tells MapStruct to generate the implementation class as a Spring bean (with @Component) so you can inject the mapper like any other service; without it, the default model is a static instance via Mappers.getMapper(...), which is less desirable in Spring.
Both are annotation processors and their execution order matters: MapStruct relies on getters/setters for mapping, but Lombok generates those at compile time. If MapStruct runs before Lombok, the getters don't exist yet and mapping comes out empty or incomplete. The official fix is to add the org.projectlombok:lombok-mapstruct-binding bridge dependency, which guarantees the coordination. This is one of the most common configuration snags and shows that both tools live in the compile phase, not runtime.
This chapter cured two pains at the edge of every backend app. Bean Validation is the bouncer at the door: with standard Jakarta annotations (@NotNull, @NotBlank, @Size, @Email, @Pattern, etc.) it catches invalid data on the input DTO before it reaches your logic; with @Valid it cascades validation into nested objects; with groups it validates one class under different rules in different situations; and with ConstraintValidator you build your own custom constraint (remember isValid(null)=true). Validation isn't one layer: format at the edge, business in the service, integrity in the database. MapStruct cures the second pain — DTO↔Entity conversion — by generating mapping code at compile time; that very "compile time" makes it both faster (no reflection at runtime, as fast as hand-written code) and safer (mapping errors at build, not in production). With unmappedTargetPolicy=ERROR no field is accidentally left out, and with componentModel="spring" the mapper becomes an injectable bean. Both tools live in the compile phase, both eliminate error-prone hand-written code, and both shout one principle: catch the error as early as possible and as close to the edge as possible.