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 برایت جعبهٔ سیاه نیست؛ می‌دانی موتورش داخل چه می‌کند و در مصاحبه می‌توانی هرکدام از این‌ها را با اطمینان توضیح دهی.

نقشهٔ راه این درس

در این مسیر این‌ها را قدم‌به‌قدم می‌سازیم:

  1. مدل ذهنی — Spring Boot واقعاً چیست و چرا «جادویی اما بی‌آزار» است.
  2. درون‌کارِ auto-configuration — چطور Boot می‌فهمد چه bean‌هایی بسازد (شرط‌ها، فایل AutoConfiguration.imports، ‏starterها).
  3. پیکربندی بیرونی و پروفایل‌ها — property‌ها از کجا می‌آیند و چه‌کسی برنده است.
  4. ساختن لایهٔ REST — کنترلر، ‏اعتبارسنجی، و مدیریت خطای استاندارد با RFC 9457.
  5. زنجیرهٔ درخواست — تفاوت filter، ‏interceptor و argument resolver.
  6. Actuator و مشاهده‌پذیری و سرور جاسازی‌شده (‏Tomcat، ‏virtual threads).
  7. یک بخش کامل سؤالات مصاحبه با پاسخ‌های کامل.

پیش‌نیازش فقط این است که 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 جادویی به‌نظر می‌رسد اما هرگز با تو نمی‌جنگد — همیشه به تصمیم صریح تو احترام می‌گذارد.

دو ستون Spring 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 کنند
دام: `@ConditionalOnMissingBean` را در کد اپ استفاده نکن

@ConditionalOnBean/@ConditionalOnMissingBean فقط روی bean‌هایی کار می‌کنند که تا آن لحظه ثبت شده‌اند. داخل auto-configuration امن‌اند، چون auto-config آخر از همه اجرا می‌شود — بعد از @ComponentScan تو. اما در @Configuration خودِ اپلیکیشن شکننده‌اند، چون ترتیب ثبت bean میان config‌های تو تضمین‌شده نیست؛ ممکن است شرط گاهی درست و گاهی غلط ارزیابی شود و باگ‌های عجیبِ «گاهی هست، گاهی نیست» بسازد. قاعدهٔ سرانگشتی: ‏@ConditionalOnMissingBean را در کدِ کتابخانه/auto-config بگذار، نه در کد اپلیکیشن.

دیباگِ آنچه پیکربندی شد

وقتی می‌پرسی «چرا این bean/endpoint اصلاً ساخته نشد؟»، این تکنیک نجاتت می‌دهد:

# گزارش «CONDITIONS EVALUATION REPORT» را چاپ می‌کند: تطبیق‌های مثبت، منفی، و استثناها
debug=true

یا endpoint به‌نام conditions در Actuator را بزن (بعداً درباره‌اش حرف می‌زنیم).

مفیدترین مهارت دیباگ در Spring Boot

گزارش 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 را مستقیم برنگردانیم؟

