Spring · اسپرینگ پایهBeginner ~49 دقیقه مطالعه~40 min read
Spring Boot و REST APISpring Boot & REST APIs
از صفر یاد میگیری Spring Boot 3 چطور با شرطها همهچیز را خودکار سیمکشی میکند و چطور یک REST API حرفهای با اعتبارسنجی، مدیریت خطای استاندارد، زنجیرهٔ درخواست، Actuator و سرور جاسازیشده بسازی — با تشبیه، کد و پرسشهای مصاحبه.Learn from scratch how Spring Boot 3 auto-wires everything through conditions, and how to build a professional REST API with validation, standard error handling, the request pipeline, Actuator, and the embedded server — through analogies, code, and interview questions.
پیشنیاز:Prerequisites: هستهٔ Spring: IoC، DI، Bean و AOPSpring Core: IoC, DI, Beans & AOP
سلام. بیا با هم یکی از پرکاربردترین ابزارهای دنیای Java را از پایه بسازیم و بفهمیم. خیلیها Spring Boot را «جادو» میدانند؛ کدی مینویسند، اجرا میکنند و همهچیز کار میکند — اما نمیدانند چرا. هدف این درس این است که آن جادو را برایت به یک ماشین شفاف و قابلفهم تبدیل کنم. وقتی این درس تمام شود، دیگر Spring Boot برایت جعبهٔ سیاه نیست؛ میدانی موتورش داخل چه میکند و در مصاحبه میتوانی هرکدام از اینها را با اطمینان توضیح دهی.
در این مسیر اینها را قدمبهقدم میسازیم:
- مدل ذهنی — Spring Boot واقعاً چیست و چرا «جادویی اما بیآزار» است.
- درونکارِ auto-configuration — چطور Boot میفهمد چه beanهایی بسازد (شرطها، فایل
AutoConfiguration.imports، starterها). - پیکربندی بیرونی و پروفایلها — propertyها از کجا میآیند و چهکسی برنده است.
- ساختن لایهٔ REST — کنترلر، اعتبارسنجی، و مدیریت خطای استاندارد با RFC 9457.
- زنجیرهٔ درخواست — تفاوت filter، interceptor و argument resolver.
- Actuator و مشاهدهپذیری و سرور جاسازیشده (Tomcat، virtual threads).
- یک بخش کامل سؤالات مصاحبه با پاسخهای کامل.
پیشنیازش فقط این است که Java و مفهوم کلاس و annotation را بشناسی. بقیهٔ واژهها را همینجا از صفر میسازم.
بخش ۰ — چند واژه که باید بشناسی
قبل از شروع، سه واژه را با تشبیه جا بیندازیم تا بعداً سرد و ناگهانی بهکار نروند.
- bean: هر شیئی که خودِ Spring آن را میسازد، نگه میدارد و به بقیه تحویل میدهد. تصور کن یک انباردار مرکزی داری که تمام قطعات آماده را نگه میدارد؛ هر قطعه یک bean است و انباردار همان «Spring container» است.
- classpath: فهرست تمام کتابخانهها (jarها) و کلاسهایی که برنامهات هنگام اجرا به آنها دسترسی دارد. مثل قفسهٔ ابزار یک کارگاه — هرچه روی قفسه باشد، در دسترس است.
- annotation: یک برچسب که روی کلاس یا متد میچسبانی تا به فریمورک بگویی «با این جور رفتار کن». مثل برچسب «شکستنی» روی یک جعبه که به مأمور پست دستور رفتار میدهد.
هر جا در Spring Boot گیج شدی، این را از خودت بپرس: «Boot الان دارد به classpath و beanهای من نگاه میکند و بر اساس یک شرط تصمیم میگیرد چه beanای بسازد یا نسازد.» تقریباً همهٔ رفتار Boot از همین یک جمله بیرون میآید.
مدل ذهنی: Spring Boot واقعاً چیست؟
یک رستوران معمولی را تصور کن که آشپزخانهاش خام است: باید خودت اجاق را روشن کنی، روغن بیاوری، دستور پخت بنویسی. این «Spring Framework خالی» است — قدرتمند، اما پر از کارِ دستی. حالا یک رستورانِ زنجیرهای حرفهای را تصور کن که وقتی وارد آشپزخانه میشوی، اجاق روشن است، مواد پرکاربرد آمادهاند و دستور پختهای استاندارد روی دیوارند. تو فقط میآیی و غذا را کامل میکنی. این Spring Boot است: همان Spring، اما با یک آشپزخانهٔ از پیش چیدهشده و نظردار.
پس دقت کن: Spring Boot یک فریمورک روی Spring نیست. یک بستهبندی نظردار (opinionated) از Spring Framework است، بههمراه یک موتور زمانِ راهاندازی که classpath، beanها و propertyهای تو را بازرسی میکند و بعد پیشفرضهای منطقی را سیمکشی میکند تا تو کد تکراری (boilerplate) ننویسی. «نظردار» یعنی Boot دربارهٔ اینکه معمولاً چه چیزی درست است، نظر دارد و همان را پیشفرض میگذارد.
دو ایده تقریباً همهٔ این کار را انجام میدهند:
۱. قرارداد بهجای پیکربندی، از طریق شرطها (conditions). Boot صدها کلاس @Configuration را عرضه میکند که هرکدام با یک @Conditional محافظت شدهاند. @Conditional یعنی «این را فقط وقتی اعمال کن که فلان شرط برقرار باشد». هنگام راهاندازی، Boot این شرطها را در برابر اپلیکیشن تو ارزیابی میکند و فقط آنهایی را که جور در میآیند اعمال میکند.
۲. عقبنشینی محترمانه (backing off). تقریباً هر auto-configuration دارای @ConditionalOnMissingBean است، یعنی «فقط وقتی این bean را بساز که خود کاربر نساخته باشد». لحظهای که تو خودت یک bean تعریف کنی، Boot کنار میکشد. برای همین است که Boot جادویی بهنظر میرسد اما هرگز با تو نمیجنگد — همیشه به تصمیم صریح تو احترام میگذارد.
همهچیز روی این دو ستون سوار است: (۱) شرطها تصمیم میگیرند چه چیزی روشن شود، و (۲) عقبنشینی (@ConditionalOnMissingBean) تضمین میکند هرجا تو حرفی زدی، Boot ساکت شود. اگر این دو را بفهمی، دیگر هیچ رفتار Boot تو را غافلگیر نمیکند.
خط پایهٔ Spring Boot 3 (پیش از هر مهاجرت اینها را تأیید کن)
اینها حقایق نسخهایاند که یک مهندس ارشد باید از بر باشد:
- حداقل Java 17 (Java 21+ توصیه میشود؛ virtual threads با
spring.threads.virtual.enabled=trueپشتیبانی میشود). - فضای نام Jakarta EE 9+: هر import از
javax.*در APIهای servlet/validation/persistence/JMS بهjakarta.*تبدیل میشود. این بزرگترین تغییر شکننده (breaking change) نسبت به Boot 2 است. «فضای نام» یعنی همان پیشوند پکیجها؛ کل صنعت ازjavaxبهjakartaکوچ کرد و Boot 3 فقط شکل جدید را میفهمد. - ساختهشده روی Spring Framework 6، Tomcat 10.1، Hibernate 6.
- پشتیبانی درجهیک از GraalVM native image (ساختن یک فایل اجراییِ بومیِ سریعبالاآینده بهجای اجرای روی JVM) و Micrometer Observation برای tracing/metrics.
نمودار ذهنی کل فرایند راهاندازی این است:
classpath اپ + beanهای شما + propertyهای شما
│
┌─────────────┴──────────────┐
│ @EnableAutoConfiguration │ (توسط @SpringBootApplication ضمنی است)
└─────────────┬──────────────┘
▼
خواندن AutoConfiguration.imports ── فهرست کلاسهای کاندید @AutoConfiguration
▼
ارزیابی محافظهای @Conditional ── نگهداشتن آنهایی که مناسباند
▼
ثبت beanها (عقبنشینی در برابر beanهای خودتان)
درونکارِ Auto-configuration
حالا برویم داخل موتور را ببینیم.
نقطهٔ ورود
@SpringBootApplication // = @Configuration + @ComponentScan + @EnableAutoConfiguration
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
آن یک annotation آشنای @SpringBootApplication در واقع سه annotation در یک لباس است: @Configuration (این کلاس منبع تعریف bean است)، @ComponentScan (پکیجهای اطراف را برای پیداکردن beanهای تو بگرد)، و @EnableAutoConfiguration (همان موتور جادو).
@EnableAutoConfiguration کلاسی بهنام AutoConfigurationImportSelector را import میکند. این selector یک فایل متادیتا را از هر jar روی classpath میخواند. یعنی هر کتابخانهای که روی قفسه گذاشتهای، یک برگهٔ راهنما همراه دارد که میگوید «اگر من اینجا هستم، این beanها را در نظر بگیر».
spring.factories مُرده است — مکانیزم اکنون AutoConfiguration.imports است
قدیمها Boot یک دفترِ همهکاره داشت (spring.factories) که همهچیز — از auto-config گرفته تا listenerها — در آن قاطی بود؛ برای پیداکردن هر چیز باید کل دفتر را میخواند و هر ردیف را باز میکرد. حالا Boot یک فهرست بار مخصوص دارد: یک فایل ساده که هر خطش نام یک کلاس کاندید است. سریعتر خوانده میشود و مرتبتر است.
در Boot 2.7 این مکانیزم جابهجا شد و در Boot 3 مسیر قدیمی بهکلی حذف شده است. کاندیدهای auto-configuration اکنون یککلاسدرهرخط در این فایل فهرست میشوند:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.acme.audit.AuditAutoConfiguration
com.acme.ratelimit.RateLimitAutoConfiguration
کلید قدیمی EnableAutoConfiguration= در META-INF/spring.factories دیگر برای auto-configuration خوانده نمیشود. (spring.factories هنوز برای نقاط توسعهٔ دیگر مثل ApplicationListener وجود دارد، اما نه برای auto-config.) چرا این تغییر خوب بود؟ چون هزینهٔ راهاندازی را کم کرد — Boot دیگر مجبور نیست یک فایل properties بزرگ را پارس کند و هر کلاس فهرستشده را فقط برای مرتبسازی نمونهسازی کند؛ فایل اختصاصی ارزانتر خوانده میشود و ترتیب را خودِ annotationها حمل میکنند.
annotation به نام @AutoConfiguration
اگر بخواهی خودت یک کتابخانهٔ auto-config بنویسی (مثلاً یک starter داخلی برای شرکتات)، کلاس را با @AutoConfiguration علامت میزنی، نه @Configuration ساده:
@AutoConfiguration(after = DataSourceAutoConfiguration.class) // اشارهٔ ترتیب
@ConditionalOnClass(RedisTemplate.class) // فقط اگر Redis روی classpath باشد
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean // عقبنشینی اگر اپ خودش یکی تعریف کرده
@ConditionalOnProperty(prefix = "acme.ratelimit", name = "enabled",
havingValue = "true", matchIfMissing = true)
RateLimiter rateLimiter(RateLimitProperties props) {
return new TokenBucketRateLimiter(props.getCapacity(), props.getRefill());
}
}
بیایید این را خطبهخط بخوانیم مثل یک جمله: «این پیکربندی را بعد از DataSourceAutoConfiguration اجرا کن؛ فقط اگر کلاس RedisTemplate روی classpath بود؛ یک RateLimiter بساز، اما فقط اگر کاربر خودش یکی نساخته و اگر property بهنام acme.ratelimit.enabled روشن بود (و اگر اصلاً تنظیم نشده، پیشفرض روشن باشد).» میبینی چقدر همهچیز شرطی است؟
نکتهٔ ظریف مهم: @AutoConfiguration بهطور ضمنی معادل @Configuration(proxyBeanMethods = false) است. این یعنی هر متد @Bean یک factory سبک است، نه یک proxy. «proxy» اینجا یعنی Spring دور کلاست یک پوستهٔ نامرئی میکشد تا رفتارها را رهگیری کند. با proxyBeanMethods = false این پوسته نیست: راهاندازی سریعتر است، اما دیگر نمیتوانی یک متد @Bean را از داخل متد @Bean دیگر صدا بزنی و انتظار همان singletonِ کششده را داشته باشی — یک نمونهٔ تازه میگیری. علاوه بر این، @AutoConfiguration ویژگیهای ترتیب before/after/beforeName/afterName را هم حمل میکند. چرا ترتیب مهم است؟ چون @ConditionalOnMissingBean یک auto-config بعدی باید بتواند beanهایی را که auto-configهای قبلی ساختهاند ببیند، وگرنه اشتباه تصمیم میگیرد.
فهرست شرطها
این جدول را مثل جعبهابزار شرطها ببین؛ هرکدام «چهوقت آتش میکند» را میگوید:
| Annotation | زمان فعالشدن |
|---|---|
@ConditionalOnClass / @ConditionalOnMissingClass |
کلاسی روی classpath هست / نیست |
@ConditionalOnBean / @ConditionalOnMissingBean |
یک bean قبلاً ثبت شده / نشده |
@ConditionalOnProperty |
یک property مقدار معینی دارد (matchIfMissing پیشفرض را کنترل میکند) |
@ConditionalOnWebApplication |
context از نوع servlet/reactive/none است |
@ConditionalOnResource |
منبعی وجود دارد |
@ConditionalOnExpression |
یک عبارت SpEL درست است |
@ConditionalOnMissingBean (مهمترین) |
به کاربران اجازه میدهد هر پیشفرض را override کنند |
@ConditionalOnBean/@ConditionalOnMissingBean فقط روی beanهایی کار میکنند که تا آن لحظه ثبت شدهاند. داخل auto-configuration امناند، چون auto-config آخر از همه اجرا میشود — بعد از @ComponentScan تو. اما در @Configuration خودِ اپلیکیشن شکنندهاند، چون ترتیب ثبت bean میان configهای تو تضمینشده نیست؛ ممکن است شرط گاهی درست و گاهی غلط ارزیابی شود و باگهای عجیبِ «گاهی هست، گاهی نیست» بسازد. قاعدهٔ سرانگشتی: @ConditionalOnMissingBean را در کدِ کتابخانه/auto-config بگذار، نه در کد اپلیکیشن.
دیباگِ آنچه پیکربندی شد
وقتی میپرسی «چرا این bean/endpoint اصلاً ساخته نشد؟»، این تکنیک نجاتت میدهد:
# گزارش «CONDITIONS EVALUATION REPORT» را چاپ میکند: تطبیقهای مثبت، منفی، و استثناها
debug=true
یا endpoint بهنام conditions در Actuator را بزن (بعداً دربارهاش حرف میزنیم).
گزارش CONDITIONS EVALUATION REPORT به تو نشان میدهد کدام شرط عبور کرد (Positive) و کدام شکست خورد (Negative) و چرا. وقتی چیزی سیمکشی نشده، بهجای حدسزدن، این گزارش را بخوان؛ دقیقاً میگوید کدام @ConditionalOn... مانع شده. این تنها راهِ سریعِ پاسخ به «چرا این bean اینجا نیست؟» است.
Starterها
یک starter مثل یک کیتِ آمادهٔ ساخت است: بهجای اینکه بروی و ده کتابخانهٔ سازگار را جداجدا با نسخهٔ درست انتخاب کنی، یک بسته میگیری که همه را با هم میآورد. خود کیت هیچ کدی ندارد؛ فقط فهرست خرید است.
دقیقتر: یک starter یک jarِ صرفاً وابستگی است (یک pom.xml منتخب، بدون کد) که یک مجموعهٔ منسجم از کتابخانهها را میکشد. مثلاً spring-boot-starter-web مجموعهٔ Spring MVC، Jackson، validation و Tomcat جاسازیشده را میآورد. توجه به تقسیم کار: starter فقط jarها را فراهم میکند؛ کلاسهای auto-config درونِ آن jarها هستند که سیمکشی را انجام میدهند. همترازی نسخهها از یک BOM (فهرست نسخههای هماهنگ) بهنام spring-boot-dependencies میآید که parent POM آن را وارد میکند — به همین دلیل starterها را بدون شماره نسخه فهرست میکنی و باز هم نسخهها با هم جور در میآیند.
پیکربندی بیرونیشده (externalized) و پروفایلها
قاعدهٔ خوبِ نرمافزار این است که تنظیمات (مثل پورت یا رمز پایگاهداده) نباید داخل کد سفتوسخت (hard-code) باشند، بلکه از بیرون بیایند تا بدون کامپایل مجدد قابلتغییر باشند. به این میگویند پیکربندی externalized (بیرونیشده).
تصور کن یک دیوار را چند بار رنگ میکنی: اول یک لایهٔ پایه، بعد لایههای تازهتر رویش. هر لایهٔ جدید، لایهٔ زیرین را میپوشاند. propertyهای Spring هم همینطورند: منابع مختلف روی هم لایه میخورند و لایهٔ بالاتر (منبع بعدی) برنده است.
Boot propertyها را از این فهرستِ مرتب PropertySource حل میکند (بالاتر = قویتر):
۱. پیشفرضهای Devtools
۲. @TestPropertySource / propهای تست
۳. آرگومانهای خط فرمان (--server.port=9000)
۴. SPRING_APPLICATION_JSON
۵. متغیرهای محیطی OS (SERVER_PORT=9000) ← relaxed binding
۶. propertyهای سیستمی Java (-Dserver.port=9000)
۷. application-{profile}.yml / .properties
۸. application.yml / .properties
۹. @PropertySource
۱۰. propهای پیشفرض
آن اصطلاح relaxed binding (اتصال آسانگیر) یعنی Spring در نام property سختگیر نیست: server.port، SERVER_PORT، server_port و serverPort همه به یک property یکسان متصل میشوند. متغیرهای محیطی از شکل بزرگ با underscore استفاده میکنند (SERVER_PORT) — این برای containerها حیاتی است، چون سیستمعامل معمولاً فقط این شکل را میپذیرد.
پیکربندی type-safe با @ConfigurationProperties
بهجای اینکه @Value("${...}") را در همهجای کد پخش کنی، بهتر است تنظیمات مرتبط را در یک کلاس جمع کنی. به این میگویند type-safe یعنی هر مقدار نوع مشخص و بررسیشده دارد. یکبار bind میشود، validate میشود و قابل refactor است:
@ConfigurationProperties(prefix = "acme.ratelimit")
@Validated
public record RateLimitProperties(
@Positive int capacity,
@DurationUnit(ChronoUnit.SECONDS) Duration refill) {
public RateLimitProperties {
// رکوردها از پیشفرض در سازندهٔ فشرده پشتیبانی میکنند
if (refill == null) refill = Duration.ofSeconds(1);
}
}
با @EnableConfigurationProperties(RateLimitProperties.class) (که auto-configها خودشان انجام میدهند) یا @ConfigurationPropertiesScan فعالش میکنی. استفاده از record (نوع دادهٔ تغییرناپذیر Java) به تو پیکربندیِ immutable و متصلبهسازنده را رایگان میدهد — یعنی مقادیر بعد از ساخت عوض نمیشوند و از خطای تصادفی جلوگیری میشود.
پروفایلها
Profile یعنی یک مجموعهٔ نامگذاریشده از تنظیمات برای یک محیط خاص — مثلاً dev (توسعه) و prod (تولید). یک فایل، چند حالت:
# application.yml
spring:
config:
activate:
on-profile: prod # روش Boot 2.4+ (سند چندبخشی با ---)
server:
port: 8080
---
spring:
config:
activate:
on-profile: dev
logging:
level:
root: DEBUG
با --spring.profiles.active=prod یا SPRING_PROFILES_ACTIVE=prod فعالش میکنی. روی beanها هم میتوانی @Profile("prod") بگذاری تا فقط در آن محیط ساخته شوند.
جداکنندهٔ سند بهسبک قدیمی spring.profiles: در نسخهٔ 2.4 تغییر کرد — حتماً از spring.config.activate.on-profile استفاده کن، نه شکل قدیمی. نکتهٔ دوم: فایلهای مخصوص پروفایل به فایل پایه افزوده میشوند، نه اینکه جایگزینش شوند. یعنی مقادیر پایه سرِ جایشان میمانند مگر اینکه پروفایل صریحاً overrideشان کند.
ساختن لایهٔ REST
حالا برویم سراغ چیزی که کاربر نهایی میبیند: خودِ API.
مبانی کنترلر
controller کلاسی است که درخواستهای HTTP ورودی را میگیرد و پاسخ میدهد. آن را مثل پیشخوان یک اداره ببین: مراجع میآید، درخواستش را میدهد، و پیشخوان جواب را برمیگرداند.
@RestController // = @Controller + @ResponseBody روی هر متد
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
@GetMapping("/{id}")
public OrderDto get(@PathVariable long id) {
return service.find(id); // توسط Jackson (HttpMessageConverter) به JSON سریال میشود
}
@GetMapping
public Page<OrderDto> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return service.list(PageRequest.of(page, size));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest req,
UriComponentsBuilder uri) {
OrderDto created = service.create(req);
URI location = uri.path("/api/orders/{id}").build(created.id());
return ResponseEntity.created(location).body(created); // 201 + هدر Location
}
}
آن @RestController معادل @Controller + @ResponseBody روی هر متد است؛ یعنی «خروجی هر متد را مستقیم بهعنوان بدنهٔ پاسخ برگردان (نه یک صفحهٔ HTML)». اصطلاح serialize (سریالسازی) یعنی تبدیل یک شیء Java به یک قالب قابلانتقال مثل JSON؛ کتابخانهای که این کار را میکند Jackson است و از طریق یک HttpMessageConverter (مبدّل پیام) کار میکند.
نکاتی که یک مهندس ارشد باید درونی کند:
- شیء domain/DTO را مستقیم برگردان و بگذار
HttpMessageConverter(Jackson) سریالش کند. فقط وقتی سراغResponseEntityبرو که نیاز داری status، هدرها یا بدنه را بهصورت شرطی کنترل کنی. - گونههای ترکیبی
@RequestMapping(یعنی@GetMapping، @PostMapping، …) علاوه بر مسیر، ویژگیهایproduces/consumesرا برای content negotiation وparams/headersرا برای مسیریابی دقیقتر هم حمل میکنند. - هرگز entityها را افشا نکن. (entity یعنی کلاسی که مستقیم به جدول پایگاهداده نگاشت شده.)
سریالکردن یک entity از JPA سه خطر دارد: (۱) خطر LazyInitializationException — چون بعضی دادهها «تنبل» بارگذاری میشوند و وقتی Jackson بیرون از تراکنش به آنها دست میزند، دیگر منبع باز نیست و انفجار رخ میدهد؛ (۲) درزِ schema پایگاهداده به کلاینت؛ (۳) جفتشدن سفتِ قرارداد API با ساختار DB، طوری که هر تغییر جدول، API را میشکند. راهحل: به DTO نگاشت کن (یک شیء سادهٔ انتقال داده)؛ recordها برای این کار ایدهآلاند.
اعتبارسنجی با Bean Validation
validation یعنی بررسی اینکه دادهٔ ورودی از کلاینت معتبر است پیش از اینکه به آن اعتماد کنی. Boot 3 از Jakarta Bean Validation (jakarta.validation.*) استفاده میکند، و starter آن (spring-boot-starter-validation) کتابخانهٔ Hibernate Validator را میکشد.
public record CreateOrderRequest(
@NotBlank String customerEmail,
@NotEmpty @Valid List<LineItem> items, // @Valid به داخل هر عنصر آبشار میشود
@Positive BigDecimal total) {
public record LineItem(@NotBlank String sku, @Min(1) int qty) {}
}
آن @Valid روی لیست را «آبشار» (cascade) میگویند: validation را به داخل هر عنصرِ لیست هم میبرد، نه فقط خود لیست. وقتی @Valid را روی پارامتر @RequestBody بگذاری، اعتبارسنجی پیش از اجرای بدنهٔ متد فعال میشود؛ اگر شکست بخورد، MethodArgumentNotValidException پرتاب میشود (که Boot به کد وضعیت 400 نگاشت میکند).
اما برای اعتبارسنجیِ تکتکِ پارامترهای ساده مثل @RequestParam/@PathVariable، کار فرق میکند: باید کلاس را با @Validated علامت بزنی و قیدها را مستقیم روی پارامترها بگذاری. اینها استثنای متفاوتی، یعنی ConstraintViolationException، پرتاب میکنند:
@RestController
@Validated // اعتبارسنجی سطحمتد پارامترها را فعال میکند
public class SearchController {
@GetMapping("/search")
public List<Hit> search(@RequestParam @Size(min = 3) String q,
@RequestParam @Max(100) int limit) { ... }
}
این دو را مردم مدام قاطی میکنند: @Valid (از Jakarta) برای آبشار به داخل گراف اشیاست و شکستش MethodArgumentNotValidException (→400) میدهد. @Validated (از Spring، سطحکلاس) برای گروههای اعتبارسنجی و اعتبارسنجی سطحمتد پارامترها است و شکستش ConstraintViolationException میدهد. باگ رایج: فقط برای اولی handler مینویسی و دومی بدون مدیریت میماند، پس بهجای 400 یک 500 زشت به کلاینت میرود.
مدیریت استثنا و ProblemDetail طبق RFC 9457
تصور کن هر ادارهٔ دولتی خطاهایش را با یک فرم دلبخواهی و متفاوت گزارش میداد؛ مراجع هیچوقت نمیدانست کجای فرم را بخواند. حالا فرض کن یک فرم شکایت استاندارد ملی تعریف کنند با خانههای ثابت: «نوع خطا»، «عنوان»، «کد»، «توضیح»، «کجا رخ داد». هرکس هر خطایی بدهد، همان قالب را پر میکند. این دقیقاً کاری است که RFC 9457 برای APIها میکند.
Spring Framework 6 استاندارد RFC 9457 (که قبلاً RFC 7807 بود) با عنوان «Problem Details for HTTP APIs» را پیاده کرده است. ProblemDetail همان بدنهٔ استاندارد خطاست:
{
"type": "https://api.acme.com/problems/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "SKU ABC-1 has 2 units, 5 requested",
"instance": "/api/orders",
"sku": "ABC-1"
}
فعالسازی سراسری با یک property:
spring.mvc.problemdetails.enabled=true # باعث میشود استثناهای داخلی Spring MVC، ProblemDetail برگردانند
حالا مدیریت خطا را در یک نقطه متمرکز کن. @RestControllerAdvice یک کلاس مرکزی است که خطاهای همهٔ کنترلرها را یکجا میگیرد. اگر از ResponseEntityExceptionHandler ارث ببری، برای همهٔ استثناهای داخلی Spring MVC (نوع رسانهٔ پشتیبانینشده، بدنهٔ ناخوانا، اعتبارسنجی و…) هم پاسخهای RFC-9457 میگیری:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(InsufficientStockException.class)
ProblemDetail handleStock(InsufficientStockException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create("https://api.acme.com/problems/insufficient-stock"));
pd.setTitle("Insufficient stock");
pd.setProperty("sku", ex.getSku()); // عضو توسعه، توسط Jackson mixin در سطح بالا رندر میشود
return pd;
}
// override برای سفارشیسازی رندرِ خطاهای bean-validation به ProblemDetail
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage)));
return ResponseEntity.badRequest().body(pd);
}
}
آن pd.setProperty("sku", ...) یک «عضو توسعه» (extension member) اضافه میکند: یک فیلد سفارشی فراتر از فیلدهای استاندارد، که یک Jackson mixin آن را به سطح بالای JSON باز میکند (نه تودرتو).
چند گزینهٔ دیگر هم داری: تمیزترین رویکرد برای استثناهای خودت این است که ErrorResponse را پیادهسازی کنی (یا ErrorResponseException پرتاب کنی) تا خودِ استثنا نگاشت HTTP خود را حمل کند — دیگر نیازی به advice نیست. @ControllerAdvice/@RestControllerAdvice را میتوانی با basePackages، assignableTypes یا annotations محدود کنی وقتی چند advice داری. و @ResponseStatus روی یک استثنای سفارشی گزینهٔ کمتشریفات است، اما نمیتواند بدنه تنظیم کند.
مذاکرهٔ محتوا (content negotiation)
content negotiation یعنی توافق بین کلاینت و سرور بر سر قالب پاسخ (JSON یا XML یا …). کلاینت میگوید «من این قالبها را میفهمم»، سرور بهترینِ ممکن را انتخاب میکند.
Spring قالب پاسخ را با مشورت به این ترتیب انتخاب میکند: پسوند مسیر (در Boot 3+ بهطور پیشفرض غیرفعال)، یک پارامتر query بهنام format (اختیاری، باید روشنش کنی)، و در نهایت هدر Accept. سپس نوعهای رسانهٔ درخواستی را با produces کنترلر و HttpMessageConverterهای ثبتشده تطبیق میدهد و بهترین converter را برمیگزیند.
@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public OrderDto get(@PathVariable long id) { ... } // Accept: application/xml → XML اگر Jackson-XML باشد
Accept: application/json → Jackson. با افزودن jackson-dataformat-xml، مذاکرهٔ XML روشن میشود. تنظیم از این راه:
spring.mvc.contentnegotiation.favor-parameter=true # اجازهٔ ?format=xml
برای رسانهٔ نسخهدار یا vendor از نوعهای سفارشی مثل application/vnd.acme.v2+json استفاده کن و با produces مسیریابی کن.
زنجیرهٔ درخواست: filter در برابر interceptor در برابر argument resolver
تصور کن وارد یک ساختمان بزرگ میشوی: اول نگهبان درِ ورودی هرکسی را که میآید بازرسی میکند (این filter است — روی همه اعمال میشود، حتی قبل از اینکه بداند کجا میروی). بعد جلوی درِ یک بخش خاص، مسئول همان بخش بلیت مخصوص همان بخش را چک میکند (این interceptor است — میداند به کدام دفتر میروی). و بالاخره منشیِ داخل اتاق، بر اساس کارت شناساییات پروندهٔ آمادهٔ تو را روی میز میگذارد (این argument resolver است — دادهٔ خام را به چیزی آمادهٔ استفاده تبدیل میکند).
این سه در لایههای متفاوت زندگی میکنند، و دانستن اینکه کدام را کِی استفاده کنی یک تمایز کلاسیک سطح ارشد است:
Servlet container
└─ زنجیرهٔ Filter ← لایهٔ servlet، بستهبندی req/res، برای هر درخواست اجرا میشود
└─ DispatcherServlet
└─ HandlerInterceptor ← لایهٔ Spring MVC، handler را میشناسد، فقط درخواستهای نگاشتشده
└─ متد کنترلر
↑ HandlerMethodArgumentResolver هر پارامتر متد را میسازد
| دغدغه | استفاده کنید از |
|---|---|
| موضوع cross-cutting روی همهٔ ترافیک (auth، CORS، gzip، لاگ درخواست، MDC/trace id، بستهبندی stream) | Filter (jakarta.servlet.Filter / OncePerRequestFilter) |
منطق وابسته به handler (بررسی auth بهازای هر mapping، زمانسنجی، تغییر ModelAndView، توقف پیش از کنترلر) |
HandlerInterceptor |
تبدیل دادهٔ درخواست به یک پارامتر متد (@CurrentUser، tenant id از هدر، bindingِ سفارشی) |
HandlerMethodArgumentResolver |
(اصطلاح cross-cutting یعنی دغدغهای که همهجا تکرار میشود و مخصوص یک نقطه نیست — مثل لاگگیری یا احراز هویت.)
// FILTER — یکبار در هر درخواست، پیش از مسیریابی Spring MVC
public class TraceIdFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("traceId", traceId);
try { chain.doFilter(req, res); } // برای ادامه، حتماً chain را صدا بزنید
finally { MDC.clear(); } // پاکسازی thread-local، همیشه
}
}
توجه: در فیلتر باید chain.doFilter(...) را صدا بزنی وگرنه درخواست همانجا میماند و به کنترلر نمیرسد. MDC هم یک انبار دادهی چسبیده به نخِ جاری است که برای لاگ استفاده میشود؛ در finally پاکش میکنی تا برای درخواست بعدی روی همان نخ نشت نکند.
// INTERCEPTOR — handler را دارد؛ با بازگرداندن false میتواند زنجیره را قطع کند
public class AuthInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
if (handler instanceof HandlerMethod hm && hm.hasMethodAnnotation(Secured.class)
&& !isAuthenticated(req)) {
res.setStatus(401);
return false; // توقف زنجیره؛ کنترلر هرگز اجرا نمیشود
}
return true;
}
}
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry r) {
r.addInterceptor(new AuthInterceptor()).addPathPatterns("/api/**");
}
@Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserResolver());
}
}
نکتهٔ کلیدی interceptor این است که preHandle میتواند false برگرداند و کل زنجیره را قطع کند — کنترلر اصلاً اجرا نمیشود. برخلاف فیلتر، اینجا شیء handler (یعنی HandlerMethod) در دست توست، پس میدانی دقیقاً کدام متد قرار است اجرا شود و چه annotationهایی دارد.
// ARGUMENT RESOLVER — یک شیء domain ساختهشده از درخواست را تزریق میکند
public class CurrentUserResolver implements HandlerMethodArgumentResolver {
@Override public boolean supportsParameter(MethodParameter p) {
return p.hasParameterAnnotation(CurrentUser.class);
}
@Override public Object resolveArgument(MethodParameter p, ModelAndViewContainer mav,
NativeWebRequest req, WebDataBinderFactory binder) {
return userService.fromToken(req.getHeader("Authorization"));
}
}
دو تفاوت که مردم را میلغزاند: (۱) فیلترها نمیتوانند ببینند کدام کنترلر درخواست را مدیریت خواهد کرد یا annotationهای آن چیست، چون پیش از مسیریابی اجرا میشوند؛ اما interceptorها میتوانند، چون HandlerMethod را دریافت میکنند. (۲) استثنایی که در یک filter پرتاب شود از @ControllerAdvice عبور میکند و گرفته نمیشود — چون هنوز هیچ کنترلری در کار نیست. باید آن را در خود فیلتر یا از طریق صفحهٔ خطا مدیریت کنی. ضمناً ترتیب فیلترها با @Order/FilterRegistrationBean تعیین میشود، ولی ترتیب interceptorها بهترتیب ثبت است.
Actuator و مشاهدهپذیری (observability)
Actuator مجموعهای از endpointهای آماده برای دیدن سلامت و وضعیت اپ در محیط تولید است — مثل داشبورد و چراغهای یک اتاق کنترل. spring-boot-starter-actuator اینها را زیر مسیر /actuator اضافه میکند.
در Boot 3 فقط /actuator/health بهطور پیشفرض روی HTTP افشا میشود؛ بقیه را باید صریحاً opt-in کنی. این حالتِ «امن بهطور پیشفرض» عمدی است: چون endpointهایی مثل /env و /configprops میتوانند secret درز دهند و /heapdump میتواند راه حملهٔ DoS (خالیکردن منابع سرور) باز کند.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
endpoint:
health:
show-details: when-authorized # هرگز سلامت اجزا را به فراخوان ناشناس درز ندهید
metrics:
tags:
application: shop-api
endpointهای کلیدی: health (گروههای liveness/readiness به probeهای Kubernetes نگاشت میشوند — یعنی k8s با آنها میفهمد اپ زنده و آمادهٔ ترافیک است)، metrics/prometheus (سنجهها از طریق Micrometer)، loggers (تغییر سطح لاگ در زمان اجرا، بدون redeploy!)، env، configprops، conditions (همان گزارش auto-config که بالاتر گفتیم)، httpexchanges، threaddump، heapdump.
مشاهدهپذیری در Boot 3 روی Micrometer Observation ساخته شده است. ایدهٔ زیبا این است: یک API واحد Observation هم metric (عدد و آمار) و هم trace (ردِ مسیر یک درخواست در سیستم) را با هم تولید میکند. با افزودن micrometer-tracing-bridge-otel و یک exporter، هر کنترلر، هر RestClient و هر متد @Observed با تگهای سازگار، span تولید میکند (span یعنی یک بازهٔ زماندار در سفر یک درخواست).
سرور جاسازیشده (embedded)
قدیمها باید یک سرور جداگانه (مثل Tomcat) نصب میکردی و فایل WAR برنامه را در آن deploy میکردی. Spring Boot این را وارونه کرد: سرور را داخل خود اپ جاسازی میکند.
تصور کن یک وسیله برقی داری که باید همیشه دنبال پریز دیوار (سرور بیرونی) بگردی. حالا تصور کن همان وسیله یک باتری و موتور توکار دارد و هرجا فقط با فشار یک دکمه روشن میشود. java -jar app.jar همان فشار آن دکمه است: سرور داخل خودِ فرایند بالا میآید.
Boot 3 بهطور پیشفرض Tomcat 10.1 را جاسازی میکند (Jetty و Undertow جایگزینهای drop-inاند — کافی است spring-boot-starter-tomcat را exclude کنی و starter دیگر را اضافه کنی). تنظیمهای کلیدی:
server.port=8080
server.tomcat.threads.max=200 # نخهای کارگر (مدل blocking)
server.tomcat.accept-count=100 # صف accept سیستمعامل وقتی همهٔ نخها مشغولاند
server.tomcat.connection-timeout=20s
server.compression.enabled=true
server.shutdown=graceful # تخلیهٔ درخواستهای در جریان هنگام خاموشی
spring.lifecycle.timeout-per-shutdown-phase=30s
آن server.shutdown=graceful (خاموشیِ محترمانه) یعنی وقتی اپ میخواهد خاموش شود، اول اجازه میدهد درخواستهای در حال پردازش تمام شوند، نه اینکه وسطشان قطع کند.
Virtual threads (Java 21+): مدل سنتی Tomcat یک استخر محدود از نخها دارد (مثلاً ۲۰۰ تا)؛ اگر همه مشغولِ منتظر ماندن برای دیتابیس باشند، درخواست جدید در صف میماند. virtual thread یک نخِ فوقسبک است که JVM مدیریتش میکند و میتوانی میلیونها از آن داشته باشی. با یک property فعالش میکنی:
spring.threads.virtual.enabled=true # نیازمند Java 21+
virtual threadها فقط به بارِ کاری blocking کمک میکنند (JDBC، HTTP مسدودکننده). اما مراقب pinning («سنجاقشدن») باش: وقتی یک virtual thread داخل بلوک synchronized مسدود شود، نخِ حاملِ واقعیاش (carrier) را به خودش سنجاق میکند و آزاد نمیکند — درست همان چیزی که میخواستیم از آن فرار کنیم. راهحل: در مسیرهای داغ بهجای synchronized از ReentrantLock استفاده کن. و هرگز virtual threadها را pool نکن؛ برای هر task یکی تازه بساز — ساختنشان ارزان است.
دامها و نکات ظریف رایج
اینها را مثل چکلیستِ «اشتباهات پرتکرار» نگه دار:
- افشای entityهای JPA ←
LazyInitializationException، N+1، درز schema. همیشه به DTO نگاشت کن. @ConditionalOnMissingBeanدر کد اپ ← وابسته به ترتیب، شکننده. آن را در کتابخانه/auto-config نگه دار.- فراموشکردن
ConstraintViolationExceptionهنگام اعتبارسنجی پارامتر با@Validated— این همانMethodArgumentNotValidExceptionنیست، پس یک advice سادهانگارانه 500 برمیگرداند بهجای 400. @RequestParamرویboolean/primitive با پارامتر غایب ← 400/500. از نوع wrapper + required=false/defaultValueاستفاده کن.@Transactionalروی کنترلر — فنی کار میکند اما لایهبندی را مخدوش میکند؛ تراکنشها را در service نگه دار.- تزریق به خود / صدازدن متد داخلی که proxy را دور میزند — صدازدن یک متد
@Transactionalیا@Cacheableاز همان کلاس، proxyِ AOP را رد میکند. همچنین دلیل اهمیتproxyBeanMethods=falseدر@AutoConfiguration. - CORS از طریق filter و
@CrossOrigin← هدرهای تکراری/متناقض. یکی را انتخاب کن؛ پیکربندی متمرکز را ترجیح بده. - افشای همهٔ endpointهای Actuator (
include: "*") در production ← افشای اطلاعات و DoS. - تکیه بر ارزانبودن classpath scanning —
@ComponentScanروی پکیجهای بزرگ راهاندازی را کند میکند؛ پکیجهای پایه را فشرده نگه دار.
بهترینروشها
- DTOها بهصورت
record؛ در لبه validate کن؛ هرگز به کلاینت اعتماد نکن. - برای هر خطا
ProblemDetailبرگردان؛ به هر problem یکtypeURI پایدار بده. - یک
@RestControllerAdviceکه ازResponseEntityExceptionHandlerارث میبرد برای کل اپ؛ موارد اضافی را فقط در صورت نیاز محدود کن. - رکوردهای
@ConfigurationPropertiestype-safe بهجای@Valueپراکنده. - Actuator را امن و انتخابی افشا کن؛ گروههای health را به probeهای k8s وصل کن.
-
server.shutdown=gracefulرا روشن کن وpreStop/terminationGracePeriodcontainer را متناسب تنظیم کن. - اگر گلوگاه تو I/O مسدودکننده است و تیم کد امری (imperative) میداند، پیش از رفتن سراغ reactive، virtual threadها (Java 21+) را در نظر بگیر.
سؤالات مصاحبه
حالا وقت آن است که همهچیز را در قالب پرسشهای واقعی مصاحبه محکم کنیم. هر پرسش را اول خودت جواب بده، بعد پاسخ را بخوان.
AutoConfigurationImportSelector را import میکند که فایل META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (یک کلاس در هر خط) را از هر jar میخواند. کلید قدیمی EnableAutoConfiguration= در spring.factories دیگر برای auto-config در Boot 3 استفاده نمیشود. هر کلاس کاندید با @Conditional محافظت میشود که در برابر classpath، beanهای موجود و propertyها ارزیابی میشود؛ آنهایی که تطبیق دارند اعمال میشوند.
عقبنشینی محترمانه را پیاده میکند: auto-config بعد از component scan تو اجرا میشود، پس اگر beanای از آن نوع تعریف کرده باشی، شرط auto-config شکست میخورد و Boot تو را override نمیکند. همین است که به Boot اجازه میدهد پیشفرض بدهد بدون اینکه هرگز با پیکربندی صریح بجنگد.
@AutoConfiguration بهطور ضمنی @Configuration(proxyBeanMethods=false) بهعلاوهٔ ویژگیهای ترتیب (before/after) است. با proxyBeanMethods=false، متدهای @Bean توسط CGLIB پروکسی نمیشوند، پس صدازدن یک متد @Bean از دیگری یک نمونهٔ جدید برمیگرداند نه singletonِ مشترک — باید وابستگیها را بهجای آن بهعنوان پارامتر متد تزریق کنی. برای سرعت راهاندازی انتخاب شده؛ برای auto-config امن است چون آن کلاسها بهندرت صدازدن متد بینbean دارند.
@Valid (Jakarta) اعتبارسنجی را به داخل گراف شیء آبشار میکند و روی @RequestBody، MethodArgumentNotValidException (→400) پرتاب میکند. @Validated (Spring) از گروههای اعتبارسنجی پشتیبانی میکند و وقتی روی کلاس کنترلر گذاشته شود، اعتبارسنجی سطحمتد @RequestParam/@PathVariable را فعال میکند که ConstraintViolationException پرتاب میکند. باگ رایج: مدیریت فقط اولی و بازگرداندن 500 برای دومی.
نه. فیلترها در لایهٔ servlet پیش از مسیریابی DispatcherServlet به کنترلر اجرا میشوند، پس نه handler هست و نه @ControllerAdvice در حوزه. استثنا به مدیریت خطای container (یا صفحهٔ /error) منتشر میشود. آن را داخل filter یا از طریق یک ErrorController مدیریت کن.
Filter: موضوع cross-cutting روی هر درخواست، میتواند stream را بستهبندی کند، از handler هدف بیاطلاع است. Interceptor: آگاه به Spring MVC، HandlerMethod را دارد، میتواند در preHandle قطع کند، فقط برای درخواستهای نگاشتشده اجرا میشود. ArgumentResolver: دادهٔ درخواست را به یک پارامتر متد مشخص کنترلر تبدیل میکند.
بهطور پیشفرض Boot 3 تطبیق پسوند مسیر را غیرفعال میکند؛ از هدر Accept استفاده میکند (اختیاراً یک پارامتر query بهنام format اگر فعال باشد)، نوعهای رسانهٔ درخواستی را با produces کنترلر و HttpMessageConverterهای ثبتشده تطبیق میدهد و بهترین converter را برمیگزیند. XML نیازمند یک converter از نوع Jackson-XML (یا JAXB) روی classpath است.
بدنهٔ استاندارد خطای «Problem Details for HTTP APIs» است (type، title، status، detail، instance بهعلاوهٔ توسعهها). spring.mvc.problemdetails.enabled=true را برای استثناهای داخلی تنظیم کن، از @ExceptionHandlerها ProblemDetail/ErrorResponse برگردان، و در یک @RestControllerAdvice از ResponseEntityExceptionHandler ارث ببر تا استثناهای Spring MVC بهصورت ProblemDetail رندر شوند. اعضای توسعهای که با setProperty تنظیم میشوند توسط یک Jackson mixin به سطح بالای JSON باز (unwrap) میشوند.
فقط health. بقیه (env، configprops، metrics، heapdump، loggers، …) باید صریحاً به management.endpoints.web.exposure.include اضافه شوند. حالت امن-بهطور-پیشفرض از افشای اطلاعات (/env میتواند secret درز دهد) و DoS (/heapdump) جلوگیری میکند.
@Service
class OrderService {
@Transactional
public void placeOrder(Order o) { save(o); notify(o); }
@Transactional(propagation = REQUIRES_NEW)
public void notify(Order o) { /* audit */ }
}
notify از طریق this.notify(...) صدا زده میشود، یک صدازدن متد داخلی که proxyِ AOPِ Spring را دور میزند، پس REQUIRES_NEW نادیده گرفته میشود و در همان تراکنش اجرا میشود — نه تراکنش جدیدی، و یک rollback در audit، سفارش را هم rollback میکند. اصلاح: notify را به bean دیگری ببر، proxy را به خود تزریق کن، یا از TransactionTemplate استفاده کن.
@RestController
class C {
@GetMapping("/x") String x(@RequestParam boolean flag) { return "" + flag; }
}
// GET /x (بدون پارامتر flag)
400 Bad Request برمیگرداند. boolean primitive است و @RequestParam بهطور پیشفرض required=true است؛ پارامتر required غایب، MissingServletRequestParameterException ← 400 میدهد. با Boolean + required=false یا defaultValue="false" اصلاح کن.
7000. آرگومانهای خط فرمان از متغیرهای محیطی بالاترند، که آنها از application.yml بالاترند. توجه به relaxed binding: SERVER_PORT به server.port متصل میشود.
Pinning نخ: یک virtual thread که هنگام نگهداشتن monitor بهصورت synchronized مسدود شود (رایج در درایورهای قدیمی JDBC، logging یا کد legacy) نخ حاملِ platformِ خود را pin میکند و زمانبند را خنثی میکند. در مسیرهای داغ synchronized را با ReentrantLock جایگزین کن، درایورها را ارتقا بده، و با -Djdk.tracePinnedThreads=full مانیتور کن.
سریالسازی پس از بستهشدن تراکنش/EntityManager رخ میدهد، پس دستزدن به یک association تنبل، LazyInitializationException پرتاب میکند؛ همچنین کوئریهای N+1 را تحریک میکند، schema تو را به کلاینت درز میدهد و قرارداد API را به مدل persistence جفت میکند. صریحاً به DTO (رکورد) نگاشت کن.
گزارش CONDITIONS EVALUATION REPORT را چاپ میکند: Positive matches (auto-configهایی که اعمال شدند و چرا)، Negative matches (ردشدهها، با شرط شکستخورده)، و Exclusions/کلاسهای بدونشرط. سریعترین راه برای پاسخ به «چرا این bean اینجا هست/نیست؟» — میخوانی که کدام @ConditionalOn... عبور کرد یا شکست خورد.
Spring Boot یک بستهبندی نظردار از Spring است که روی دو ستون میایستد: شرطها (@Conditional...) تصمیم میگیرند چه beanای روشن شود، و عقبنشینی (@ConditionalOnMissingBean) تضمین میکند هرجا تو حرف زدی Boot ساکت شود. در Boot 3 کاندیدهای auto-config در فایل AutoConfiguration.imports فهرست میشوند (نه spring.factories)، و کل فرایند را با debug=true یا endpoint conditions میتوانی دیباگ کنی. برای REST: کنترلر شیء/DTO برمیگرداند و Jackson سریالش میکند (هرگز entity نه!)، اعتبارسنجی با @Valid (آبشار، 400) و @Validated (پارامترها، ConstraintViolationException) فرق دارد، و خطاها را با ProblemDetail طبق RFC 9457 و یک @RestControllerAdvice مرکزی مدیریت میکنی. زنجیرهٔ درخواست سه لایه است — filter (همهجا، کور به handler)، interceptor (آگاه به handler، قابل قطع)، argument resolver (ساختن پارامتر) — و استثنای filter از ControllerAdvice عبور میکند. Actuator امنبهطورپیشفرض است (فقط health)، مشاهدهپذیری روی Micrometer Observation سوار است، و سرور Tomcat 10.1 جاسازیشده با java -jar بالا میآید — با virtual threadها (Java 21+) برای بار blocking، مراقب pinning. حالا Boot دیگر جادو نیست؛ یک ماشین شفاف است که تو موتورش را میشناسی.
Hi. Let's build and understand one of the most-used tools in the Java world from the ground up. Many people treat Spring Boot as "magic" — they write some code, run it, everything works, but they don't know why. The goal of this lesson is to turn that magic into a transparent, understandable machine for you. By the end, Spring Boot won't be a black box: you'll know what its engine does inside, and in an interview you'll be able to explain every piece of it with confidence.
We'll build this up step by step:
- The mental model — what Spring Boot really is, and why it's "magical but never fights you."
- Auto-configuration internals — how Boot decides which beans to create (conditions, the
AutoConfiguration.importsfile, starters). - Externalized configuration & profiles — where properties come from and who wins.
- Building the REST layer — controllers, validation, and standard error handling with RFC 9457.
- The request pipeline — the difference between filter, interceptor, and argument resolver.
- Actuator & observability, and the embedded server (Tomcat, virtual threads).
- A full interview questions section with complete answers.
The only prerequisite is that you know Java and the idea of a class and an annotation. Every other term I'll build from scratch right here.
Part 0 — a few words you must know
Before we start, let's anchor three terms with analogies so they never land on you cold later.
- bean: any object that Spring itself creates, holds, and hands to others. Picture a central storekeeper holding all your ready-made parts; each part is a bean and the storekeeper is the "Spring container."
- classpath: the list of all libraries (jars) and classes your program can reach at runtime. Like a workshop's tool shelf — whatever is on the shelf is available.
- annotation: a label you stick on a class or method to tell the framework "treat this a certain way." Like a "Fragile" label on a box that dictates how the courier handles it.
Whenever you get lost in Spring Boot, ask yourself: "Right now Boot is looking at my classpath and my beans and deciding, based on a condition, which bean to create or skip." Almost all of Boot's behavior falls out of that single sentence.
The mental model: what is Spring Boot really?
Picture a plain restaurant with a bare kitchen: you have to light the stove yourself, fetch the oil, write the recipes. That's "raw Spring Framework" — powerful, but full of manual work. Now picture a professional chain restaurant where, the moment you walk into the kitchen, the stove is lit, the common ingredients are prepped, and the standard recipes hang on the wall. You just come in and finish the dish. That's Spring Boot: the same Spring, but with a pre-arranged, opinionated kitchen.
So be precise: Spring Boot is not a framework layered on top of Spring. It is an opinionated packaging of Spring Framework, plus a startup-time engine that inspects your classpath, your beans, and your properties, then wires up sensible defaults so you don't write boilerplate. "Opinionated" means Boot has an opinion about what is usually correct, and it makes that the default.
Two ideas do almost all of this work:
Convention over configuration, via conditions. Boot ships hundreds of
@Configurationclasses, each guarded by a@Conditionalannotation.@Conditionalmeans "only apply this when a certain condition holds." At startup, Boot evaluates those conditions against your application and only applies the ones that fit.Backing off gracefully. Almost every auto-configuration carries
@ConditionalOnMissingBean, meaning "only create this bean if the user hasn't." The moment you define a bean yourself, Boot steps aside. That's why Boot feels magical but never fights you — it always respects your explicit decision.
Everything rides on these two pillars: (1) conditions decide what turns on, and (2) backing off (@ConditionalOnMissingBean) guarantees that wherever you spoke up, Boot goes quiet. Understand these two and no Boot behavior will ever surprise you.
The Spring Boot 3 baseline (verify these before any migration)
These are version facts a senior should know cold:
- Java 17 minimum (Java 21+ recommended; virtual threads supported via
spring.threads.virtual.enabled=true). - Jakarta EE 9+ namespace: every
javax.*import from the servlet/validation/persistence/JMS APIs becomesjakarta.*. This is the single biggest breaking change from Boot 2. "Namespace" here just means the package prefix; the whole industry migrated fromjavaxtojakarta, and Boot 3 only understands the new form. - Built on Spring Framework 6, Tomcat 10.1, Hibernate 6.
- First-class support for GraalVM native images (compiling to a fast-starting native executable instead of running on the JVM) and Micrometer Observation for tracing/metrics.
Here is the whole startup process as one mental diagram:
your app classpath + your beans + your properties
│
┌─────────────┴──────────────┐
│ @EnableAutoConfiguration │ (implied by @SpringBootApplication)
└─────────────┬──────────────┘
▼
read AutoConfiguration.imports ── list of candidate @AutoConfiguration classes
▼
evaluate @Conditional guards ── keep the ones that fit
▼
register beans (backing off on your own beans)
Auto-configuration mechanics
Now let's go inside the engine.
The entry point
@SpringBootApplication // = @Configuration + @ComponentScan + @EnableAutoConfiguration
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
That familiar @SpringBootApplication is really three annotations in one coat: @Configuration (this class is a source of bean definitions), @ComponentScan (scan surrounding packages to find your beans), and @EnableAutoConfiguration (the magic engine).
@EnableAutoConfiguration imports a class called AutoConfigurationImportSelector. That selector reads a metadata file from every jar on the classpath. In other words, each library you put on the shelf carries a little instruction sheet that says "if I'm here, consider these beans."
spring.factories is dead — the mechanism is now AutoConfiguration.imports
Boot used to keep one all-purpose ledger (spring.factories) where everything — from auto-config to listeners — was mixed together; to find anything it had to read the whole ledger and open every row. Now Boot keeps a dedicated packing list: a plain file where each line is the name of one candidate class. It reads faster and stays tidy.
In Boot 2.7 this mechanism moved, and in Boot 3 the old path is gone entirely. Auto-configuration candidates are now listed one-class-per-line in:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.acme.audit.AuditAutoConfiguration
com.acme.ratelimit.RateLimitAutoConfiguration
The old EnableAutoConfiguration= key in META-INF/spring.factories is no longer read for auto-configuration. (spring.factories still exists for other extension points like ApplicationListener, but not for auto-config.) Why was this a good change? It cut startup cost — Boot no longer has to parse one big properties file and instantiate every listed class just to sort it; the dedicated file is cheaper to read, and ordering is carried by the annotations themselves.
The @AutoConfiguration annotation
If you want to write your own auto-config library (say, an internal starter for your company), you mark the class with @AutoConfiguration, not plain @Configuration:
@AutoConfiguration(after = DataSourceAutoConfiguration.class) // ordering hint
@ConditionalOnClass(RedisTemplate.class) // only if Redis is on the classpath
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean // back off if the app already defines one
@ConditionalOnProperty(prefix = "acme.ratelimit", name = "enabled",
havingValue = "true", matchIfMissing = true)
RateLimiter rateLimiter(RateLimitProperties props) {
return new TokenBucketRateLimiter(props.getCapacity(), props.getRefill());
}
}
Read this like a sentence, clause by clause: "Run this configuration after DataSourceAutoConfiguration; only if the class RedisTemplate is on the classpath; create a RateLimiter, but only if the user hasn't defined one, and if a property named acme.ratelimit.enabled is on (and if it isn't set at all, default to on)." See how everything is conditional?
An important subtlety: @AutoConfiguration implies @Configuration(proxyBeanMethods = false). That means each @Bean method is a lightweight factory, not a proxy. A "proxy" here means Spring wraps an invisible shell around your class to intercept behavior. With proxyBeanMethods = false there is no shell: startup is faster, but you can no longer call one @Bean method from inside another and get the same cached singleton back — you get a fresh instance. On top of that, @AutoConfiguration carries the before/after/beforeName/afterName ordering attributes. Why does ordering matter? Because a later auto-config's @ConditionalOnMissingBean must be able to see the beans that earlier auto-configs created, or it will decide wrong.
The condition catalog
Treat this table as the toolbox of conditions; each says "when it fires":
| Annotation | Fires when |
|---|---|
@ConditionalOnClass / @ConditionalOnMissingClass |
a class is / isn't on the classpath |
@ConditionalOnBean / @ConditionalOnMissingBean |
a bean is / isn't already registered |
@ConditionalOnProperty |
a property has a given value (matchIfMissing controls default) |
@ConditionalOnWebApplication |
the context is servlet/reactive/none |
@ConditionalOnResource |
a resource exists |
@ConditionalOnExpression |
a SpEL expression is true |
@ConditionalOnMissingBean (the important one) |
lets users override any default |
@ConditionalOnBean/@ConditionalOnMissingBean only work on the beans registered so far. Inside auto-configuration they're safe, because auto-config runs last — after your @ComponentScan. But in your own application @Configuration they're fragile, because the order in which your configs register beans is not guaranteed; the condition may evaluate true sometimes and false other times, producing maddening "sometimes it's there, sometimes it isn't" bugs. Rule of thumb: keep @ConditionalOnMissingBean in library/auto-config code, not in application code.
Debugging what got configured
When you ask "why did this bean/endpoint not get created at all?", this technique saves you:
# Prints the "CONDITIONS EVALUATION REPORT": Positive matches, Negative matches, Exclusions
debug=true
Or hit the Actuator conditions endpoint (we'll cover it shortly).
The CONDITIONS EVALUATION REPORT shows you which condition passed (Positive) and which failed (Negative), and why. When something didn't wire up, don't guess — read this report; it tells you exactly which @ConditionalOn... got in the way. It's the fastest way to answer "why isn't this bean here?"
Starters
A starter is like a build kit: instead of hunting down ten compatible libraries at the right versions individually, you grab one bundle that brings them all together. The kit itself has no code; it's just a shopping list.
More precisely: a starter is a dependency-only jar (a curated pom.xml with no code) that pulls a coherent set of libraries. For example, spring-boot-starter-web brings Spring MVC, Jackson, validation, and embedded Tomcat. Note the division of labor: the starter provides only the jars; the auto-config classes inside those jars provide the wiring. Version alignment comes from a BOM (a coordinated list of versions) named spring-boot-dependencies, imported by the parent POM — which is why you list starters without version numbers and the versions still line up.
Externalized configuration & profiles
A good software rule is that settings (like a port or a database password) should not be hard-coded, but come from outside so they can change without recompiling. This is called externalized configuration.
Imagine painting a wall several times: first a base coat, then fresher coats on top. Each new layer covers the one beneath. Spring's properties work the same way: different sources stack up, and the higher layer (the later source) wins.
Boot resolves properties from this ordered PropertySource list (higher = stronger):
1. Devtools defaults
2. @TestPropertySource / test props
3. Command-line args (--server.port=9000)
4. SPRING_APPLICATION_JSON
5. OS environment variables (SERVER_PORT=9000) ← relaxed binding
6. Java system properties (-Dserver.port=9000)
7. application-{profile}.yml / .properties
8. application.yml / .properties
9. @PropertySource
10. default properties
That term relaxed binding means Spring isn't strict about the property's name: server.port, SERVER_PORT, server_port, and serverPort all bind to the same property. Environment variables use the uppercased, underscore form (SERVER_PORT) — this is essential for containers, since the OS usually only accepts that form.
Type-safe configuration with @ConfigurationProperties
Instead of scattering @Value("${...}") all over the code, it's better to gather related settings into one class. This is called type-safe: each value has a defined, checked type. It's bound once, validated, and refactorable:
@ConfigurationProperties(prefix = "acme.ratelimit")
@Validated
public record RateLimitProperties(
@Positive int capacity,
@DurationUnit(ChronoUnit.SECONDS) Duration refill) {
public RateLimitProperties {
// records support compact-constructor defaults
if (refill == null) refill = Duration.ofSeconds(1);
}
}
Enable it with @EnableConfigurationProperties(RateLimitProperties.class) (which auto-configs do themselves) or @ConfigurationPropertiesScan. Using a record (Java's immutable data type) gives you immutable, constructor-bound config for free — the values can't change after construction, which prevents accidental bugs.
Profiles
A profile is a named set of settings for a particular environment — for instance dev (development) and prod (production). One file, several modes:
# application.yml
spring:
config:
activate:
on-profile: prod # Boot 2.4+ way (multi-document with ---)
server:
port: 8080
---
spring:
config:
activate:
on-profile: dev
logging:
level:
root: DEBUG
Activate with --spring.profiles.active=prod or SPRING_PROFILES_ACTIVE=prod. You can also put @Profile("prod") on beans so they're created only in that environment.
The old comment-style spring.profiles: document separator changed in 2.4 — use spring.config.activate.on-profile, not the old form. Second point: profile-specific files add to the base file rather than replacing it. That is, base values stay in place unless the profile explicitly overrides them.
Building the REST layer
Now to the part the end user actually sees: the API itself.
Controller basics
A controller is a class that receives incoming HTTP requests and responds. Think of it as the front desk of an office: a visitor comes, hands over their request, and the desk returns the answer.
@RestController // = @Controller + @ResponseBody on every method
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
@GetMapping("/{id}")
public OrderDto get(@PathVariable long id) {
return service.find(id); // serialized to JSON by Jackson (HttpMessageConverter)
}
@GetMapping
public Page<OrderDto> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return service.list(PageRequest.of(page, size));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest req,
UriComponentsBuilder uri) {
OrderDto created = service.create(req);
URI location = uri.path("/api/orders/{id}").build(created.id());
return ResponseEntity.created(location).body(created); // 201 + Location header
}
}
That @RestController equals @Controller + @ResponseBody on every method; it means "return each method's output directly as the response body (not an HTML page)." The term serialize means converting a Java object into a transferable format like JSON; the library that does this is Jackson, working through an HttpMessageConverter.
Notes a senior should internalize:
- Return the domain/DTO object directly and let the
HttpMessageConverter(Jackson) serialize it. Reach forResponseEntityonly when you need to control status, headers, or body conditionally. - The composed
@RequestMappingvariants (@GetMapping,@PostMapping, …) also carryproduces/consumesfor content negotiation andparams/headersfor finer routing, in addition to the path. - Never expose entities. (An entity is a class mapped directly to a database table.)
Serializing a JPA entity has three dangers: (1) the risk of LazyInitializationException — some data is loaded "lazily," and when Jackson touches it outside the transaction the source is already closed and it blows up; (2) leaking your database schema to the client; (3) tightly coupling the API contract to the DB structure, so any table change breaks the API. The fix: map to a DTO (a plain data-transfer object); records are ideal for this.
Validation with Bean Validation
Validation means checking that input from the client is valid before you trust it. Boot 3 uses Jakarta Bean Validation (jakarta.validation.*), and its starter (spring-boot-starter-validation) pulls the Hibernate Validator library.
public record CreateOrderRequest(
@NotBlank String customerEmail,
@NotEmpty @Valid List<LineItem> items, // @Valid cascades into each element
@Positive BigDecimal total) {
public record LineItem(@NotBlank String sku, @Min(1) int qty) {}
}
That @Valid on the list is called "cascading": it carries validation into each element of the list, not just the list itself. When you put @Valid on a @RequestBody parameter, validation fires before the method body runs; if it fails, a MethodArgumentNotValidException is thrown (which Boot maps to status 400).
But validating individual simple parameters like @RequestParam/@PathVariable works differently: you must mark the class with @Validated and put the constraints directly on the parameters. These throw a different exception, ConstraintViolationException:
@RestController
@Validated // enables method-level param validation
public class SearchController {
@GetMapping("/search")
public List<Hit> search(@RequestParam @Size(min = 3) String q,
@RequestParam @Max(100) int limit) { ... }
}
People constantly conflate these: @Valid (from Jakarta) is for cascading into an object graph, and its failure gives MethodArgumentNotValidException (→400). @Validated (from Spring, class-level) is for validation groups and method-level parameter validation, and its failure gives ConstraintViolationException. Common bug: you write a handler only for the former, the latter goes unhandled, so instead of 400 the client gets an ugly 500.
Exception handling & RFC 9457 ProblemDetail
Imagine every government office reported its errors on a different, arbitrary form; the visitor never knew where to read on the form. Now imagine a national standard complaint form with fixed boxes: "error type," "title," "code," "detail," "where it happened." Anyone reporting any error fills out that same layout. That's exactly what RFC 9457 does for APIs.
Spring Framework 6 implements RFC 9457 (formerly RFC 7807), "Problem Details for HTTP APIs." ProblemDetail is that standard error body:
{
"type": "https://api.acme.com/problems/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "SKU ABC-1 has 2 units, 5 requested",
"instance": "/api/orders",
"sku": "ABC-1"
}
Enable it globally with one property:
spring.mvc.problemdetails.enabled=true # makes Spring MVC's built-in exceptions return ProblemDetail
Now centralize error handling in one place. @RestControllerAdvice is a central class that catches errors from all controllers at once. If you extend ResponseEntityExceptionHandler, you also get RFC-9457 responses for all the built-in Spring MVC exceptions (unsupported media type, unreadable body, validation, etc.):
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(InsufficientStockException.class)
ProblemDetail handleStock(InsufficientStockException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create("https://api.acme.com/problems/insufficient-stock"));
pd.setTitle("Insufficient stock");
pd.setProperty("sku", ex.getSku()); // extension member, rendered top-level via Jackson mixin
return pd;
}
// Override to customize how bean-validation failures render as ProblemDetail
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage)));
return ResponseEntity.badRequest().body(pd);
}
}
That pd.setProperty("sku", ...) adds an "extension member": a custom field beyond the standard ones, which a Jackson mixin unwraps to the top level of the JSON (not nested).
You have a few other options too: the cleanest approach for your own exceptions is to implement ErrorResponse (or throw ErrorResponseException) so the exception carries its own HTTP mapping — no advice needed. @ControllerAdvice/@RestControllerAdvice can be scoped with basePackages, assignableTypes, or annotations when you have multiple advices. And @ResponseStatus on a custom exception is the low-ceremony option, but it can't set a body.
Content negotiation
Content negotiation means the client and server agreeing on the response format (JSON or XML or …). The client says "I understand these formats," and the server picks the best one it can.
Spring chooses the response format by consulting, in order: the path extension (disabled by default in Boot 3+), a format query param (opt-in, you must turn it on), and finally the Accept header. It then matches the requested media types against the controller's produces and the registered HttpMessageConverters, and picks the best converter.
@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public OrderDto get(@PathVariable long id) { ... } // Accept: application/xml → XML if Jackson-XML present
Accept: application/json → Jackson. Add jackson-dataformat-xml and XML negotiation lights up. Configure via:
spring.mvc.contentnegotiation.favor-parameter=true # allow ?format=xml
For versioned or vendor media types, use custom types like application/vnd.acme.v2+json and route with produces.
The request pipeline: filter vs interceptor vs argument resolver
Picture entering a large building: first the front-door guard inspects everyone who comes in (this is the filter — it applies to everyone, even before it knows where you're going). Then, at the door of a specific department, the department attendant checks the pass for that department (this is the interceptor — it knows which office you're heading to). And finally, the secretary inside the room, based on your ID, sets your prepared file on the desk (this is the argument resolver — it turns raw data into something ready to use).
These three live at different layers, and knowing which to use when is a classic senior distinction:
Servlet container
└─ Filter chain ← servlet layer, wraps request/response, runs for EVERY request
└─ DispatcherServlet
└─ HandlerInterceptor ← Spring MVC layer, knows the handler, only mapped requests
└─ Controller method
↑ HandlerMethodArgumentResolver builds each method parameter
| Concern | Use |
|---|---|
| Cross-cutting on all traffic (auth, CORS, gzip, request logging, MDC/trace id, wrap the stream) | Filter (jakarta.servlet.Filter / OncePerRequestFilter) |
Logic tied to the handler (auth checks per mapping, timing, mutate the ModelAndView, short-circuit before controller) |
HandlerInterceptor |
Turn request data into a method parameter (@CurrentUser, tenant id from header, custom binding) |
HandlerMethodArgumentResolver |
(The term cross-cutting means a concern that recurs everywhere and isn't specific to one spot — like logging or authentication.)
// FILTER — runs once per request, before Spring MVC even routes
public class TraceIdFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("traceId", traceId);
try { chain.doFilter(req, res); } // MUST call chain to continue
finally { MDC.clear(); } // clean up thread-local, always
}
}
Note: in a filter you must call chain.doFilter(...) or the request stops right there and never reaches the controller. MDC is a data store stuck to the current thread, used for logging; you clear it in finally so it doesn't leak into the next request on the same thread.
// INTERCEPTOR — has the handler; can short-circuit by returning false
public class AuthInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
if (handler instanceof HandlerMethod hm && hm.hasMethodAnnotation(Secured.class)
&& !isAuthenticated(req)) {
res.setStatus(401);
return false; // stop the chain; controller never runs
}
return true;
}
}
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry r) {
r.addInterceptor(new AuthInterceptor()).addPathPatterns("/api/**");
}
@Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserResolver());
}
}
The key point about an interceptor is that preHandle can return false and short-circuit the whole chain — the controller never runs at all. Unlike a filter, here the handler object (a HandlerMethod) is in your hands, so you know exactly which method is about to run and what annotations it has.
// ARGUMENT RESOLVER — injects a domain object built from the request
public class CurrentUserResolver implements HandlerMethodArgumentResolver {
@Override public boolean supportsParameter(MethodParameter p) {
return p.hasParameterAnnotation(CurrentUser.class);
}
@Override public Object resolveArgument(MethodParameter p, ModelAndViewContainer mav,
NativeWebRequest req, WebDataBinderFactory binder) {
return userService.fromToken(req.getHeader("Authorization"));
}
}
Two differences that trip people up: (1) filters cannot see which controller will handle the request or what its annotations are, because they run before routing; interceptors can, because they receive the HandlerMethod. (2) An exception thrown in a filter slips past @ControllerAdvice and is not caught — because there's no controller yet. You must handle it inside the filter or via an error page. Also, filter ordering is set by @Order/FilterRegistrationBean, whereas interceptor ordering is registration order.
Actuator & observability
Actuator is a set of ready-made endpoints for viewing the app's health and state in production — like the dashboard and status lights of a control room. spring-boot-starter-actuator adds these under the /actuator path.
In Boot 3, only /actuator/health is exposed over HTTP by default; you must explicitly opt the others in. This "secure by default" posture is deliberate: endpoints like /env and /configprops can leak secrets, and /heapdump can open a DoS attack (draining server resources).
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
endpoint:
health:
show-details: when-authorized # never leak component health to anonymous callers
metrics:
tags:
application: shop-api
Key endpoints: health (liveness/readiness groups map to Kubernetes probes — i.e., k8s uses them to know the app is alive and ready for traffic), metrics/prometheus (metrics via Micrometer), loggers (change log levels at runtime, no redeploy!), env, configprops, conditions (the very auto-config report we discussed), httpexchanges, threaddump, heapdump.
Observability in Boot 3 is built on Micrometer Observation. The elegant idea: a single Observation API produces both metrics (numbers and stats) and traces (the trail of a single request through the system) together. Add micrometer-tracing-bridge-otel plus an exporter and every controller, every RestClient, and every @Observed method emits spans with consistent tags (a span is a timed interval in a request's journey).
The embedded server
You used to install a separate server (like Tomcat) and deploy your app's WAR file into it. Spring Boot inverted this: it embeds the server inside the app itself.
Imagine an electric device that always has to hunt for a wall socket (an external server). Now imagine that same device has a built-in battery and motor and starts anywhere at the push of a button. java -jar app.jar is that button push: the server boots up inside the process itself.
Boot 3 embeds Tomcat 10.1 by default (Jetty and Undertow are drop-in alternatives — just exclude spring-boot-starter-tomcat and add the other starter). Key tunables:
server.port=8080
server.tomcat.threads.max=200 # worker threads (blocking model)
server.tomcat.accept-count=100 # OS accept queue when all threads busy
server.tomcat.connection-timeout=20s
server.compression.enabled=true
server.shutdown=graceful # drain in-flight requests on shutdown
spring.lifecycle.timeout-per-shutdown-phase=30s
That server.shutdown=graceful (graceful shutdown) means when the app is about to shut down, it first lets in-flight requests finish rather than cutting them off mid-way.
Virtual threads (Java 21+): Tomcat's classic model has a limited pool of threads (say 200); if they're all busy waiting on the database, a new request queues up. A virtual thread is an ultra-lightweight thread the JVM manages, and you can have millions of them. Flip one property to enable it:
spring.threads.virtual.enabled=true # requires Java 21+
Virtual threads only help blocking workloads (JDBC, blocking HTTP). But watch for pinning: when a virtual thread blocks inside a synchronized block, it pins its real carrier thread to itself and won't release it — exactly the thing we were trying to escape. The fix: on hot paths use ReentrantLock instead of synchronized. And never pool virtual threads; create a fresh one per task — they're cheap to make.
Common pitfalls & gotchas
Keep these as a "frequent mistakes" checklist:
- Exposing JPA entities →
LazyInitializationException, N+1, schema leakage. Always map to DTOs. @ConditionalOnMissingBeanin app code → order-dependent, flaky. Keep it in libraries/auto-config.- Forgetting
ConstraintViolationExceptionwhen using@Validatedparam validation — it is notMethodArgumentNotValidException, so a naive advice returns 500 instead of 400. @RequestParamon aboolean/primitive with a missing param → 400/500. Use wrapper types +required=false/defaultValue.@Transactionalon a controller — technically works but muddies layering; keep transactions in the service.- Self-injection / internal method calls bypassing proxies — calling a
@Transactionalor@Cacheablemethod from the same class skips the AOP proxy. Also why@AutoConfiguration'sproxyBeanMethods=falsematters. - CORS via a filter and
@CrossOrigin→ duplicate/conflicting headers. Pick one; prefer central config. - Exposing all Actuator endpoints (
include: "*") in production → info disclosure and DoS. - Relying on classpath scanning being cheap —
@ComponentScanover huge packages slows startup; keep base packages tight.
Best practices
- DTOs as
records; validate at the edge; never trust the client. - Return
ProblemDetailfor every error; give each problem a stabletypeURI. - One
@RestControllerAdviceextendingResponseEntityExceptionHandlerfor the whole app; scope extras only when needed. - Type-safe
@ConfigurationPropertiesrecords over scattered@Value. - Secure and selectively expose Actuator; wire health groups to k8s probes.
- Turn on
server.shutdown=gracefuland set the container'spreStop/terminationGracePeriodaccordingly. - Consider virtual threads (Java 21+) before reaching for reactive, if your bottleneck is blocking I/O and your team knows imperative code.
Interview Questions
Now it's time to cement everything as real interview questions. Answer each one yourself first, then read the answer.
It imports AutoConfigurationImportSelector, which reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (one class per line) from every jar. The old spring.factories EnableAutoConfiguration= key is no longer used for auto-config in Boot 3. Each candidate class is guarded by @Conditional annotations, evaluated against the classpath, existing beans, and properties; matching ones are applied.
It implements graceful back-off: auto-config runs after your component scan, so if you've defined a bean of that type, the auto-config's condition fails and Boot doesn't override you. This is what lets Boot provide defaults without ever fighting explicit configuration.
@AutoConfiguration implies @Configuration(proxyBeanMethods=false) plus ordering attributes (before/after). With proxyBeanMethods=false, @Bean methods aren't CGLIB-proxied, so calling one @Bean method from another returns a new instance rather than the shared singleton — you must inject dependencies as method parameters instead. It's chosen for startup speed; it's safe for auto-config because those classes rarely do inter-bean method calls.
@Valid (Jakarta) cascades validation into an object graph and, on a @RequestBody, throws MethodArgumentNotValidException (→400). @Validated (Spring) supports validation groups and, when placed on a controller class, enables method-level validation of @RequestParam/@PathVariable, which throws ConstraintViolationException. A common bug: handling only the former and returning 500 for the latter.
No. Filters run in the servlet layer before DispatcherServlet routes to a controller, so there's no handler and no @ControllerAdvice in scope. The exception propagates to the container's error handling (or the /error page). Handle it inside the filter or via an ErrorController.
Filter: cross-cutting on every request, can wrap the stream, no knowledge of the target handler. Interceptor: Spring-MVC-aware, has the HandlerMethod, can short-circuit preHandle, runs only for mapped requests. ArgumentResolver: converts request data into a specific controller method parameter.
By default Boot 3 disables path-extension matching; it uses the Accept header (optionally a format query param if enabled), matches requested media types against the controller's produces and the registered HttpMessageConverters, and picks the best converter. XML requires a Jackson-XML (or JAXB) converter on the classpath.
It's the standard "Problem Details for HTTP APIs" error body (type, title, status, detail, instance, plus extensions). Set spring.mvc.problemdetails.enabled=true for built-in exceptions, return ProblemDetail/ErrorResponse from @ExceptionHandlers, and extend ResponseEntityExceptionHandler in a @RestControllerAdvice to render Spring MVC exceptions as ProblemDetail. Extension members set via setProperty are unwrapped to top-level JSON by a Jackson mixin.
Only health. Everything else (env, configprops, metrics, heapdump, loggers, …) must be explicitly added to management.endpoints.web.exposure.include. The secure-by-default posture prevents information disclosure (/env can leak secrets) and DoS (/heapdump).
@Service
class OrderService {
@Transactional
public void placeOrder(Order o) { save(o); notify(o); }
@Transactional(propagation = REQUIRES_NEW)
public void notify(Order o) { /* audit */ }
}
notify is called via this.notify(...), an internal method call that bypasses the Spring AOP proxy, so REQUIRES_NEW is ignored and it runs in the same transaction — no new transaction, and an audit rollback would roll back the order too. Fix: move notify to another bean, self-inject the proxy, or use TransactionTemplate.
@RestController
class C {
@GetMapping("/x") String x(@RequestParam boolean flag) { return "" + flag; }
}
// GET /x (no flag param)
It returns 400 Bad Request. boolean is primitive and @RequestParam is required=true by default; a missing required param yields MissingServletRequestParameterException → 400. Fix with Boolean + required=false or defaultValue="false".
7000. Command-line args outrank environment variables, which outrank application.yml. Note relaxed binding: SERVER_PORT binds to server.port.
Thread pinning: a virtual thread that blocks while holding a synchronized monitor (common in older JDBC drivers, logging, or legacy code) pins its carrier platform thread, defeating the scheduler. Replace synchronized with ReentrantLock on hot paths, upgrade drivers, and monitor with -Djdk.tracePinnedThreads=full.
Serialization happens after the transaction/EntityManager closes, so touching a lazy association throws LazyInitializationException; it also triggers N+1 queries, leaks your schema to clients, and couples the API contract to the persistence model. Map to DTOs (records) explicitly.
It prints the CONDITIONS EVALUATION REPORT: Positive matches (auto-configs that were applied and why), Negative matches (skipped, with the failing condition), and Exclusions/Unconditional classes. It's the fastest way to answer "why is/isn't this bean here?" — you read which @ConditionalOn... passed or failed.
Spring Boot is an opinionated packaging of Spring standing on two pillars: conditions (@Conditional...) decide which beans turn on, and backing off (@ConditionalOnMissingBean) guarantees Boot goes quiet wherever you spoke up. In Boot 3, auto-config candidates are listed in the AutoConfiguration.imports file (not spring.factories), and you can debug the whole process with debug=true or the conditions endpoint. For REST: a controller returns an object/DTO and Jackson serializes it (never an entity!); validation differs between @Valid (cascade, 400) and @Validated (params, ConstraintViolationException); and you handle errors with ProblemDetail per RFC 9457 and one central @RestControllerAdvice. The request pipeline is three layers — filter (everywhere, blind to the handler), interceptor (handler-aware, short-circuitable), argument resolver (building a parameter) — and a filter's exception slips past ControllerAdvice. Actuator is secure-by-default (only health), observability rides on Micrometer Observation, and the embedded Tomcat 10.1 boots with java -jar — with virtual threads (Java 21+) for blocking load, watching out for pinning. Now Boot isn't magic anymore; it's a transparent machine whose engine you understand.