سریال‌کردن یک 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` و `@Validated`

این دو را مردم مدام قاطی می‌کنند: ‏@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"));
    }
}
دامِ کلیدی: فیلترها کورند و از ControllerAdvice عبور می‌کنند

دو تفاوت که مردم را می‌لغزاند: (۱) فیلترها نمی‌توانند ببینند کدام کنترلر درخواست را مدیریت خواهد کرد یا annotation‌های آن چیست، چون پیش از مسیریابی اجرا می‌شوند؛ اما interceptor‌ها می‌توانند، چون HandlerMethod را دریافت می‌کنند. (۲) استثنایی که در یک filter پرتاب شود از @ControllerAdvice عبور می‌کند و گرفته نمی‌شود — چون هنوز هیچ کنترلری در کار نیست. باید آن را در خود فیلتر یا از طریق صفحهٔ خطا مدیریت کنی. ضمناً ترتیب فیلترها با @Order/FilterRegistrationBean تعیین می‌شود، ولی ترتیب interceptor‌ها به‌ترتیب ثبت است.


Actuator و مشاهده‌پذیری (observability)

‏Actuator مجموعه‌ای از endpoint‌های آماده برای دیدن سلامت و وضعیت اپ در محیط تولید است — مثل داشبورد و چراغ‌های یک اتاق کنترل. ‏spring-boot-starter-actuator این‌ها را زیر مسیر /actuator اضافه می‌کند.

امن به‌طور پیش‌فرض در Boot 3

در 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+
دامِ pinning در virtual threadها

‏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 یک type URI پایدار بده.
  • یک @RestControllerAdvice که از ResponseEntityExceptionHandler ارث می‌برد برای کل اپ؛ موارد اضافی را فقط در صورت نیاز محدود کن.
  • رکوردهای @ConfigurationProperties type-safe به‌جای @Value پراکنده.
  • ‏Actuator را امن و انتخابی افشا کن؛ گروه‌های health را به probe‌های k8s وصل کن.
  • server.shutdown=graceful را روشن کن و preStop/terminationGracePeriod container را متناسب تنظیم کن.
  • اگر گلوگاه تو I/O مسدودکننده است و تیم کد امری (imperative) می‌داند، پیش از رفتن سراغ reactive، ‏virtual thread‌ها (‏Java 21+) را در نظر بگیر.

سؤالات مصاحبه

حالا وقت آن است که همه‌چیز را در قالب پرسش‌های واقعی مصاحبه محکم کنیم. هر پرسش را اول خودت جواب بده، بعد پاسخ را بخوان.

۱. `@EnableAutoConfiguration` واقعاً چگونه auto-configuration‌ها را در Boot 3 پیدا می‌کند؟

AutoConfigurationImportSelector را import می‌کند که فایل META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (یک کلاس در هر خط) را از هر jar می‌خواند. کلید قدیمی EnableAutoConfiguration= در spring.factories دیگر برای auto-config در Boot 3 استفاده نمی‌شود. هر کلاس کاندید با @Conditional محافظت می‌شود که در برابر classpath، ‏bean‌های موجود و property‌ها ارزیابی می‌شود؛ آن‌هایی که تطبیق دارند اعمال می‌شوند.

۲. چرا `@ConditionalOnMissingBean` محور طراحی Boot است؟

عقب‌نشینی محترمانه را پیاده می‌کند: ‏auto-config بعد از component scan تو اجرا می‌شود، پس اگر bean‌ای از آن نوع تعریف کرده باشی، شرط auto-config شکست می‌خورد و Boot تو را override نمی‌کند. همین است که به Boot اجازه می‌دهد پیش‌فرض بدهد بدون اینکه هرگز با پیکربندی صریح بجنگد.

۳. (سخت) تفاوت `@AutoConfiguration` و `@Configuration` چیست، و چرا `proxyBeanMethods=false` اینجا مهم است؟

@AutoConfiguration به‌طور ضمنی @Configuration(proxyBeanMethods=false) به‌علاوهٔ ویژگی‌های ترتیب (before/after) است. با proxyBeanMethods=false، متدهای @Bean توسط CGLIB پروکسی نمی‌شوند، پس صدازدن یک متد @Bean از دیگری یک نمونهٔ جدید برمی‌گرداند نه singletonِ مشترک — باید وابستگی‌ها را به‌جای آن به‌عنوان پارامتر متد تزریق کنی. برای سرعت راه‌اندازی انتخاب شده؛ برای auto-config امن است چون آن کلاس‌ها به‌ندرت صدازدن متد بین‌bean دارند.

۴. `@Valid` در برابر `@Validated` — هرکدام چه زمانی فعال می‌شوند و چه استثنایی پرتاب می‌کنند؟

@Valid (‏Jakarta) اعتبارسنجی را به داخل گراف شیء آبشار می‌کند و روی @RequestBody، ‏MethodArgumentNotValidException (‏→400) پرتاب می‌کند. ‏@Validated (‏Spring) از گروه‌های اعتبارسنجی پشتیبانی می‌کند و وقتی روی کلاس کنترلر گذاشته شود، اعتبارسنجی سطح‌متد @RequestParam/@PathVariable را فعال می‌کند که ConstraintViolationException پرتاب می‌کند. باگ رایج: مدیریت فقط اولی و بازگرداندن 500 برای دومی.

۵. (سخت) یک filter استثنا پرتاب می‌کند. آیا `@ControllerAdvice` تو آن را می‌گیرد؟ چرا/چرا نه؟

نه. فیلترها در لایهٔ servlet پیش از مسیریابی DispatcherServlet به کنترلر اجرا می‌شوند، پس نه handler هست و نه @ControllerAdvice در حوزه. استثنا به مدیریت خطای container (یا صفحهٔ /error) منتشر می‌شود. آن را داخل filter یا از طریق یک ErrorController مدیریت کن.

۶. Filter در برابر Interceptor در برابر ArgumentResolver — قاعدهٔ یک‌خطی تصمیم برای هرکدام را بگو.

‏Filter: موضوع cross-cutting روی هر درخواست، می‌تواند stream را بسته‌بندی کند، از handler هدف بی‌اطلاع است. ‏Interceptor: آگاه به Spring MVC، ‏HandlerMethod را دارد، می‌تواند در preHandle قطع کند، فقط برای درخواست‌های نگاشت‌شده اجرا می‌شود. ‏ArgumentResolver: دادهٔ درخواست را به یک پارامتر متد مشخص کنترلر تبدیل می‌کند.

۷. content negotiation چگونه JSON در برابر XML را تصمیم می‌گیرد؟

به‌طور پیش‌فرض Boot 3 تطبیق پسوند مسیر را غیرفعال می‌کند؛ از هدر Accept استفاده می‌کند (اختیاراً یک پارامتر query به‌نام format اگر فعال باشد)، نوع‌های رسانهٔ درخواستی را با produces کنترلر و HttpMessageConverterهای ثبت‌شده تطبیق می‌دهد و بهترین converter را برمی‌گزیند. ‏XML نیازمند یک converter از نوع Jackson-XML (یا JAXB) روی classpath است.

۸. RFC 9457 / ProblemDetail چیست و چگونه آن را در Boot 3 به‌کار می‌گیری؟

بدنهٔ استاندارد خطای «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) می‌شوند.

۹. کدام endpoint‌های Actuator به‌طور پیش‌فرض در Boot 3 روی HTTP افشا می‌شوند و چرا این پیش‌فرض است؟

فقط 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" اصلاح کن.

۱۲. اولویت منبع property: اگر `application.yml` مقدار `server.port=8080` را تنظیم کند و تو با متغیر محیطی `SERVER_PORT=9000` و `--server.port=7000` اجرا کنی، کدام پورت برنده است؟

7000. آرگومان‌های خط فرمان از متغیرهای محیطی بالاترند، که آن‌ها از application.yml بالاترند. توجه به relaxed binding: ‏SERVER_PORT به server.port متصل می‌شود.

۱۳. (سخت) `spring.threads.virtual.enabled=true` را روی Java 21 فعال کردی اما توان عملیاتی زیر بار بهبود نیافت و نخ‌ها گیرکرده به‌نظر می‌رسند. علت محتمل چیست؟

Pinning نخ: یک virtual thread که هنگام نگه‌داشتن monitor به‌صورت synchronized مسدود شود (رایج در درایورهای قدیمی JDBC، ‏logging یا کد legacy) نخ حاملِ platformِ خود را pin می‌کند و زمان‌بند را خنثی می‌کند. در مسیرهای داغ synchronized را با ReentrantLock جایگزین کن، درایورها را ارتقا بده، و با -Djdk.tracePinnedThreads=full مانیتور کن.

۱۴. چرا هرگز نباید entity‌های JPA را از کنترلرها سریال کنی؟

سریال‌سازی پس از بسته‌شدن تراکنش/EntityManager رخ می‌دهد، پس دست‌زدن به یک association تنبل، ‏LazyInitializationException پرتاب می‌کند؛ همچنین کوئری‌های N+1 را تحریک می‌کند، ‏schema تو را به کلاینت درز می‌دهد و قرارداد API را به مدل persistence جفت می‌کند. صریحاً به DTO (رکورد) نگاشت کن.

۱۵. (سخت) endpoint به نام `conditions` در Actuator (یا `debug=true`) چگونه کمک می‌کند و گزارش شامل کدام سه بخش است؟

گزارش 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.

This lesson's roadmap

We'll build this up step by step:

  1. The mental model — what Spring Boot really is, and why it's "magical but never fights you."
  2. Auto-configuration internals — how Boot decides which beans to create (conditions, the AutoConfiguration.imports file, starters).
  3. Externalized configuration & profiles — where properties come from and who wins.
  4. Building the REST layer — controllers, validation, and standard error handling with RFC 9457.
  5. The request pipeline — the difference between filter, interceptor, and argument resolver.
  6. Actuator & observability, and the embedded server (Tomcat, virtual threads).
  7. 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.
One sentence that holds the whole lesson

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?

The opinionated restaurant

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:

  1. Convention over configuration, via conditions. Boot ships hundreds of @Configuration classes, each guarded by a @Conditional annotation. @Conditional means "only apply this when a certain condition holds." At startup, Boot evaluates those conditions against your application and only applies the ones that fit.

  2. 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.

Memorize Boot's two pillars

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 becomes jakarta.*. This is the single biggest breaking change from Boot 2. "Namespace" here just means the package prefix; the whole industry migrated from javax to jakarta, 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

A packing list instead of a cluttered ledger

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
Pitfall: don't use `@ConditionalOnMissingBean` in app code

@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 single most useful debugging skill in Spring Boot

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 ready-made kit instead of buying parts one by one

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.

Layers of paint on a wall

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.

Profile pitfalls

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 for ResponseEntity only when you need to control status, headers, or body conditionally.
  • The composed @RequestMapping variants (@GetMapping, @PostMapping, …) also carry produces/consumes for content negotiation and params/headers for finer routing, in addition to the path.
  • Never expose entities. (An entity is a class mapped directly to a database table.)
Why never return an entity directly?

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) { ... }
}
The crucial `@Valid` vs `@Validated` difference

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

A standard complaint form

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

The security layers of an office building

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"));
    }
}
Key pitfall: filters are blind and slip past ControllerAdvice

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.

Secure by default in Boot 3

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.

A built-in engine instead of a wall socket

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+
The pinning pitfall with virtual threads

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 entitiesLazyInitializationException, N+1, schema leakage. Always map to DTOs.
  • @ConditionalOnMissingBean in app code → order-dependent, flaky. Keep it in libraries/auto-config.
  • Forgetting ConstraintViolationException when using @Validated param validation — it is not MethodArgumentNotValidException, so a naive advice returns 500 instead of 400.
  • @RequestParam on a boolean/primitive with a missing param → 400/500. Use wrapper types + required=false/defaultValue.
  • @Transactional on a controller — technically works but muddies layering; keep transactions in the service.
  • Self-injection / internal method calls bypassing proxies — calling a @Transactional or @Cacheable method from the same class skips the AOP proxy. Also why @AutoConfiguration's proxyBeanMethods=false matters.
  • 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@ComponentScan over huge packages slows startup; keep base packages tight.

Best practices

  • DTOs as records; validate at the edge; never trust the client.
  • Return ProblemDetail for every error; give each problem a stable type URI.
  • One @RestControllerAdvice extending ResponseEntityExceptionHandler for the whole app; scope extras only when needed.
  • Type-safe @ConfigurationProperties records over scattered @Value.
  • Secure and selectively expose Actuator; wire health groups to k8s probes.
  • Turn on server.shutdown=graceful and set the container's preStop/terminationGracePeriod accordingly.
  • 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.

1. How does `@EnableAutoConfiguration` actually find auto-configurations in Boot 3?

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.

2. Why is `@ConditionalOnMissingBean` the linchpin of Boot's design?

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.

3. (Hard) What's the difference between `@AutoConfiguration` and `@Configuration`, and why does `proxyBeanMethods=false` matter here?

@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.

4. `@Valid` vs `@Validated` — when does each fire and what exception does each throw?

@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.

5. (Hard) A filter throws an exception. Does your `@ControllerAdvice` catch it? Why/why not?

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.

6. Filter vs Interceptor vs ArgumentResolver — give the one-line decision rule for each.

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.

7. How does content negotiation decide JSON vs XML?

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.

8. What is RFC 9457 / ProblemDetail and how do you adopt it in Boot 3?

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.

9. Which Actuator endpoints are exposed over HTTP by default in Boot 3, and why is that the default?

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).

10. (Hard, find-the-bug) What's wrong here?
@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.

11. (Gotcha) What does this print / return?
@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".

12. Property source precedence: given `application.yml` sets `server.port=8080` and you start with `SERVER_PORT=9000` env var and `--server.port=7000`, what port wins?

7000. Command-line args outrank environment variables, which outrank application.yml. Note relaxed binding: SERVER_PORT binds to server.port.

13. (Hard) You enabled `spring.threads.virtual.enabled=true` on Java 21 but throughput under load didn't improve and threads look stuck. What's the likely cause?

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.

14. Why should you never serialize JPA entities from controllers?

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.

15. (Hard) How does the `conditions` Actuator endpoint (or `debug=true`) help, and what three sections does the report contain?

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.


In a nutshell

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.