Enterprise & Integration · سازمانی و یکپارچهسازی سنیورSenior ~61 دقیقه مطالعه~50 min read
Java EE / Jakarta EE و تفاوتش با SpringJava EE / Jakarta EE and How It Differs from Spring
Jakarta EE یک مجموعه مشخصات با چند پیادهسازی رقیب است و Spring یک فریمورک با یک پیادهسازی؛ این فصل از تاریخچه و تغییر namespace از javax به jakarta تا Servlet و CDI و EJB و JPA/JTA و JAX-RS و MicroProfile و مسیرهای مهاجرت را میسازد تا بتوانی این مقایسه را با قضاوت مهندسی جواب بدهی.Jakarta EE is a specification set with competing implementations while Spring is a single-implementation framework; this chapter builds the whole picture — history, the javax-to-jakarta break, Servlet, CDI, EJB, JPA/JTA, JAX-RS, MicroProfile, packaging and migration paths — so you can answer that comparison with engineering judgement.
اگر چند سال با Spring Boot کار کرده باشی، یک روز به پروژهای میخوری که بهجای @RestController کلاسی با @Path دارد، بهجای @Service یک @Stateless، و بهجای java -jar app.jar یک فایل .ear را روی چیزی به اسم WildFly «deploy» میکند. یا در مصاحبه میپرسند «فرق Jakarta EE با Spring چیست؟» و اگر بگویی «یکی قدیمی است و یکی جدید»، مصاحبهگر میفهمد فقط یک لایه از داستان را میشناسی. این فصل همان شکاف را پر میکند: از معنی کلمهی «specification» شروع میکنیم و تا جایی میرویم که بتوانی دربارهی مهاجرت یک سیستم legacy تصمیم بگیری و از آن دفاع کنی.
۱. تاریخ و نامها: J2EE → Java EE → Jakarta EE و اینکه چرا اسم عوض شد.
۲. ایدهی مرکزی: spec در برابر framework، و زلزلهی javax → jakarta با ابزارهای عبور از آن.
۴. specهایی که واقعاً میبینی: Servlet، CDI، EJB، JPA، JTA، JMS، Validation، JAX-RS، JNDI، Security.
۵. بستهبندی و اجرا: WAR در برابر EAR در برابر fat jar؛ سرور در برابر کانتینر؛ MicroProfile و نسل جدید (Quarkus، Helidon، Open Liberty).
۶. جدول کامل Jakarta EE در برابر Spring، روش درست جواب دادن در مصاحبه، و مسیرهای مهاجرت با ابزار واقعی.
فصلهای دیگر روی Spring (spring-core، spring-boot-web، spring-data-tx)، jpa-hibernate، microservices-distributed و ms-security عمیق شدهاند؛ اینجا آنها را تکرار نمیکنیم و فقط روی چیزی تمرکز میکنیم که جای دیگری پوشش داده نشده: خودِ استاندارد.
۱. آنالوژی: پریز برق در برابر یک برند لوازم خانگی
وقتی یخچال میخری نمیپرسی «با پریز خانهی من کار میکند؟» چون یک استاندارد هست: ولتاژ، فرکانس و شکل دوشاخهی مشخص، نوشتهی یک کمیته نه یک شرکت. هر سازندهای که رعایتش کند محصولش همهجا کار میکند و تو میتوانی برند را عوض کنی بدون تغییر سیمکشی خانه.
حالا شرکتی را تصور کن که یک سیستم خانهی هوشمند کامل میسازد: سنسور، هاب، اپلیکیشن، همه از خودش. عالی کار میکند و سریعتر از هر کمیتهای نوآوری میکند — ولی برای تعویض برند باید همهچیز را عوض کنی.
Jakarta EE آن استاندارد پریز است. Spring آن سیستم خانهی هوشمند است.
تقریباً هر تفاوت دیگری از همین یکی بیرون میآید: چرا Jakarta EE کندتر تغییر میکند، چرا Spring سریعتر feature میدهد، و چرا در بانک و بیمه هنوز application server میبینی ولی در استارتاپها تقریباً همهجا Spring Boot است.
۲. تاریخ و نامها
اول واژهها. Java SE یعنی خود زبان و کتابخانهی پایه که با JDK میگیری؛ Java EE مجموعهای از APIهای اضافه برای کارهای سازمانی است (HTTP، تراکنش، پیام، persistence، امنیت) که روی Java SE سوار میشود. JCP فرایند رسمی نوشتن استانداردهاست و خروجی هر پروژه یک JSR. spec سندی است که رفتار یک API را تعریف میکند، RI پیادهسازی مرجع (مثل Jersey برای JAX-RS)، و TCK مجموعهی عظیم تستی که پاس کردنش حق ادعای «certified» میدهد — همین آخری «استاندارد» را از «ادعا» جدا میکند.
J2EE (۱۹۹۹–۲۰۰۳) نسل اول بود: Servlet، JSP، EJB 1.x/2.x — و برای یک bean ساده دو interface (Home و Remote)، یک کلاس پیادهسازی و چند صفحه XML لازم بود. Spring دقیقاً بهعنوان واکنش به همین پیچیدگی متولد شد. Java EE 5 (۲۰۰۶) نقطهی عطف بود: annotationها آمدند، EJB 3.0 تقریباً همهی XML را حذف کرد و JPA 1.0 استاندارد شد. Java EE 6 CDI و JAX-RS و مفهوم Web Profile را آورد و نسخههای ۷ و ۸ WebSocket، JSON-P، JSON-B و Security 1.0 را اضافه کردند — ولی سرعت کند شده بود.
۲۰۱۷: انتقال به Eclipse Foundation. Oracle کدها را داد اما علامت تجاری «Java» را نگه داشت؛ پس نه نام «Java EE» قابل استفاده بود، نه بستههای javax.* قابل تغییر و توسعه. Jakarta EE 8 (۲۰۱۹) همان APIهای قبلی با همان javax.* بود، فقط زیر حاکمیت جدید. Jakarta EE 9 (۲۰۲۰) همان big bang بود: تمام javax.* به jakarta.* رفت بدون هیچ feature جدید؛ همزمان specهای مرده هم پاک شدند (XML RPC، XML Registries، Deployment، Management، entity beanهای EJB 2.x). Jakarta EE 11 (۲۶ ژوئن ۲۰۲۵) Jakarta Data 1.0، پشتیبانی virtual thread در Concurrency 3.1، پشتیبانی record در Persistence 3.2 و حذف ارجاعها به SecurityManager را آورد.
| نسخه | تاریخ | namespace | حداقل Java SE | نکتهی کلیدی |
|---|---|---|---|---|
| J2EE 1.2 – 1.4 | ۱۹۹۹–۲۰۰۳ | javax |
1.2–1.4 | Servlet، JSP، EJB 2.x، XML سنگین |
| Java EE 5 | ۲۰۰۶ | javax |
5 | annotation، EJB 3.0، JPA 1.0 |
| Java EE 6 | ۲۰۰۹ | javax |
6 | CDI 1.0، JAX-RS 1.1، Web Profile |
| Java EE 7 · 8 | ۲۰۱۳ · ۲۰۱۷ | javax |
7 · 8 | WebSocket، JSON-P، Batch؛ سپس JSON-B، JAX-RS 2.1، Security 1.0 |
| Jakarta EE 8 | سپتامبر ۲۰۱۹ | javax |
8 | فقط تغییر حاکمیت |
| Jakarta EE 9 · 9.1 | دسامبر ۲۰۲۰ · مه ۲۰۲۱ | jakarta |
8 · 11 | big bang rename؛ سپس پشتیبانی JDK 11 |
| Jakarta EE 10 | سپتامبر ۲۰۲۲ | jakarta |
11 / 17 | CDI 4.0، Core Profile |
| Jakarta EE 11 | ژوئن ۲۰۲۵ | jakarta |
17 (بهینه روی 21) | Jakarta Data 1.0، virtual threads |
| Jakarta EE 12 | در حال توسعه | jakarta |
21 (runtime تا 25) | Jakarta Query، بهروزرسانی گسترده |
هر spec نسخهی خودش را دارد و با شمارهی پلتفرم یکی نیست. Jakarta EE 11 یعنی: Servlet 6.1، CDI 4.1، Persistence 3.2، RESTful Web Services 4.0، Data 1.0، Faces 4.1، Expression Language 6.0، WebSocket 2.2، Pages 4.0، Security 4.0، Validation 3.1، Concurrency 3.1. موقع جستوجوی مستندات همیشه نسخهی spec را بزن، نه نسخهی پلتفرم.
۳. ایدهی مرکزی: spec با چند پیادهسازی
وقتی در Spring مینویسی @Autowired، آن annotation یک پیادهسازی دارد: خود Spring. سند spec وجود ندارد؛ کد، خودِ spec است. وقتی در Jakarta EE مینویسی @Inject، پشتش سندی رسمی ایستاده و همان را Weld (در WildFly)، OpenWebBeans (در TomEE) یا موتور داخلی Quarkus پیاده میکنند — بدون اینکه کد تو تغییر کند.
نتیجهی اول: dependency تو فقط API است.
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-api</artifactId>
<version>11.0.0</version>
<scope>provided</scope>
</dependency>
آن provided قلب ماجراست: «موقع کامپایل لازمش دارم، در بستهی نهایی نگذارش — محیط اجرا خودش دارد»؛ پس WAR نهایی میتواند چند صد کیلوبایت باشد. (برای Web Profile همان مختصات با artifact jakarta.jakartaee-web-api.) در Spring Boot عکس این است: از Tomcat تا Jackson، همهچیز داخل fat jar میآید.
نتیجهی دوم: محصول یا «Jakarta EE 11 certified» هست یا نیست، و این را TCK تعیین میکند. نتیجهی سوم: چند فروشندهی رقیب برای یک API — برای JPA Hibernate و EclipseLink؛ برای JAX-RS Jersey و RESTEasy و CXF؛ برای CDI Weld و OpenWebBeans؛ برای JTA Narayana و Atomikos.
روی کاغذ میتوانی WAR را از WildFly به WebLogic ببری؛ در عمل معمولاً نه. descriptorهای اختصاصی فروشنده (jboss-web.xml، weblogic.xml) تنظیمات کلیدی مثل context-root و نگاشت نقشها را نگه میدارند، نامهای JNDI غیراستانداردند، سیاست classloader هر سرور فرق دارد، و رفتار در لبهها فرق میکند. قابل حمل بودن یک طیف است نه کلید on/off: spec سطح پایه را تضمین میکند و ۱۰٪ آخر همیشه کار دستی است.
مزیت واقعی استاندارد «تعویض راحت فروشنده» نیست؛ قدرت چانهزنی و طول عمر است. کدی که علیه یک spec نوشته شده از هر فروشندهای بیشتر عمر میکند. سازمانهایی با تعهد دهساله این را میخرند؛ استارتاپی با افق ۱۸ ماهه نمیخرد و درست هم تصمیم میگیرد — یک تصمیم مهندسی-تجاری، نه سلیقهای.
Jakarta EE یک مجموعه مشخصات است که زیر نظر Eclipse Foundation نوشته میشود، چند فروشندهی مستقل پیادهاش میکنند و سازگاری با یک TCK رسمی اثبات میشود. Spring یک فریمورک است: یک پیادهسازی، یک تیم، بدون spec رسمی. نتیجهی عملی: در Jakarta EE به API وابستهای و runtime قابل تعویض است ولی نوآوری با سرعت کمیته پیش میرود؛ در Spring به یک محصول وابستهای ولی سرعت و اکوسیستم بسیار بیشتری میگیری.
و نکتهای که باید اضافه کنی: این دو رقیب مطلق نیستند. Spring از ابتدا بسیاری از APIهای Jakarta EE را مصرف میکند — Servlet، JPA، Validation، JTA، JMS — و Spring Framework 7 (نوامبر ۲۰۲۵) صراحتاً روی APIهای Jakarta EE 11 نشسته است. پس سؤال درست «کدام بهتر است؟» نیست؛ «چقدر از پشتهات را به یک فروشنده میسپاری؟» است.
۴. زلزلهی javax → jakarta
توافق نهایی این بود که Eclipse Foundation میتواند بستههای javax.* موجود را نگه دارد ولی حق تغییر یا افزودن به آنها را ندارد — یعنی javax.servlet برای همیشه در Servlet 4.0 یخ میزد؛ تنها راه ادامهی تکامل، تغییر نام کل namespace بود.
چرا این همهچیز را شکست؟ چون در JVM نام بسته بخشی از هویت باینری کلاس است. javax.servlet.http.HttpServlet و jakarta.servlet.http.HttpServlet دو کلاس کاملاً بیربطاند: نه interface مشترکی، نه رابطهی وراثتی، نه تبدیل خودکاری. پس هر کتابخانهای که به javax.* اشاره میکرد باید دوباره کامپایل و منتشر میشد — Hibernate، Jackson، Jersey، Tomcat، Jetty و ابزارهای build. این چند سال طول کشید.
بدترین حالت این است که در یک classpath هم javax.servlet-api باشد و هم jakarta.servlet-api. برنامه کامپایل میشود، حتی بالا میآید، ولی در runtime با NoClassDefFoundError، ClassCastException یا فیلتری که هرگز صدا زده نمیشود میترکد — چون دو کپی از یک مفهوم در حافظه است.
mvn dependency:tree | grep -iE 'javax\.(servlet|persistence|validation|inject|annotation)'
درمان: <exclusions> روی وابستگیهای قدیمی و ارتقای کتابخانه — نه نگه داشتن هر دو.
دو ابزار برای عبور از این شکاف داری. اگر همهی کتابخانههایت نسخهی jakarta دارند، مسیر ساده است؛ اگر نه، باید یا کتابخانه را از سورس دوباره build کنی یا بایتکدش را بازنویسی کنی.
ابزار ۱ — OpenRewrite، وقتی سورس را داری. یک بار اجرا میکنی و importها، وابستگیهای Maven/Gradle و بسیاری از APIهای حذفشده جابهجا میشوند (برای هدف قبلی، …jakarta.JakartaEE10):
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JakartaEE11
ابزار ۲ — Eclipse Transformer، وقتی سورس یک کتابخانه را نداری. روی بایتکد کار میکند: یک JAR/WAR/EAR میگیرد و یکی جدید با نامهای عوضشده میدهد. در Maven بهصورت org.eclipse.transformer:transformer-maven-plugin (نسخهی 0.5.0) با <extensions>true</extensions> و قاعدهی <jakartaDefaults>true</jakartaDefaults> اضافه میشود و goal های jar و transform را دارد.
اشتباه رایج: همزمان عوض کردن JDK، namespace، سرور و فریمورک — بعد وقتی چیزی خراب شد، هیچکس نمیداند کدام تغییر مقصر است.
ترتیب امن: (۱) اول JDK را بالا ببر و روی همان سرور قدیمی تست کن؛ (۲) کتابخانهها را تا آخرین نسخهی javax برسان؛ (۳) namespace را با OpenRewrite عوض کن و روی یک servlet container ساده تست کن؛ (۴) آخر سر runtime را عوض کن. هر مرحله یک commit و یک اجرای کامل تست. بدون تور ایمنی تست (testing) این مهاجرت قمار است.
چون Oracle علامت تجاری Java را نگه داشت و اجازهی تکامل بستههای javax.* را نداد، فقط اجازهی نگهداری بدون تغییر؛ برای اینکه specها بتوانند رشد کنند، namespace باید عوض میشد.
سازگاری باینری ممکن نبود چون نام بسته جزئی از هویت کلاس در JVM است؛ نمیشود گفت «jakarta.servlet.Filter همان javax.servlet.Filter است». راهحل عملی: بازنویسی سورس با OpenRewrite یا بازنویسی بایتکد با Eclipse Transformer.
۵. محیط اجرا: سرور، کانتینر، fat jar
سه چیز را مردم قاطی میکنند. application server مثل هتل کامل است: چمدانت (WAR/EAR) را میبری و همهی سرویسها آمادهاند. servlet container مثل آپارتمان خالی است: دیوار و برق و آب داری (HTTP و چرخهی حیات Servlet) ولی یخچال و لباسشویی را خودت باید بیاوری (JPA، تراکنش، DI). fat jar مثل کاروان است: خانه را با خودت حمل میکنی — هیچ زیرساخت بیرونی لازم نیست، ولی مسئول هر چیزی هستی که داخلش گذاشتهای، از جمله وصلههای امنیتی.
| ویژگی | Application Server | Servlet Container | Fat Jar |
|---|---|---|---|
| مثال | WildFly، WebLogic، Liberty، Payara | Tomcat، Jetty، Undertow | Spring Boot، Quarkus، Helidon |
| قالب بسته · مالک lifecycle | WAR / EAR · سرور | WAR · سرور | JAR اجرایی · خود برنامه |
| XA · چند برنامه در یک process | داخلی · بله | ندارد · بله | افزودنی (Narayana) · خیر |
| مناسب K8s · بالا آمدن | متوسط · چند تا دهها ثانیه | خوب · چند ثانیه | عالی · زیر ثانیه تا چند ثانیه |
| وصلهی امنیتی runtime | مستقل از برنامه | مستقل از برنامه | نیاز به build مجدد برنامه |
آن سطر آخر مهمتر از چیزی است که به نظر میرسد: وقتی یک CVE در Tomcat منتشر میشود و صد میکروسرویس Spring Boot داری، باید صد برنامه را دوباره build و deploy کنی؛ در مدل application server تیم عملیات یک بار runtime را patch میکند. جواب مدرن، خودکارسازی build و base image مشترک است (containers-jvm) — و آوردن هر دو طرف این بحث در مصاحبه نشان میدهد شعار تکرار نمیکنی.
| Runtime | وضعیت امروز |
|---|---|
| WildFly | توزیع استاندارد از WildFly 40 (مه ۲۰۲۶) روی Jakarta EE 11؛ پیش از آن EE 11 در WildFly Preview بود |
| Payara Community · GlassFish | Payara 7 گواهی EE 11 (نسخهی ۶ روی EE 10)؛ GlassFish 8 پیادهسازی سازگار و مرجع EE 11 |
| Open Liberty · TomEE | Liberty روی EE 11 + MicroProfile 7.1 (feature-based، سبک)؛ TomEE 10.x روی EE 10 + MicroProfile 6.1 |
| Apache Tomcat | 11.0 = Servlet 6.1 · 10.1 = Servlet 6.0 · 10.0 = Servlet 5.0 · 9.x = Servlet 4.0 (javax) |
| WebLogic / WebSphere سنتی | عمدتاً Java EE 7/8 — دنیای legacy سازمانی |
Tomcat 9 و پایینتر فقط javax.servlet را میفهمند؛ Tomcat 10 و بالاتر فقط jakarta.servlet. هیچ حالت سازگاری دوطرفهای وجود ندارد. WAR قدیمی روی Tomcat 10+ ساکت نادیده گرفته میشود و ۴۰۴ میگیری، بدون خطای واضح در لاگ. همین برای Spring هم صادق است: Spring Framework 6 به بالا jakarta است و Spring Framework 7 روی Jakarta EE 11 نشسته و Tomcat 11+ یا Jetty 12.1+ میخواهد.
۶. Servlet: پایهای که همهچیز روی آن ساخته شده
Servlet یعنی کلاسی که یک درخواست HTTP میگیرد و یک پاسخ میدهد. هر چیزی که در وب Java میبینی — JSF، JAX-RS، Spring MVC، حتی Spring Boot در حالت پیشفرض — در نهایت روی Servlet سوار است.
servlet container مثل ادارهای است که در را باز میکند، صف را مدیریت میکند و مشتری را جلوی باجه میگذارد؛ Servlet تو فقط کارمند پشت باجه است. نکتهی حیاتی: اداره برای هر مشتری کارمند جدید استخدام نمیکند — یک instance ساخته میشود و دهها thread همزمان واردش میشوند.
نمودار: چرخهی حیات یک Servlet از دید کانتینر.
stateDiagram-v2
[*] --> Loaded: class loaded by container
Loaded --> Initialized: init(ServletConfig) once
Initialized --> Servicing: service() per request
Servicing --> Servicing: many threads concurrently
Servicing --> Destroyed: destroy() on shutdown or undeploy
Destroyed --> [*]
یک نمونهی کمینه: کلاسی با @WebServlet(urlPatterns = {"/health"}, loadOnStartup = 1) که از HttpServlet ارث میبرد، یک شمارندهی AtomicLong دارد (state مشترک باید thread-safe باشد) و doGet را override میکند تا JSON را در resp.getWriter() بنویسد.
یک فیلد نمونهی معمولی مثل private String currentUser; بگذار و در doGet تغییرش بده — باگی ساختهای که فقط زیر بار واقعی خودش را نشان میدهد: با ۵۰ کاربر همزمان، کاربر A دادهی کاربر B را میبیند؛ همان کلاس باگی که در گزارشهای امنیتی «نشت اطلاعات بین کاربران» طبقهبندی میشود. قانون: Servletها stateless باشند و state درخواست در متغیر محلی یا HttpServletRequest بماند (sync-locks-jmm).
قدیمیترها SingleThreadModel را راه فرار میدانستند — آن interface در Servlet 6.0 کاملاً حذف شد. در همان نسخه HttpSessionContext، HttpUtils، encodeUrl()، encodeRedirectUrl()، isRequestedSessionIdFromUrl() و ServletContext.getRealPath() هم حذف شدند — همینها دلیل شکست کامپایل بعد از مهاجرت به Tomcat 10.1 هستند.
Filter زنجیرهی میانافزار است: کلاسی با @WebFilter(urlPatterns = "/api/*") که Filter را پیاده میکند، قبل و بعد از Servlet اجرا میشود و باید chain.doFilter(req, res) را صدا بزند وگرنه درخواست همانجا میمیرد؛ معادل مفهومی فیلترهای امنیتی و HandlerInterceptor در Spring. کاربرد کلاسیکش گذاشتن یک شناسهی همبستگی در MDC است: از Servlet 6.0 متد getRequestId() روی درخواست، شناسهای یکتا در طول عمر کانتینر میدهد (کنارش getProtocolRequestId() و getServletConnection() هم آمدند) — الگوی درست در logging و observability. Listener — کلاسی با @WebListener که ServletContextListener یا HttpSessionListener را پیاده میکند — به رویدادهای چرخهی حیات گوش میدهد: بالا آمدن context، ساخت session، تغییر attribute.
قبل از Servlet 3.0 همهچیز باید در WEB-INF/web.xml اعلام میشد. امروز annotation کافی است، ولی web.xml هنوز برای تنظیمات محیطی، override، امنیت اعلانی و ترتیب فیلترها (که با annotation قابل کنترل نیست) لازم میشود:
یک نمونهی کمینه <session-timeout>30</session-timeout> را میگذارد، کوکی نشست را با <http-only>true</http-only> و <secure>true</secure> علامت میزند و <tracking-mode>COOKIE</tracking-mode> را قفل میکند.
امنیت اعلانی در سطح URL هم در همین فایل است: یک <security-constraint> که یک <web-resource-collection> (مثلاً /admin/*) را به یک <auth-constraint> با نام نقش لازم گره میزند.
myapp.war
├── index.html , css/ , js/ ← عمومی، مستقیم قابل دسترسی
└── WEB-INF/
├── web.xml , beans.xml ← پیکربندی وب و فعالسازی CDI
├── classes/ , lib/ ← کلاسهای خودت و JAR های وابسته
└── views/ ← غیرقابل دسترسی از بیرون
۱. ساختار WAR: هرچیزی بیرون WEB-INF مستقیماً از HTTP قابل دریافت است؛ هرچیزی داخل آن هرگز مستقیماً سرو نمیشود. قالبها، فایلهای حساس و config همیشه داخل WEB-INF.
۲. tracking-mode: اگر URL را مجاز بگذاری، کانتینر میتواند ;jsessionid=... را داخل URL بگذارد و آن شناسه در لاگ وبسرور، هدر Referer و تاریخچهی مرورگر نشت میکند — یک راه کلاسیک session hijacking. همیشه COOKIE تنها حالت مجاز باشد، با HttpOnly و Secure.
کانتینر کلاس را بارگذاری میکند، یک instance میسازد، یک بار init(ServletConfig) را صدا میزند، برای هر درخواست service() را فراخوانی میکند که به doGet/doPost/… مسیریابی میشود، و در پایان یک بار destroy().
نکتهای که سطح را نشان میدهد: یک instance، چند thread همزمان؛ پس هر فیلد نمونهی قابل تغییر یک باگ concurrency است. و اگر loadOnStartup مثبت باشد، instance هنگام بالا آمدن ساخته میشود نه در اولین درخواست — مفید برای کشف زودهنگام خطای پیکربندی.
JSP و JSF هم همینجا جا میگیرند. JSP یعنی HTML با کد Java تعبیهشده که کانتینر آن را به یک Servlet ترجمه میکند؛ JSF یک فریمورک کامپوننتمحور و stateful برای UI سمت سرور است که درخت کامپوننت را روی سرور نگه میدارد. کنار رفتند چون UI به React/Angular/Vue رفت و چون state سمت سرور برای مقیاس افقی نیاز به replication یا sticky session دارد. با این حال JSF زنده است (Faces 4.1 در EE 11) و برای پنل ادمین هنوز بهرهور است — در مصاحبه نگو «JSF مرده»، بگو «برای این کلاس مسائل کنار گذاشته شد و برای آن کلاس هنوز منطقی است».
۷. CDI: قلب Jakarta EE مدرن
CDI یعنی Contexts and Dependency Injection: تزریق وابستگی بهعلاوهی context (کانتینر میداند هر شیء چقدر عمر میکند و کِی نابود شود). اگر DI ساده انباری است که هر ابزاری بخواهی میدهد، CDI انباری است با قانون امانت: این دریل تا آخر شیفت مال توست، این نردبان تا آخر پروژه، و آن جرثقیل یکی است و تا ابد در انبار میماند — و انبار موقع پس گرفتن تمیزکاری هم میکند.
«bean» یعنی کلاسی که کانتینر بتواند نمونهاش را بسازد و مدیریت کند: یک constructor بدون آرگومان (یا یکی با @Inject) و قرار داشتن در یک bean archive — یعنی JAR یا WEB-INF/classes که CDI در آن دنبال bean میگردد و با beans.xml کنترل میشود.
تا CDI 3.0 یک beans.xml خالی یعنی «همهی کلاسهای این آرشیو bean هستند» (bean-discovery-mode=all). از CDI 4.0 (Jakarta EE 10 به بعد) beans.xml خالی یعنی annotated: فقط کلاسهایی که یک annotation تعریفکنندهی bean دارند (@ApplicationScoped، @RequestScoped، @Dependent، @Stateless، …) شناسایی میشوند.
نتیجه: بعد از ارتقا، تزریقهایی که سالها کار میکردند با UnsatisfiedResolutionException میترکند. راه درست: scope صریح بده. راه سریع و موقت: نوشتن صریح bean-discovery-mode="all" با schema beans_4_0.xsd.
| Scope | عمر | معادل تقریبی در Spring |
|---|---|---|
@Dependent (پیشفرض) |
به عمر تزریقکننده وابسته است | prototype |
@RequestScoped |
یک درخواست HTTP یا یک پیام | request |
@SessionScoped |
یک نشست HTTP (کلاس باید Serializable باشد) |
session |
@ApplicationScoped · @ConversationScoped |
کل عمر برنامه · چند درخواست با شروع و پایان دستی | singleton · معادل ندارد |
دو پیادهسازی از یک interface داری؛ کانتینر از کجا بداند کدام را تزریق کند؟ در Spring @Qualifier("name") یا @Primary؛ در CDI، qualifier یک annotation تایپدار است:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.PARAMETER})
public @interface Gateway {
Provider value();
enum Provider { CARD, WALLET }
}
@ApplicationScoped @Gateway(Gateway.Provider.CARD)
class CardGateway implements PaymentGateway { /* ... */ }
@ApplicationScoped
public class CheckoutService {
private final PaymentGateway gateway;
@Inject
CheckoutService(@Gateway(Gateway.Provider.CARD) PaymentGateway gateway) {
this.gateway = gateway; // بدون ابهام، بدون رشتهی جادویی
}
}
۱. پیشفرض scope تله است. در Spring پیشفرض singleton است؛ در CDI پیشفرض @Dependent — یعنی هر بار تزریق، یک instance تازه که عمرش به عمر میزبان گره میخورد. اگر انتظار singleton داشته باشی و scope ننویسی، ممکن است هزاران شیء بسازی یا cache داخلیات هرگز کار نکند. قانون: همیشه scope را صریح بنویس.
۲. qualifier تایپدار از رشته بهتر است. @Qualifier("cardGatway") با غلط املایی در Spring تا runtime ساکت میماند؛ معادل CDI را کامپایلر بررسی میکند و رفکتور امن است.
برای اشیائی که خودت نمیسازی — مثلاً یک HttpClient — producer method معادل @Bean در Spring است: متدی با @Produces و در صورت نیاز یک @Disposes برای آزادسازی. رویدادها هم یک event bus داخلی و همگام میدهند، معادل ApplicationEventPublisher:
@Inject Event<OrderPlaced> orderPlaced; // انتشار
orderPlaced.fire(new OrderPlaced(orderId, amountMinor));
// دریافت — فقط بعد از commit موفق تراکنش اجرا میشود
void onOrder(@Observes(during = TransactionPhase.AFTER_SUCCESS) OrderPlaced event) { }
آن TransactionPhase.AFTER_SUCCESS یک ابزار سنیوری است: باگ «ایمیل فرستادم ولی تراکنش rollback شد» یکی از رایجترین اشکالات منطقی سیستمهای سازمانی است، و این تضمین میکند عارضهی بیرونی فقط بعد از commit موفق رخ دهد (معادل Spring: @TransactionalEventListener).
Interceptor نسخهی استاندارد و اعلانی AOP است: بهجای pointcut expression، یک annotation با @InterceptorBinding میسازی (مثلاً @Audited)، بعد کلاسی با @Audited @Interceptor @Priority(Interceptor.Priority.APPLICATION) که متدی با @AroundInvoke و پارامتر InvocationContext دارد و داخلش ctx.proceed() را صدا میزند. بدون @Priority (یا اعلان در beans.xml) اصلاً فعال نمیشود — اشتباه بسیار رایج.
interceptorهای CDI، تراکنشهای EJB و @Transactional در Spring همه روی proxy کار میکنند. اگر متد a() داخل همان کلاس this.b() را صدا بزند، فراخوانی از proxy عبور نمیکند و هیچ interceptor یا تراکنشی اعمال نمیشود. باگ ساکت است: کد درست به نظر میرسد، تستهای واحد پاس میشوند و در production تراکنشی وجود ندارد. راهحل: متد را به یک bean دیگر ببر، یا bean را به خودش تزریق کن.
قرارداد: CDI یک spec است با چند پیادهسازی (Weld، OpenWebBeans، موتور Quarkus)؛ Spring DI یک محصول. پیشفرض scope: Spring singleton، CDI @Dependent. انتخاب bean: Spring رشتهی qualifier و @Primary؛ CDI annotation تایپدار که در برابر رفکتور امنتر است. AOP: pointcut expression در Spring بسیار قدرتمندتر؛ interceptor binding در CDI سادهتر و کمجادوتر.
نکتهای که سطح را نشان میدهد: CDI کلاسیک همهی تزریقها را در زمان deploy اعتبارسنجی میکند؛ وابستگی مبهم یا مفقود یعنی برنامه اصلاً بالا نمیآید. در عوض، اکوسیستم بیچونوچرا برد Spring است.
۸. EJB: چرا بدنام شد و چه چیزی ماند
EJB (امروز Jakarta Enterprise Beans) ایدهی سادهای داشت: تو منطق کسبوکار را بنویس، کانتینر تراکنش، امنیت، pooling و concurrency را انجام دهد. بدنامی از EJB 2.x میآید: برای یک bean ساده یک interface Home، یک Remote، یک کلاس bean و یک ejb-jar.xml طولانی؛ Entity Beanها کند و پیچیده بودند و جایشان را JPA گرفت؛ و تست کردن تقریباً غیرممکن بود. همین فضا Spring را ساخت. از EJB 3.0 (۲۰۰۶) همهی آن پیچیدگی حذف شد ولی شهرت بد ماند؛ در Jakarta EE 9 هم APIهای EJB 2.x رسماً اختیاری شدند.
@Stateless
public class InvoiceService {
@PersistenceContext(unitName = "billingPU")
private EntityManager em;
// پیشفرض REQUIRED: اگر تراکنشی هست بپیوند، وگرنه بساز
@RolesAllowed("BILLING_CLERK")
public void issue(Invoice invoice) {
em.persist(invoice);
}
// همیشه تراکنش جدید — برای لاگ حسابرسی که نباید با شکست اصلی rollback شود
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void auditTrail(String message) {
em.persist(new AuditEntry(message));
}
}
نکتهی بزرگ: هیچ @Transactionalای در کد نیست و با این حال هر متد عمومی تراکنشدار است. این همان CMT (Container-Managed Transaction) است.
چهار نوع bean داریم: @Stateless (بدون state بین فراخوانیها، از یک pool — سرویسهای کسبوکار، ۹۰٪ موارد)، @Stateful (state مخصوص هر کلاینت — جادوگر چندمرحلهای، سبد خرید)، @Singleton (یک instance در کل برنامه — cache، زمانبند، بارگذاری اولیه) و @MessageDriven (مصرفکنندهی پیام، بدون کلاینت مستقیم).
| Transaction attribute | تراکنش موجود هست | تراکنش موجود نیست |
|---|---|---|
REQUIRED (پیشفرض) |
میپیوندد | جدید میسازد |
REQUIRES_NEW |
موجود را معلق میکند، جدید میسازد | جدید میسازد |
MANDATORY |
میپیوندد | خطای EJBTransactionRequired |
SUPPORTS |
میپیوندد | بدون تراکنش اجرا |
NOT_SUPPORTED |
معلق میکند، بدون تراکنش اجرا | بدون تراکنش |
NEVER |
EJBException |
بدون تراکنش |
در CMT، تراکنش فقط با exception های unchecked بهطور خودکار rollback میشود؛ یک checked exception پیشفرض rollback نمیکند مگر کلاسش با @ApplicationException(rollback = true) علامت بخورد. فاجعهی رایج: یک checked exception پرتاب میشود، تیم فکر میکند rollback شده، و در واقع دادهی نیمهکاره commit میشود. رفتار پیشفرض @Transactional در Spring هم همین است و با rollbackFor تنظیم میشود (tx-acid).
نکتهی دوم: @Stateful یک شیء در حافظهی یک node نگه میدارد و در استقرار چندنودی به replication (گران) یا sticky session (شکننده) نیاز دارد — در معماری cloud-native تقریباً همیشه اشتباه است (caching).
بدنامی از EJB 2.x میآید: home/remote interface اجباری، XML فراوان، entity beanهای کند و تستناپذیری کامل. از EJB 3.0 به بعد مدل یک POJO ساده با @Stateless شد. آنچه ماند و واقعاً ارزشمند است: تراکنش مدیریتشده توسط کانتینر، pooling خودکار، thread-safety در سطح متد، امنیت اعلانی با @RolesAllowed، تایمر با @Schedule و مصرفکنندهی پیام با @MessageDriven.
جواب متعادل: در پروژهی جدید امروز EJB انتخاب نمیکنم — CDI بههمراه @Transactional همان کار را تمیزتر و سازگارتر با Quarkus انجام میدهد. ولی در سیستم موجود، بازنویسی EJB فقط بهخاطر «قدیمی بودن» ریسک بیدلیل است.
۹. JPA، JTA و تراکنشهای توزیعشده
عمق JPA در فصل jpa-hibernate است؛ اینجا فقط بخش مخصوص Jakarta EE: persistence unit مدیریتشده توسط کانتینر. در META-INF/persistence.xml یک <persistence-unit name="billingPU" transaction-type="JTA"> تعریف میکنی که به یک <jta-data-source> اشاره دارد.
با JTA کانتینر مالک تراکنش است؛ EntityManager را با @PersistenceContext میگیری و هرگز em.getTransaction() را صدا نمیزنی (اگر بزنی IllegalStateException میگیری). با RESOURCE_LOCAL خودت مالک هستی و begin()/commit() را صریح مینویسی — حالت Java SE. قاطی کردن این دو یکی از رایجترین خطاهای runtime هنگام تست خارج از سرور است.
JTA (Jakarta Transactions) استانداردی است برای تراکنشهایی که چند منبع را در بر میگیرند: دو دیتابیس، یا یک دیتابیس و یک صف. مکانیزم زیرین two-phase commit است و به درایورهای XA نیاز دارد — مثل معاملهای با محضردار: اول از همه میپرسد «آمادهای؟» (فاز prepare) و فقط اگر همه بله بگویند دستور امضا میدهد (فاز commit).
نمودار: پروتکل two-phase commit در JTA.
sequenceDiagram
participant App as Application
participant TM as Transaction Manager
participant DB as XA Database
participant MQ as XA Message Broker
App->>TM: begin()
App->>DB: insert row
App->>MQ: send message
App->>TM: commit()
TM->>DB: prepare
TM->>MQ: prepare
DB-->>TM: vote yes
MQ-->>TM: vote yes
TM->>DB: commit
TM->>MQ: commit
TM-->>App: committed
در Jakarta EE مدرن حتی بدون EJB هم تراکنش اعلانی داری: کافی است @Transactional(Transactional.TxType.REQUIRED) را روی متد یک CDI bean بگذاری. توجه: این jakarta.transaction.Transactional است نه نسخهی Spring؛ رفتار مشابه است ولی با rollbackOn و dontRollbackOn تنظیم میشود. برای کنترل دستی، @Resource UserTransaction utx; را تزریق میکنی.
بین فاز prepare و commit، منابع قفل میمانند؛ اگر transaction manager در آن لحظه بمیرد، تراکنش «in-doubt» میشود و تا بازیابی ردیفها قفل باقی میمانند. throughput هم بهشدت پایین میآید و در محیط کانتینری نگهداری لاگ بازیابی پایدارِ آن دردسر است.
جایگزین مدرن: الگوی outbox و saga — بهجای یک تراکنش اتمی روی دو سیستم، پیام را در همان تراکنش دیتابیس در جدول outbox بنویس و یک فرایند جدا آن را منتشر کند (ms-data، messaging).
خواندن رویدادهای منتشرنشدهی outbox توسط چند worker همزمان:
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
FETCH FIRST 100 ROWS ONLY
FOR UPDATE SKIP LOCKED;۱. PostgreSQL LIMIT دارد، Oracle از 12c به بعد FETCH FIRST … ROWS ONLY؛ نوشتن LIMIT در Oracle خطای نحوی است. ۲. در PostgreSQL BEGIN تراکنش را شروع میکند؛ در Oracle هر session بهصورت ضمنی در تراکنش است و BEGIN شروع بلاک PL/SQL است. ۳. FOR UPDATE SKIP LOCKED در هر دو یکسان است و قهرمان گمنام صفهای دیتابیسی است: بدون آن، چند worker پشت هم قفل میشوند و throughput به یک worker تقلیل مییابد (rdbms-tuning).
CMT یعنی کانتینر مرزهای تراکنش را با @TransactionAttribute تعیین میکند؛ BMT یعنی خودت با UserTransaction مرزها را مینویسی. تقریباً همیشه CMT درست است: کد کمتر، خطای کمتر، مرزهای اعلانی قابل بازبینی. BMT فقط وقتی لازم است که مرز تراکنش با مرز متد یکی نباشد — مثلاً حلقهی batch که هر ۱۰۰۰ رکورد یک commit میزند تا لاگ undo منفجر نشود.
نکتهای که جوابت را برجسته میکند: مرز تراکنش باید کوتاه باشد و هرگز نباید یک فراخوانی شبکهی بیرونی داخل تراکنش باشد — یک HTTP کند یعنی قفل ماندن ردیفهای دیتابیس.
۱۰. JMS و Bean Validation
JMS (امروز Jakarta Messaging) یک API استاندارد برای صف و topic است — نه یک پروتکل سیمی مثل AMQP؛ یعنی با یک کلاینت JMS به هر broker دلخواهی وصل نمیشوی. دو مدل دارد: Queue (هر پیام به یک مصرفکننده) و Topic (هر پیام به همهی مشترکان). مصرفکننده در Jakarta EE کلاسیک یک MDB است:
@MessageDriven(activationConfig = {
@ActivationConfigProperty(propertyName = "destinationType",
propertyValue = "jakarta.jms.Queue"),
@ActivationConfigProperty(propertyName = "destinationLookup",
propertyValue = "java:/jms/queue/Orders")
})
public class OrderConsumer implements MessageListener {
@Override
public void onMessage(Message m) {
try { if (m instanceof TextMessage t) { process(t.getText()); } }
catch (JMSException e) { throw new IllegalStateException("bad message", e); }
}
}
سمت تولید، JMSContext را با @Inject میگیری، مقصد را با @Resource(lookup = "…") و بعد context.createProducer().send(queue, payload) — داخل یک متد @Transactional.
پیامها در MDB پیشفرض داخل تراکنش کانتینر مصرف میشوند. اگر onMessage یک RuntimeException پرتاب کند، تراکنش rollback میشود و پیام دوباره تحویل داده میشود — و اگر باگ قطعی باشد، بینهایت بار؛ صف عملاً میایستد.
راه درست: سقف تحویل مجدد و dead-letter queue را در سطح broker پیکربندی کن، و خطاهای غیرقابل بازیابی را داخل خود متد بگیر و به DLQ بفرست بهجای اینکه بگذاری تراکنش rollback شود (messaging، resilience).
Jakarta Validation (که همه هنوز Bean Validation صدایش میزنند) استاندارد اعتبارسنجی با annotation است — و Spring هم دقیقاً از همین spec استفاده میکند (پیادهسازی رایج: Hibernate Validator)، پس این دانش صددرصد بین دو دنیا منتقل میشود.
public record CreateOrderRequest(
@NotBlank @Size(max = 64) String accountId,
@Positive long amountMinor,
@Email String notifyEmail) {}
نکتهی مخصوص Jakarta EE: اعتبارسنجی خودکار در چند نقطه اعمال میشود — روی پارامترهای متدهای EJB و CDI با @Valid، روی بدنهی درخواست در JAX-RS، و روی entity های JPA قبل از persist/update. همین مورد آخر غافلگیرکننده است: خطا در لحظهی flush پرتاب میشود که میتواند خیلی دورتر از جایی باشد که فکر میکنی؛ با jakarta.persistence.validation.mode میتوانی آن را روی NONE یا CALLBACK تنظیم کنی.
۱۱. JAX-RS در برابر Spring MVC
JAX-RS (امروز Jakarta RESTful Web Services، نسخهی 4.0 در EE 11) استاندارد ساخت REST API است.
@Path("/orders")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject OrderService service;
@Context UriInfo uriInfo;
@GET @Path("/{id}")
public Response findById(@PathParam("id") String id) {
return service.find(id).map(Response::ok)
.orElseGet(() -> Response.status(Response.Status.NOT_FOUND)).build();
}
@POST
public Response create(@Valid CreateOrderRequest request) {
String id = service.create(request);
return Response.created(uriInfo.getAbsolutePathBuilder().path(id).build()).build();
}
}
@ApplicationPath("/api")
public class RestActivator extends Application {}
مدیریت خطا با یک کلاس @Provider که ExceptionMapper<MyException> را پیاده میکند — معادل @ControllerAdvice با @ExceptionHandler در Spring.
| موضوع | JAX-RS | Spring MVC |
|---|---|---|
| نگاشت مسیر · متد HTTP | @Path · @GET/@POST/@PUT/@DELETE |
@RequestMapping · داخل همان annotation |
| پارامتر مسیر · کوئری | @PathParam · @QueryParam + @DefaultValue |
@PathVariable · @RequestParam |
| بدنه · هدر | پارامتر بدون annotation · @HeaderParam |
@RequestBody · @RequestHeader |
| مذاکرهی محتوا · فیلتر | @Produces/@Consumes · ContainerRequestFilter |
produces/consumes · HandlerInterceptor |
| مدیریت خطا | ExceptionMapper |
@ControllerAdvice + @ExceptionHandler |
| کلاینت · فعالسازی | jakarta.ws.rs.client.Client · کلاس Application |
RestClient/WebClient · خودکار در Boot |
از نظر طراحی JAX-RS تمیزتر است و کلاینت استاندارد در همان spec دارد؛ Spring MVC از نظر بهرهوری جلوتر است چون بدون کلاس فعالسازی کار میکند و با validation و امنیت و مستندسازی از پیش سیمکشی شده. نکتهی سنیوری: هر دو روی Servlet مینشینند و هیچکدام یک API خوب طراحی نمیکنند — کیفیت API از نامگذاری منابع، کدهای وضعیت، صفحهبندی، نسخهبندی و idempotency میآید (api-design).
مفهوماً یک کار میکنند و نگاشت annotationها تقریباً یکبهیک است. سه تفاوت واقعی: (۱) در JAX-RS پارامتری که هیچ annotation ندارد خودکار بدنه است، در Spring باید @RequestBody بنویسی — یعنی در JAX-RS فراموش کردن یک annotation میتواند رفتار غافلگیرکننده بدهد؛ (۲) مدل پاسخ: Response با builder در برابر ResponseEntity؛ (۳) JAX-RS یک Client API در همان spec دارد و MicroProfile Rest Client نسخهی type-safe و اعلانی روی آن میسازد.
و یک نکتهی مهم: در Jakarta EE منبع JAX-RS معمولاً خودش یک CDI bean است، پس @Inject مستقیماً کار میکند — دو دنیا یکپارچهاند.
۱۲. JAX-WS، JNDI و Jakarta Security
SOAP پروتکل پیام مبتنی بر XML است و WSDL قراردادی ماشینخوان که میگوید سرویس چه عملیاتی دارد. JAX-WS (Jakarta XML Web Services) API استاندارد Java برای این دنیاست: یک کلاس با @WebService و متدهایی با @WebMethod کافی است تا کانتینر WSDL را تولید کند؛ ابزار wsimport هم از یک WSDL کلاسهای کلاینت میسازد (در JDKهای مدرن از خود JDK حذف شده و از توزیع مستقل Jakarta XML Web Services Tools میآید).
در Jakarta EE 9 خانوادهی سرویسهای وب XML از هستهی پلتفرم بیرون رفت و اختیاری شد — ولی در بانک، بیمه و تبادل دادهی بینسازمانی هنوز رایج است، چون WSDL قراردادی رسمی و قابل تولید کد میدهد و WS-Security امضا و رمزنگاری در سطح پیام دارد، نه فقط سطح انتقال مثل TLS. معادل مدرن این قرارداد رسمی، OpenAPI و gRPC هستند (api-design، grpc-protobuf).
JNDI یعنی Java Naming and Directory Interface: دفترچهتلفن داخل سرور. برنامه با @Resource(lookup = "java:app/jdbc/BillingDS") یک DataSource میگیرد بدون اینکه بداند پشت آن نام چیست — مثل تلفنچی شرکت: نام منطقی ثابت میماند، منبع فیزیکی عوض میشود. خود datasource در سرور تعریف میشود (WildFly با jboss-cli.sh، Payara با asadmin create-jdbc-connection-pool) یا با annotation استاندارد @DataSourceDefinition. فضاهای نام به ترتیب دامنهی دید: java:comp/env/…، java:module/…، java:app/… (قابل حملترین انتخاب) و java:global/…؛ هر چیزی مثل java:jboss/… اختصاصی فروشنده است.
چرا pool را داخل برنامه نگذاریم؟ وقتی ۴۰ سرویس Spring Boot هر کدام یک HikariCP بیستتایی دارند، دیتابیس با ۸۰۰ اتصال روبهرو میشود در حالی که max_connections معمولاً خیلی کمتر است — قطعی سراسری در ساعت اوج. در مدل container-managed این بودجه در یک جا دیده و پایش میشود؛ اگر Spring Boot را انتخاب میکنی، بودجهی اتصال را در سطح پلتفرم مدیریت کن.
Jakarta Security (نسخهی 4.0 در EE 11) امنیت را استاندارد کرد؛ قبلاً هر سرور مفهوم خودش را داشت. سه مفهوم پایه: Authentication Mechanism (@BasicAuthenticationMechanismDefinition، @FormAuthenticationMechanismDefinition یا @OpenIdAuthenticationMechanismDefinition برای OIDC)، Identity Store (مثلاً @DatabaseIdentityStoreDefinition با callerQuery، groupsQuery و یک hashAlgorithm)، و SecurityContext که با @Inject میگیری و getCallerPrincipal() میدهد. برای مجوزدهی @RolesAllowed روی EJB و CDI bean کار میکند و در سطح URL هم <security-constraint>. در دنیای میکروسرویس معمولاً MicroProfile JWT استفاده میشود؛ عمق RBAC/ABAC در ms-security است.
۱۳. بستهبندی: WAR، EAR و fat jar
WAR یک ماژول وب است. EAR (Enterprise Archive) ظرفی است که چند ماژول را با هم بستهبندی میکند — چند WAR، چند EJB-JAR و کتابخانههای مشترک در lib/ — با یک META-INF/application.xml که ماژولها و context-root ها را فهرست میکند.
قانون کلی: هر WAR کلاسهای EAR/lib را میبیند، ولی EAR کلاسهای داخل WAR را نمیبیند و دو WAR همدیگر را نمیبینند. علاوه بر آن، اکثر سرورها یک لایهی ماژول خودشان دارند که ممکن است نسخهای از یک کتابخانهی محبوب را قبل از نسخهی تو بارگذاری کند. نتیجهی کلاسیک: LinkageError یا NoSuchMethodError که فقط روی سرور رخ میدهد و روی لپتاپ هرگز. راه کنترل: در WildFly فایل WEB-INF/jboss-deployment-structure.xml برای exclude کردن ماژولهای سرور، و در بسیاری از سرورها تنظیم «parent-last».
WAR یک ماژول وب است که روی servlet container یا application server مستقر میشود؛ EAR بستهبندی چندماژولی است که برای دورانی طراحی شد که یک سرور گران، چند برنامه را میزبانی میکرد؛ fat jar همهچیز از جمله سرور را داخل خودش دارد.
برای سیستم جدید، fat jar انتخاب پیشفرض است چون با کانتینر و Kubernetes همراستاست: یک artifact، یک process، یک واحد مقیاسپذیری. اما جواب کامل باید trade-off را هم بگوید: با fat jar، وصلهی امنیتی runtime یعنی build دوبارهی همهی سرویسها، و مصرف کل حافظه بالاتر میرود چون هر process یک JVM و یک وبسرور کامل دارد. اگر پنج برنامهی داخلی کوچک داری که یک تیم نگهداری میکند، یک application server مشترک واقعاً میتواند ارزانتر باشد.
۱۴. MicroProfile و نسل جدید runtimeها
Jakarta EE برای برنامههای بزرگ و یکپارچه طراحی شده بود. با موج میکروسرویس چیزهایی لازم شد که در spec نبود: پیکربندی بیرونی، health check، متریک، مستندسازی خودکار API، circuit breaker و JWT. MicroProfile همین شکاف را پر کرد: مجموعهای از specهای سبک روی Jakarta EE Core Profile.
| spec در MicroProfile 7.1 | نسخه | چه کاری میکند |
|---|---|---|
| Config | 3.1 | پیکربندی از متغیر محیطی، فایل و ConfigMap با اولویت مشخص |
| Fault Tolerance | 4.1 | @Retry، @Timeout، @CircuitBreaker، @Bulkhead، @Fallback |
| Health · Telemetry | 4.0 · 2.1 | /health/live و /health/ready · trace و متریک و لاگ با OpenTelemetry |
| OpenAPI · JWT Auth | 4.1 · 2.1 | تولید خودکار سند OpenAPI · احراز هویت استاندارد با JWT |
| Rest Client | 4.0 | کلاینت HTTP اعلانی و type-safe |
یک تغییر مهم که در مصاحبه امتیاز دارد: تا MicroProfile 6.1 MicroProfile Metrics بخشی از چتر بود؛ از MicroProfile 7.0 جایش را MicroProfile Telemetry گرفت که بر پایهی OpenTelemetry هر سه رکن مشاهدهپذیری را میدهد و Metrics به یک spec مستقل بیرون از چتر تبدیل شد — همان مسیری که کل صنعت رفت (observability).
@ApplicationScoped
public class RateClient {
@Inject @ConfigProperty(name = "pricing.currency", defaultValue = "EUR")
String currency;
@Retry(maxRetries = 3, delay = 200, jitter = 100)
@Timeout(value = 2, unit = ChronoUnit.SECONDS)
@CircuitBreaker(requestVolumeThreshold = 20, failureRatio = 0.5, delay = 10000)
@Fallback(fallbackMethod = "cachedRate")
public double currentRate(String pair) { /* فراخوانی سرویس بیرونی */ }
double cachedRate(String pair) { return 1.0; } // جایگزین، با همان امضا
}
ترتیب استاندارد اولویت پیکربندی از بالا به پایین: متغیرهای محیطی، سپس system property های JVM، سپس META-INF/microprofile-config.properties — همان چیزی که در Kubernetes با ConfigMap و Secret میخواهی. برای health، یک کلاس @Readiness @ApplicationScoped که HealthCheck را پیاده کند کافی است. و برای فراخوانی سرویس دیگر، یک interface با @RegisterRestClient(configKey = "catalog-api") بنویس و URL و timeout را در catalog-api/mp-rest/url و …/connectTimeout بگذار — معادل FeignClient.
مشکل application server کلاسیک این بود که برای دنیای کانتینر سنگین بود: حافظهی بالا، بالا آمدن کند، و reflection زیاد در زمان اجرا. نسل جدید همان APIها را برمیدارد ولی معماری اجرا را عوض میکند: Quarkus کارهای سنگین (پویش CDI، متادیتای JPA، جدول مسیرها) را به زمان build منتقل میکند و کامپایل native با GraalVM میدهد. Helidon از نسخهی ۴ روی virtual thread های Java 21 بازنویسی شده (virtual-threads). Open Liberty مدل feature-based دارد: فقط قابلیتهایی که در server.xml اعلام کنی (مثل restfulWS-4.0، cdi-4.1، mpConfig-3.1) بارگذاری میشوند.
quarkus create app com.example:billing --extension='rest-jackson,hibernate-orm-panache'
quarkus dev # توسعه با بارگذاری زنده
./mvnw package -Dnative -Dquarkus.native.container-build=true # اجرایی native
اگر یک سیستم legacy مبتنی بر Jakarta EE داری، مسیر Quarkus معمولاً کمدردتر از مسیر Spring Boot است: @Inject، @ApplicationScoped، @Path، @Transactional و entity های JPA همه همانطور میمانند و فقط لایهی زیرساخت عوض میشود. اگر به Spring Boot بروی، هر annotation باید ترجمه شود و ریسک خطای انسانی بسیار بالاتر است. این استدلال را در جلسهی تصمیم معماری بیاور — معمولاً کسی به آن فکر نکرده است.
MicroProfile مجموعهای از specهای سبک زیر نظر Eclipse Foundation است که همان کاری را برای میکروسرویسها میکند که Spring Boot و Spring Cloud در دنیای Spring. روی Jakarta EE Core Profile مینشیند، پس CDI، JAX-RS و JSON-B را از آن میگیرد.
نگاشت به Spring: @ConfigProperty ↔ @Value؛ HealthCheck ↔ Actuator؛ @Retry/@CircuitBreaker ↔ Resilience4j؛ @RegisterRestClient ↔ FeignClient؛ Telemetry ↔ Micrometer با OpenTelemetry. تفاوت اصلی: MicroProfile یک spec است که Quarkus، Helidon، Open Liberty و TomEE پیاده میکنند؛ Spring Boot یک محصول است.
Full Platform همهی specها را دارد، از جمله EJB کامل، JMS، Connectors و Batch. Web Profile از Java EE 6 وجود دارد و آنچه یک برنامهی وب معمولی لازم دارد را میدهد: Servlet، Faces، CDI، JPA، JTA، Validation، REST و EJB Lite — بدون JMS و Connectors؛ اکثر برنامههای واقعی فقط به همین نیاز دارند. Core Profile از Jakarta EE 10 اضافه شد برای میکروسرویس و کامپایل ahead-of-time: CDI Lite، RESTful Web Services، JSON-P، JSON-B، Annotations، Interceptors و Dependency Injection — همان پایهای که MicroProfile و Quarkus روی آن مینشینند. CDI Lite بدون reflection در زمان اجرا قابل پیادهسازی است و فقط @Dependent، @RequestScoped و @ApplicationScoped را دارد — دقیقاً همان چیزی که کامپایل native را ممکن میکند. انتخاب profile سبکتر یعنی سطح حملهی کمتر، بالا آمدن سریعتر و تصویر کانتینر کوچکتر.
۱۵. جدول بزرگ: Jakarta EE در برابر Spring
| محور | Jakarta EE | Spring / Spring Boot |
|---|---|---|
| ماهیت · حاکمیت | مجموعه spec با چند پیادهسازی و TCK؛ Eclipse Foundation | فریمورک با یک پیادهسازی؛ یک شرکت پشتیبان |
| مدل DI | CDI — qualifier تایپدار، پیشفرض @Dependent، اعتبارسنجی در زمان deploy |
Spring DI — qualifier رشتهای، پیشفرض singleton |
| AOP | interceptor binding اعلانی، ساده | AOP با pointcut expression، بسیار قدرتمند |
| تراکنش | @Transactional از Jakarta Transactions یا CMT در EJB؛ XA داخلی |
@Transactional در Spring؛ XA افزودنی |
| لایهی وب و داده | JAX-RS، Faces؛ JPA و از EE 11 Jakarta Data | Spring MVC، WebFlux؛ Spring Data |
| پیامرسانی · مشاهدهپذیری | JMS و MDB؛ MicroProfile Telemetry | Spring JMS/Kafka/AMQP؛ Micrometer + Actuator |
| پیکربندی · استقرار | web.xml/persistence.xml/MicroProfile Config؛ WAR/EAR یا fat jar در Quarkus |
application.yml و @ConfigurationProperties؛ fat jar |
| اکوسیستم | متمرکز روی هسته؛ محیط سازمانی | بسیار وسیع: Security، Batch، Cloud، Integration |
| منحنی یادگیری · تست | شیبدار در ابتدا؛ Arquillian یا Testcontainers | شروع آسان، جادوی زیاد در عمق؛ @SpringBootTest |
| کجا انتخاب میشود | بانک، بیمه، دولت، مخابرات؛ استقلال از فروشنده | استارتاپ، محصول SaaS، اکثر تیمهای وب |
| ریسک اصلی | اکوسیستم کوچکتر، منابع کمتر، استخدام سختتر | وابستگی به یک فروشنده، ارتقاهای بزرگ دورهای |
۱. تفاوت بنیادی در یک جمله: «Jakarta EE یک spec با چند پیادهسازی است؛ Spring یک فریمورک با یک پیادهسازی.» ۲. یک نتیجهی عملی: «به همین دلیل در Jakarta EE وابستگیها provided هستند و runtime قابل تعویض است؛ در Spring Boot همهچیز داخل fat jar است.» ۳. نشان بده هر دو را میشناسی: نگاشت واقعی بده — @Inject ↔ @Autowired، @Path ↔ @RequestMapping، @Stateless ↔ @Service با @Transactional، ExceptionMapper ↔ @ControllerAdvice، MicroProfile ↔ Actuator و Spring Cloud.
۴. با قضاوت تمام کن: «برای محصول جدید با تیم کوچک Spring Boot را انتخاب میکنم؛ برای سازمانی با تعهد دهساله و الزام استقلال از فروشنده، Jakarta EE با Quarkus یا Open Liberty کاملاً معقول است.» اگر بگویی «Spring بهتر است» یا «Jakarta EE قدیمی است»، امتیاز از دست میدهی — مصاحبهگر دنبال قضاوت است، نه طرفداری.
هر دو — و همین جواب درست است. Spring رقیب پلتفرم Jakarta EE بهعنوان یک کل است: مدل EJB، مدل استقرار روی application server و JSF را جایگزین میکند. ولی Spring مصرفکنندهی بسیاری از specهای آن است: Spring MVC روی Jakarta Servlet، Spring Data JPA روی Jakarta Persistence، اعتبارسنجی همان Jakarta Validation با Hibernate Validator، و پشتیبانی از @Inject/@Resource/@PostConstruct. شاهد قاطع: Spring Framework 6 برای همین به jakarta.* مهاجرت کرد و Spring Framework 7 (نوامبر ۲۰۲۵) روی APIهای Jakarta EE 11 نشسته و Tomcat 11+ یا Jetty 12.1+ میخواهد.
۱۶. مسیرهای مهاجرت
نمودار: درخت تصمیم برای مهاجرت یک سیستم Java EE قدیمی.
flowchart TD
A[Legacy Java EE app] --> B{Is the business logic<br/>worth preserving as-is?}
B -->|No, rewrite planned| C[Strangler Fig to<br/>Spring Boot or Quarkus]
B -->|Yes| D{Heavy use of EJB,<br/>JMS, JTA, JSF?}
D -->|Yes| E[Modernize in place:<br/>Jakarta EE 11 runtime]
D -->|No, mostly CDI + JAX-RS| F[Lift to Quarkus:<br/>same annotations]
E --> G[Then containerize]
F --> G
C --> G
مسیر ۱ — مدرنسازی در جا: سرور را به یک runtime مدرن ببر (Open Liberty، WildFly، Payara)، namespace را عوض کن و کد را تقریباً دستنخورده نگه دار — کمریسکترین مسیر. مسیر ۲ — انتقال به Quarkus: اگر برنامه عمدتاً CDI + JAX-RS + JPA است، بیشترین کد حفظ میشود. مسیر ۳ — Strangler Fig به سمت Spring Boot: یک gateway جلوی سیستم قدیمی میگذاری و هر بار یک قابلیت را منتقل میکنی (hexagonal-clean).
| Jakarta EE | معادل در Spring Boot |
|---|---|
@Stateless |
@Service + @Transactional |
@Singleton (EJB) · @ApplicationScoped |
@Component (پیشفرض singleton) |
@Inject · @Produces |
تزریق از طریق constructor · @Bean در @Configuration |
@Path + @GET · ExceptionMapper |
@RestController + @GetMapping · @RestControllerAdvice |
@MessageDriven · @Schedule · @RolesAllowed |
@JmsListener · @Scheduled · @PreAuthorize |
@Resource DataSource (JNDI) · persistence.xml |
spring.datasource.* با HikariCP · spring.jpa.* |
| MicroProfile Config / Health / Fault Tolerance | @ConfigurationProperties / Actuator / Resilience4j |
اکثر تیمها روی annotationها تمرکز میکنند و همانجا مشکل را از دست میدهند. در EJB با CMT، هر متد عمومی خودکار تراکنشدار است. وقتی همان کلاس به یک @Service تبدیل میشود و کسی یادش میرود @Transactional بگذارد، کد کامپایل میشود، تستها پاس میشوند، و در production نوشتنها بدون اتمی بودن انجام میشوند.
قبل از مهاجرت مرزهای تراکنش موجود را مستند کن و بعد با یک تست یکپارچگی واقعی (نه mock) بررسی کن که rollback واقعاً اتفاق میافتد. نکتهی دوم: قانون rollback روی checked exception در هر دو دنیا پیشفرضِ «rollback نکن» دارد، ولی نحوهی override فرق میکند (@ApplicationException در برابر rollbackFor).
اول هدف را روشن میکنم: مهاجرت به چه دلیلی؟ پایان پشتیبانی سرور، هزینهی لایسنس، سرعت تحویل، یا استخدام؟ هر دلیل مسیر متفاوتی میطلبد. بعد یک برنامهی مرحلهای: (۱) ارزیابی — فهرست specهای استفادهشده، کتابخانههای بدون نسخهی jakarta و ویژگیهای اختصاصی فروشنده؛ (۲) تور ایمنی — تستهای یکپارچگی با Testcontainers روی سیستم فعلی، که بدون آن هر مهاجرتی قمار است؛ (۳) ارتقای JDK روی همان سرور؛ (۴) ارتقای کتابخانهها تا آخرین نسخهی javax؛ (۵) تعویض namespace با OpenRewrite؛ (۶) تعویض runtime — Open Liberty یا WildFly برای کمترین تغییر، یا Quarkus اگر کد عمدتاً CDI و JAX-RS است؛ (۷) کانتینری کردن و انتقال پیکربندی به Secret؛ (۸) تجزیهی تدریجی با strangler fig، اگر واقعاً میکروسرویس لازم است.
و نکتهی مدیریتی که مصاحبهگر منتظرش است: بازنویسی «big bang» را پیشنهاد نمیدهم مگر دامنهی کسبوکار هم عوض شده باشد؛ اکثر بازنویسیهای یکجا شکست میخورند چون منطق کسبوکار مستند نیست و فقط در همان کد قدیمی زندگی میکند.
در اکثر موارد Spring Boot — اکوسیستم بزرگتر، استخدام راحتتر، و یکپارچگی آماده با تقریباً هر تکنولوژی زیرساختی. ولی سه سناریو هست که Jakarta EE را جدی انتخاب میکنم: (۱) الزام قراردادی به استقلال از فروشنده؛ (۲) تیمی که سالهاست Jakarta EE مینویسد — ریسک بازنویسی معمولاً بیشتر از سود فرضی است؛ (۳) نیاز به کمترین حافظه و سریعترین بالا آمدن، که Quarkus با کامپایل native میدهد.
و نکتهای که اکثر کاندیداها نمیگویند: با Quarkus و Helidon، انتخاب Jakarta EE دیگر به معنای «application server سنگین» نیست. مقایسهی درست امروز «Spring Boot در برابر Quarkus» است، نه «Spring در برابر WebLogic».
۱۷. برگهی تقلب دستورها
| کار | دستور |
|---|---|
| ساخت WAR | mvn clean package (با <packaging>war</packaging>) |
| استقرار / حذف استقرار در WildFly | bin/jboss-cli.sh --connect --command="deploy target/app.war --force" · … --command="undeploy app.war" |
| اجرای WildFly با پروفایل کامل | bin/standalone.sh -c standalone-full.xml |
| Payara/GlassFish · Liberty | asadmin deploy --force=true target/app.war · bin/server run defaultServer |
| Quarkus: ساخت، اجرا، build بومی | quarkus create app com.example:svc --extension='rest-jackson' · quarkus dev · ./mvnw package -Dnative |
| مهاجرت به Jakarta EE 11 | mvn -U org.openrewrite.maven:rewrite-maven-plugin:run -Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JakartaEE11 (بههمراه -Drewrite.recipeArtifactCoordinates=…:rewrite-migrate-java:RELEASE) |
یافتن باقیماندههای javax |
mvn dependency:tree | grep -iE 'javax\.(servlet|persistence|validation|inject)' |
| تشخیص نسل یک WAR | unzip -p app.war WEB-INF/web.xml | head -20 (java.sun.com یعنی Java EE، jakarta.ee یعنی EE 9+) |
| health و OpenAPI در MicroProfile | curl -s localhost:8080/health/ready · curl -s localhost:8080/openapi |
- Jakarta EE یک مجموعه spec است، Spring یک فریمورک. همهی تفاوتهای دیگر از این یکی بیرون میآید: چند فروشنده در برابر یک فروشنده، وابستگی
providedدر برابر fat jar، سرعت کمیته در برابر سرعت محصول. - مسیر نامها: J2EE → Java EE → انتقال به Eclipse در ۲۰۱۷ → Jakarta EE 8 با
javax→ EE 9 باjakarta→ EE 10 با Core Profile → EE 11 (ژوئن ۲۰۲۵). تغییر namespace سازگاری باینری ندارد چون نام بسته جزء هویت کلاس در JVM است؛ ابزارها: OpenRewrite برای سورس، Eclipse Transformer برای بایتکد. - specهایی که واقعاً میبینی: Servlet (یک instance و چند thread)، CDI (تزریق تایپدار، scope صریح، رویداد، interceptor)، EJB (آنچه ماند: CMT، pooling، امنیت اعلانی)، JPA و JTA، JMS، Validation، JAX-RS، JNDI و Jakarta Security. MicroProfile شکاف میکروسرویس را پر میکند و Quarkus و Helidon و Open Liberty آن را مدرن کردهاند؛ مقایسهی منصفانهی امروز «Spring Boot در برابر Quarkus» است.
- در مصاحبه: با تفاوت بنیادی شروع کن، یک نتیجهی عملی بده، نگاشت دوطرفهی annotationها را نشان بده، و با قضاوت مبتنی بر زمینه تمام کن. در مهاجرت: یک متغیر در هر زمان، تور ایمنی تست قبل از هر چیز، و مرزهای تراکنش را جدیتر از annotationها بگیر.
After a few years in Spring Boot, one day you open a project with @Path instead of @RestController, @Stateless instead of @Service, and an .ear "deployed" onto something called WildFly instead of java -jar app.jar. Or an interviewer asks "what's the difference between Jakarta EE and Spring?" — and if you answer "one is old and one is new", they know you see only one layer. This chapter fills that gap: from the meaning of "specification" to deciding how to migrate a legacy system and defending that decision.
- History and naming: J2EE → Java EE → Jakarta EE, and why the name changed.
- The central idea: spec versus framework, and the
javax→jakartaearthquake with the tools that get you across it. - The specs you actually meet: Servlet, CDI, EJB, JPA, JTA, JMS, Validation, JAX-RS, JNDI, Security.
- Packaging and runtime: WAR vs EAR vs fat jar; server vs container; MicroProfile and the new generation (Quarkus, Helidon, Open Liberty).
- The Jakarta EE vs Spring table, how to answer that interview question, and the migration paths.
Other chapters go deep on Spring (spring-core, spring-boot-web, spring-data-tx), jpa-hibernate, microservices-distributed and ms-security; we focus on what is covered nowhere else: the standard itself.
1. Analogy first: the wall socket versus one appliance brand
When you buy a fridge you don't ask "will it work with the socket in my house?" — a standard exists: fixed voltage, frequency and plug shape, written by a committee, not one company. Any manufacturer who honours it works everywhere, and you can swap brands without rewiring.
Now picture a company building a complete smart-home system: sensors, hub, app, all its own. It works beautifully and innovates faster than any committee — but switching brands means replacing everything.
Jakarta EE is the wall socket standard. Spring is the smart-home system.
Almost every other difference falls out of that one: why Jakarta EE changes more slowly, why Spring ships faster, and why banks still run application servers while startups are almost all Spring Boot.
2. History and naming
Vocabulary first. Java SE is the language and base library you get with a JDK. Java EE is a set of additional enterprise APIs (HTTP, transactions, messaging, persistence, security) sitting on top of Java SE. JCP is the formal process in which Java standards are written, and each project's output is a JSR. A spec defines exactly how an API must behave; an RI is a reference implementation proving it is buildable (Jersey for JAX-RS); and a TCK is a huge test suite whose passing earns the right to say "certified" — that last one separates a standard from a claim.
J2EE (1999–2003) was the first generation: Servlet, JSP, EJB 1.x/2.x — a trivial bean needed two interfaces (Home and Remote), an implementation class and pages of XML; Spring was born as a reaction. Java EE 5 (2006) was the turning point: annotations arrived, EJB 3.0 removed nearly all the XML, JPA 1.0 became standard. Java EE 6 brought CDI, JAX-RS and the Web Profile; versions 7 and 8 added WebSocket, JSON-P, Batch, JSON-B and Security 1.0 — but the pace had slowed.
2017: the move to the Eclipse Foundation. Oracle handed over the code but kept the "Java" trademark, so neither the name "Java EE" nor evolution of javax.* was allowed. Jakarta EE 8 (2019) was the same APIs under new governance. Jakarta EE 9 (2020) was the big bang: everything moved to jakarta.* with no new features, and dead specs were pruned (XML RPC, XML Registries, Deployment, Management, EJB 2.x entity beans). Jakarta EE 11 (26 June 2025) delivered Jakarta Data 1.0, virtual-thread support in Concurrency 3.1, Java record support in Persistence 3.2, and removal of all SecurityManager references.
| Version | Date | Namespace | Min Java SE | Key point |
|---|---|---|---|---|
| J2EE 1.2 – 1.4 | 1999–2003 | javax |
1.2–1.4 | Servlet, JSP, EJB 2.x, heavy XML |
| Java EE 5 · 6 | 2006 · 2009 | javax |
5 · 6 | annotations, EJB 3.0, JPA 1.0; then CDI 1.0, JAX-RS 1.1, Web Profile |
| Java EE 7 · 8 | 2013 · 2017 | javax |
7 · 8 | WebSocket, JSON-P, Batch; then JSON-B, JAX-RS 2.1, Security |
| Jakarta EE 8 · 9 · 9.1 | 2019 · 2020 · 2021 | javax → jakarta |
8 · 8 · 11 | governance change; then the big bang rename; then JDK 11 |
| Jakarta EE 10 | Sept 2022 | jakarta |
11 / 17 | CDI 4.0, Core Profile |
| Jakarta EE 11 · 12 | June 2025 · in development | jakarta |
17 (best on 21) · 21 (runtime 25) | Jakarta Data 1.0 and virtual threads; then Jakarta Query |
Every spec has its own version number, unrelated to the platform number. Jakarta EE 11 means: Servlet 6.1, CDI 4.1, Persistence 3.2, RESTful Web Services 4.0, Data 1.0, Faces 4.1, Expression Language 6.0, WebSocket 2.2, Pages 4.0, Security 4.0, Validation 3.1, Concurrency 3.1. When searching documentation, always search the spec version, never the platform version.
3. The central idea: one spec, many implementations
When you write @Autowired in Spring, that annotation has exactly one implementation: Spring itself — there is no spec document, the code is the spec. When you write @Inject in Jakarta EE, a formal document stands behind it, and Weld (WildFly), OpenWebBeans (TomEE) or the Quarkus engine can implement it without your code changing. First consequence: your dependency is the API only.
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-api</artifactId>
<version>11.0.0</version>
<scope>provided</scope>
</dependency>
That provided is the heart of it: "I need this to compile, but keep it out of the artifact — the runtime has it." A production WAR can be a few hundred kilobytes. (For the Web Profile, the same coordinates with jakarta.jakartaee-web-api.) Spring Boot is the mirror image: Tomcat, Jackson and everything else ship inside the fat jar. Second: a product either is "Jakarta EE 11 certified" or it is not, and the TCK decides. Third: competing vendors per API — Hibernate and EclipseLink for JPA; Jersey and RESTEasy for JAX-RS; Weld and OpenWebBeans for CDI; Narayana and Atomikos for JTA.
On paper you can move a WAR from WildFly to WebLogic; in practice you usually can't. Vendor descriptors (jboss-web.xml, weblogic.xml) hold context-root and role mapping, JNDI names are non-standard, classloader policy differs per server, and edge behaviour differs. Portability is a spectrum, not a switch: the spec guarantees the base level, the last 10% is manual.
The real benefit is not "easy vendor swaps"; it is negotiating power and longevity. Code written against a spec outlives any vendor. Organisations with ten-year commitments buy that; a startup with an 18-month horizon does not, and is right not to — an engineering-commercial decision, not taste.
Jakarta EE is a set of specifications under the Eclipse Foundation, implemented by several independent vendors, with compatibility proven by a formal TCK. Spring is a framework: one implementation, one team, no formal spec. Practical consequence: in Jakarta EE you depend on an API and the runtime is replaceable, but innovation moves at committee speed; in Spring you depend on a product but get far more velocity.
And the point you must add: they are not absolute competitors. Spring has consumed Jakarta EE APIs from the start — Servlet, JPA, Validation, JTA, JMS — and Spring Framework 7 (November 2025) sits on the Jakarta EE 11 APIs. The right question is not "which is better?" but "how much of your stack do you hand to a single vendor?"
4. The javax → jakarta earthquake
The final agreement let the Eclipse Foundation keep the existing javax.* packages but not change or add to them — javax.servlet would be frozen at Servlet 4.0 forever, so the only way to keep evolving was to rename the whole namespace.
Why did that break everything? Because in the JVM the package name is part of a class's binary identity. javax.servlet.http.HttpServlet and jakarta.servlet.http.HttpServlet are entirely unrelated classes: no shared interface, no inheritance, no automatic conversion. So every library referencing javax.* had to be recompiled and re-released — Hibernate, Jackson, Jersey, Tomcat, Jetty, build tooling. That took years.
The worst case is both javax.servlet-api and jakarta.servlet-api on one classpath. The application compiles, may even start, then blows up at runtime with NoClassDefFoundError, ClassCastException, or a filter that is never invoked — two copies of the same concept are in memory.
mvn dependency:tree | grep -iE 'javax\.(servlet|persistence|validation|inject|annotation)'
The cure is <exclusions> on the old dependency plus a library upgrade — never keeping both.
If every library already has a jakarta build the path is easy; otherwise you rebuild the library from source or rewrite its bytecode.
Tool 1 — OpenRewrite, when you have the source: one run moves imports, Maven/Gradle dependencies and many removed APIs (use …jakarta.JakartaEE10 for the earlier target):
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JakartaEE11
Tool 2 — Eclipse Transformer, when you don't have a library's source. It works on bytecode: it takes a JAR/WAR/EAR and emits a new one with the names swapped. In Maven you add org.eclipse.transformer:transformer-maven-plugin (version 0.5.0) with <extensions>true</extensions> and the <jakartaDefaults>true</jakartaDefaults> rule; it exposes the jar and transform goals.
The common mistake is changing the JDK, the namespace, the server and the framework at once — then nobody knows which change broke things.
Safe order: (1) raise the JDK and test on the old server; (2) bring libraries to their last javax release; (3) switch the namespace with OpenRewrite and test on a plain servlet container; (4) only then swap the runtime. One commit and one full test run per step. Without a test safety net (testing) this is a gamble.
Because Oracle kept the Java trademark and did not allow javax.* to evolve, only to be maintained unchanged; for the specs to keep growing the namespace had to move. Binary compatibility was impossible because the package name is part of a class's identity in the JVM: you cannot declare that jakarta.servlet.Filter is javax.servlet.Filter. The practical answer is source rewriting with OpenRewrite or bytecode rewriting with Eclipse Transformer.
5. The runtime: server, container, fat jar
People conflate three things. An application server is a full hotel: you bring your suitcase (WAR/EAR) and every service is there. A servlet container is an empty flat: walls, power and water (HTTP and the Servlet lifecycle), but you bring the fridge yourself (JPA, transactions, DI). A fat jar is a caravan: you carry the house with you — no external infrastructure, but you own everything inside it, including security patches.
| Trait | Application Server | Servlet Container | Fat Jar |
|---|---|---|---|
| Examples | WildFly, WebLogic, Liberty, Payara | Tomcat, Jetty, Undertow | Spring Boot, Quarkus, Helidon |
| Package · lifecycle · XA · multi-app | WAR/EAR · server · built in · yes | WAR · server · none · yes | JAR · the app · add-on · no |
| K8s fit · startup | fair · seconds to tens of seconds | good · a few seconds | excellent · sub-second to a few seconds |
| Runtime security patching | independent of the app | independent of the app | requires rebuilding the app |
That last row matters more than it looks: when a CVE lands in Tomcat and you run a hundred Spring Boot microservices, you rebuild and redeploy a hundred applications; in the application-server model ops patches once. The modern answer is build automation and a shared base image (containers-jvm) — bringing both sides of that argument shows you are not repeating slogans.
| Runtime | Status today |
|---|---|
| WildFly | The standard distribution runs Jakarta EE 11 from WildFly 40 (May 2026); before that EE 11 was incubated in WildFly Preview |
| Payara Community · GlassFish | Payara 7 is EE 11 certified (version 6 is EE 10); GlassFish 8 is the compatible/reference implementation for EE 11 |
| Open Liberty · TomEE | Liberty on EE 11 + MicroProfile 7.1 (feature-based, very light); TomEE 10.x on EE 10 + MicroProfile 6.1 |
| Apache Tomcat | 11.0 = Servlet 6.1 · 10.1 = Servlet 6.0 · 10.0 = Servlet 5.0 · 9.x = Servlet 4.0 (javax) |
| Traditional WebLogic / WebSphere | Mostly Java EE 7/8 in existing installations — the enterprise legacy world |
Tomcat 9 and earlier understand only javax.servlet; Tomcat 10 and later only jakarta.servlet. There is no two-way compatibility mode: an old WAR on Tomcat 10+ has its servlets silently ignored and you get 404s with no obvious error in the log. The same applies to Spring: Framework 6+ is jakarta, and Framework 7 sits on Jakarta EE 11 and wants Tomcat 11+ or Jetty 12.1+.
6. Servlet: the foundation everything sits on
A Servlet is a class that takes an HTTP request and produces a response. Everything in Java web development — JSF, JAX-RS, Spring MVC, even Spring Boot by default — rides on Servlet.
A servlet container is an office that opens the door, manages the queue and puts the customer at the counter; your Servlet is only the clerk. The critical point: the office does not hire a clerk per customer — one instance is created and dozens of threads enter concurrently.
Diagram: the Servlet lifecycle as the container sees it
stateDiagram-v2
[*] --> Loaded: class loaded by container
Loaded --> Initialized: init(ServletConfig) once
Initialized --> Servicing: service() per request
Servicing --> Servicing: many threads concurrently
Servicing --> Destroyed: destroy() on shutdown or undeploy
Destroyed --> [*]
A minimal example is a class annotated @WebServlet(urlPatterns = {"/health"}, loadOnStartup = 1) extending HttpServlet, holding an AtomicLong counter (shared state must be thread-safe) and overriding doGet to write JSON to resp.getWriter().
Add an ordinary instance field such as private String currentUser; and mutate it in doGet — a bug that only shows itself under real load: with 50 concurrent users, user A sees user B's data, exactly the defect class security reports call cross-user information disclosure. The rule: Servlets are stateless, and per-request state lives in a local variable or in HttpServletRequest (sync-locks-jmm).
Old hands remember SingleThreadModel as the escape hatch — removed outright in Servlet 6.0, along with HttpSessionContext, HttpUtils, encodeUrl(), encodeRedirectUrl(), isRequestedSessionIdFromUrl() and ServletContext.getRealPath() — these are what break compilation right after a move to Tomcat 10.1.
A Filter is the middleware chain: a class annotated @WebFilter(urlPatterns = "/api/*") implementing Filter, running before and after the Servlet, and it must call chain.doFilter(req, res) or the request dies there — the conceptual equivalent of Spring's security filters and HandlerInterceptor. Its classic use is putting a correlation id into MDC: since Servlet 6.0, getRequestId() gives an id unique for the container's lifetime (alongside getProtocolRequestId() and getServletConnection()) — see logging and observability. A Listener (@WebListener implementing ServletContextListener or HttpSessionListener) hooks lifecycle events: context startup, session creation, attribute changes.
Before Servlet 3.0 everything was declared in WEB-INF/web.xml. Today annotations suffice, but web.xml is still needed for environment settings, overrides, declarative security and filter ordering (which annotations cannot control):
A minimal one sets <session-timeout>30</session-timeout>, marks the session cookie <http-only>true</http-only> and <secure>true</secure>, and pins <tracking-mode>COOKIE</tracking-mode>.
Declarative URL security lives in the same file: a <security-constraint> pairing a <web-resource-collection> (/admin/*) with an <auth-constraint>.
The layout matters: index.html, css/ and js/ sit at the root and are public, while WEB-INF/ holds web.xml, beans.xml, classes/, lib/ and any server-side templates.
1. WAR layout: anything outside WEB-INF is fetchable over HTTP; anything inside it is never served directly.
2. tracking-mode: if you allow URL, the container may put ;jsessionid=... into the URL, and that id leaks into web-server logs, the Referer header and browser history — a classic session-hijacking route. Make COOKIE the only allowed mode, with HttpOnly and Secure.
The container loads the class, creates one instance, calls init(ServletConfig) once, then service() per request (dispatching to doGet/doPost/…), and finally destroy() once.
The detail that shows your level: one instance, many concurrent threads — so every mutable instance field is a concurrency bug. And a positive loadOnStartup creates the instance at startup rather than on the first request, useful for catching configuration errors early.
JSP and JSF belong here too. JSP is HTML with embedded Java that the container translates into a Servlet; JSF is a component-based, stateful server-side UI framework keeping the component tree on the server. They faded because UI moved to React/Angular/Vue and because server-side state needs replication or sticky sessions to scale out. Yet JSF is alive (Faces 4.1 in EE 11) and still productive for admin panels — in an interview don't say "JSF is dead"; say "it was set aside for this class of problem and still makes sense for that one".
7. CDI: the heart of modern Jakarta EE
CDI means Contexts and Dependency Injection: injection plus contexts (the container knows how long each object lives and when to destroy it). If plain DI is a tool shed that hands you whatever you ask for, CDI is a shed with a lending policy: this drill until the shift ends, this ladder until the project ends, one crane forever — and the shed cleans up when it takes things back.
A "bean" is any class the container can instantiate and manage: a no-arg constructor (or one annotated @Inject), inside a bean archive — a JAR or WEB-INF/classes that CDI scans, controlled by beans.xml.
Through CDI 3.0, an empty beans.xml meant "every class in this archive is a bean" (bean-discovery-mode=all). From CDI 4.0 (Jakarta EE 10 onward) it means annotated: only classes carrying a bean-defining annotation (@ApplicationScoped, @RequestScoped, @Dependent, @Stateless, …) are discovered.
Consequence: after an upgrade, injections that worked for years fail with UnsatisfiedResolutionException. The correct fix is explicit scopes; the quick, temporary fix the spec itself calls a transition aid is writing bean-discovery-mode="all" explicitly with the beans_4_0.xsd schema.
| Scope | Lifetime | Rough Spring equivalent |
|---|---|---|
@Dependent (default) · @RequestScoped |
tied to the injecting bean · one HTTP request or message | prototype · request |
@SessionScoped |
one HTTP session (class must be Serializable) |
session |
@ApplicationScoped · @ConversationScoped |
whole application · several requests, started and ended manually | singleton · no direct equivalent |
You have two implementations of one interface; how does the container know which to inject? Spring uses @Qualifier("name") or @Primary; in CDI a qualifier is a typed annotation:
@Qualifier @Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.PARAMETER})
public @interface Gateway {
Provider value();
enum Provider { CARD, WALLET }
}
@ApplicationScoped @Gateway(Gateway.Provider.CARD)
class CardGateway implements PaymentGateway { /* ... */ }
@ApplicationScoped
public class CheckoutService {
private final PaymentGateway gateway;
@Inject CheckoutService(@Gateway(Gateway.Provider.CARD) PaymentGateway gateway) {
this.gateway = gateway; // unambiguous, no magic string
}
}
1. The default scope is a trap. Spring defaults to singleton; CDI to @Dependent — a fresh instance per injection point, tied to its host. Expect a singleton, omit the scope, and you may create thousands of objects or a cache that never works. Rule: always write the scope.
2. Typed qualifiers beat strings. A misspelt @Qualifier("cardGatway") in Spring stays silent until runtime; the CDI equivalent is compiler-checked and refactor-safe.
For objects you don't construct yourself — an HttpClient, say — a producer method is Spring's @Bean equivalent: a method annotated @Produces, plus an optional @Disposes for cleanup. Events give a synchronous in-process bus, the equivalent of ApplicationEventPublisher:
@Inject Event<OrderPlaced> orderPlaced; // publish
orderPlaced.fire(new OrderPlaced(orderId, amountMinor));
void onOrder(@Observes(during = TransactionPhase.AFTER_SUCCESS) OrderPlaced e) { } // observe
That TransactionPhase.AFTER_SUCCESS is a senior tool: "I sent the email but the transaction rolled back" is a very common enterprise logic bug, and this guarantees the external side effect happens only after a successful commit (Spring: @TransactionalEventListener).
Interceptors are the standard declarative form of AOP: instead of pointcut expressions you create an annotation meta-annotated @InterceptorBinding (say @Audited), then a class annotated @Audited @Interceptor @Priority(Interceptor.Priority.APPLICATION) with an @AroundInvoke method taking an InvocationContext and calling ctx.proceed(). Without @Priority (or a beans.xml declaration) it never activates — a very common mistake.
CDI interceptors, EJB transactions and Spring's @Transactional all work through a proxy. If a() calls this.b() inside the same class, the call never crosses the proxy and no interceptor or transaction applies. The bug is silent: the code looks right, unit tests pass, and production has no transaction. Fixes: move the method to another bean, or inject the bean into itself.
Contract: CDI is a spec with several implementations (Weld, OpenWebBeans, Quarkus); Spring DI is a product. Default scope: Spring singleton, CDI @Dependent. Bean selection: qualifier strings and @Primary versus refactor-safe typed annotations. AOP: Spring's pointcut expressions are more powerful; CDI's interceptor bindings are simpler.
The detail that shows your level: classic CDI validates every injection point at deploy time — an ambiguous or missing dependency means the application does not start. In exchange, the ecosystem is a decisive win for Spring.
8. EJB: why it got a bad name and what survived
EJB (today Jakarta Enterprise Beans) had a simple idea: you write business logic, the container handles transactions, security, pooling and concurrency. The bad name comes from EJB 2.x: a trivial bean needed a Home interface, a Remote interface, a bean class and a long ejb-jar.xml; entity beans were slow and were replaced by JPA; testing was impossible. That climate created Spring. EJB 3.0 removed the ceremony, but the reputation stuck; in Jakarta EE 9 the EJB 2.x APIs became optional.
@Stateless
public class InvoiceService {
@PersistenceContext(unitName = "billingPU")
private EntityManager em;
// default is REQUIRED: join the caller's transaction, otherwise start one
@RolesAllowed("BILLING_CLERK")
public void issue(Invoice invoice) {
em.persist(invoice);
}
// always a new transaction — for an audit record that must survive the main failure
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void auditTrail(String message) {
em.persist(new AuditEntry(message));
}
}
The big point: there is no @Transactional anywhere, yet every public method is transactional. That is CMT, container-managed transactions.
Four bean kinds: @Stateless (no state between calls, pooled — business services, 90% of cases), @Stateful (per-client state — wizards, carts), @Singleton (one instance per application — caches, schedulers) and @MessageDriven (a message consumer with no direct client).
| Transaction attribute | Transaction exists | No transaction |
|---|---|---|
REQUIRED (default) |
joins it | starts a new one |
REQUIRES_NEW |
suspends it, starts a new one | starts a new one |
MANDATORY |
joins it | EJBTransactionRequired error |
SUPPORTS · NOT_SUPPORTED |
joins it · suspends it, runs untransacted | untransacted · untransacted |
NEVER |
EJBException |
runs untransacted |
Under CMT, only unchecked exceptions roll the transaction back automatically; a checked exception does not roll back unless its class is annotated @ApplicationException(rollback = true). The classic disaster: a checked exception is thrown, the team assumes a rollback happened, and half-written data is committed. Spring's @Transactional defaults the same way and is tuned with rollbackFor (tx-acid).
Second point: @Stateful keeps an object in one node's memory and needs replication (expensive) or sticky sessions (fragile) — in cloud-native architecture almost always the wrong choice, so put state in Redis or the database (caching).
The bad name comes from EJB 2.x: mandatory home/remote interfaces, XML everywhere, slow entity beans and untestability. From EJB 3.0 the model became a plain POJO with @Stateless. What survived and is genuinely valuable: container-managed transactions, automatic pooling, method-level thread safety, declarative security with @RolesAllowed, timers with @Schedule, consumers with @MessageDriven.
The balanced answer: for a new project I would not pick EJB — CDI plus @Transactional is cleaner and suits Quarkus better. But rewriting working EJBs purely because they are "old" is a risk with no payoff.
9. JPA, JTA and distributed transactions
JPA depth lives in jpa-hibernate; here only the Jakarta EE-specific part: the container-managed persistence unit. In META-INF/persistence.xml you declare <persistence-unit name="billingPU" transaction-type="JTA"> pointing at a <jta-data-source>.
With JTA the container owns the transaction; you obtain the EntityManager with @PersistenceContext and never call em.getTransaction() (which throws IllegalStateException). With RESOURCE_LOCAL you own it and write begin()/commit() explicitly — the Java SE mode. Mixing the two is a very common runtime error when testing outside a server.
JTA (Jakarta Transactions) is the standard for transactions spanning multiple resources: two databases, or a database and a queue. The mechanism is two-phase commit, needing XA drivers — like a notary: everyone is asked "are you ready?" (prepare), and only if all say yes does the notary order signatures (commit).
Diagram: the two-phase commit protocol in JTA
sequenceDiagram
participant App as Application
participant TM as Transaction Manager
participant DB as XA Database
participant MQ as XA Message Broker
App->>TM: begin()
App->>DB: insert row
App->>MQ: send message
App->>TM: commit()
TM->>DB: prepare
TM->>MQ: prepare
DB-->>TM: vote yes
MQ-->>TM: vote yes
TM->>DB: commit
TM->>MQ: commit
TM-->>App: committed
Modern Jakarta EE gives declarative transactions without EJB: put @Transactional(Transactional.TxType.REQUIRED) on a CDI bean method. Note this is jakarta.transaction.Transactional, not Spring's; behaviour is similar but tuned with rollbackOn and dontRollbackOn. For manual control, inject @Resource UserTransaction utx;.
Between prepare and commit the resources stay locked; if the transaction manager dies then, the transaction goes "in-doubt" and rows stay locked until recovery. Throughput drops sharply, and in containers keeping the recovery log durable is a genuine headache.
The modern alternative is the outbox pattern and sagas: instead of one atomic transaction across two systems, write the message into an outbox table inside the same database transaction and let a separate process publish it (ms-data, messaging).
Reading unpublished outbox events from several workers at once:
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
FETCH FIRST 100 ROWS ONLY
FOR UPDATE SKIP LOCKED;1. PostgreSQL has LIMIT; Oracle uses FETCH FIRST … ROWS ONLY (12c+) — LIMIT on Oracle is a syntax error. 2. In PostgreSQL BEGIN starts a transaction; on Oracle every session is implicitly in one and BEGIN opens a PL/SQL block. 3. FOR UPDATE SKIP LOCKED is identical on both and is the unsung hero of database-backed queues: without it, concurrent workers block behind each other and throughput collapses to one worker (rdbms-tuning).
CMT means the container sets boundaries via @TransactionAttribute; BMT means you write them with UserTransaction. CMT is almost always right: less code, fewer mistakes, reviewable declarative boundaries. BMT is only needed when the boundary is not the method boundary — a batch loop committing every 1000 records so the undo log doesn't explode.
The point that makes your answer stand out: the boundary must be short, and there must never be an external network call inside a transaction — one slow HTTP call means locked rows.
10. JMS and Bean Validation
JMS (today Jakarta Messaging) is a standard API for queues and topics — not a wire protocol like AMQP, so a JMS client cannot connect to an arbitrary broker. Two models: Queue (each message to exactly one consumer) and Topic (each message to every subscriber). The classic Jakarta EE consumer is an MDB:
@MessageDriven(activationConfig = {
@ActivationConfigProperty(propertyName = "destinationType",
propertyValue = "jakarta.jms.Queue"),
@ActivationConfigProperty(propertyName = "destinationLookup",
propertyValue = "java:/jms/queue/Orders")
})
public class OrderConsumer implements MessageListener {
@Override
public void onMessage(Message m) {
try { if (m instanceof TextMessage t) { process(t.getText()); } }
catch (JMSException e) { throw new IllegalStateException("bad message", e); }
}
}
On the producing side you @Inject a JMSContext, look up the destination with @Resource(lookup = "…") and call context.createProducer().send(queue, payload) inside a @Transactional method.
MDB messages are consumed inside the container transaction by default. If onMessage throws a RuntimeException the transaction rolls back and the message is redelivered — forever, if the bug is deterministic, and the queue stops.
The fix: configure a redelivery limit and a dead-letter queue at the broker, and catch unrecoverable errors inside the method to route them to the DLQ instead of letting the transaction roll back (messaging, resilience).
Jakarta Validation (everyone still calls it Bean Validation) is the annotation-driven validation standard — and Spring uses exactly the same spec (usually with Hibernate Validator), so this knowledge transfers 100% between the two worlds.
public record CreateOrderRequest(@NotBlank @Size(max = 64) String accountId,
@Positive long amountMinor, @Email String notifyEmail) {}
The Jakarta EE-specific part: validation applies automatically — EJB and CDI method parameters via @Valid, the JAX-RS request body, and JPA entities before persist/update. That last one surprises people: the error is thrown at flush time, far from where you expect; jakarta.persistence.validation.mode can set it to NONE or CALLBACK.
11. JAX-RS versus Spring MVC
JAX-RS (today Jakarta RESTful Web Services, 4.0 in EE 11) is the standard for building REST APIs.
@Path("/orders")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject OrderService service;
@Context UriInfo uriInfo;
@GET @Path("/{id}")
public Response findById(@PathParam("id") String id) {
return service.find(id).map(Response::ok)
.orElseGet(() -> Response.status(Response.Status.NOT_FOUND)).build();
}
@POST
public Response create(@Valid CreateOrderRequest request) {
String id = service.create(request);
return Response.created(uriInfo.getAbsolutePathBuilder().path(id).build()).build();
}
}
@ApplicationPath("/api")
public class RestActivator extends Application {}
Error handling uses a @Provider class implementing ExceptionMapper<MyException> — the equivalent of @ControllerAdvice plus @ExceptionHandler.
| Topic | JAX-RS | Spring MVC |
|---|---|---|
| Path mapping · HTTP method · path/query params | @Path · @GET/@POST/… · @PathParam/@QueryParam |
@RequestMapping · inside it · @PathVariable/@RequestParam |
| Body · header | unannotated parameter · @HeaderParam |
@RequestBody · @RequestHeader |
| Content negotiation · filters | @Produces/@Consumes · ContainerRequestFilter |
produces/consumes · HandlerInterceptor |
| Error handling | ExceptionMapper |
@ControllerAdvice + @ExceptionHandler |
| Client · activation | jakarta.ws.rs.client.Client · an Application class |
RestClient/WebClient · automatic in Boot |
By design JAX-RS is cleaner — the HTTP method is its own annotation and a standard client lives in the same spec — while Spring MVC wins on productivity: no activation class, and validation, security and documentation pre-wired. The senior note: both sit on Servlet and neither designs a good API for you. API quality comes from resource naming, status codes, pagination, versioning and idempotency (api-design).
Conceptually the same job, and the annotations map almost one-to-one. Three real differences: (1) in JAX-RS a parameter with no annotation is the body, whereas Spring requires @RequestBody, so a forgotten annotation can surprise you; (2) the response model: Response with a builder versus ResponseEntity; (3) JAX-RS ships a Client API in the same spec, and MicroProfile Rest Client adds a type-safe declarative layer.
One more point: in Jakarta EE a JAX-RS resource is usually itself a CDI bean, so @Inject works directly — the two worlds are integrated.
12. JAX-WS, JNDI and Jakarta Security
SOAP is an XML-based message protocol and WSDL a machine-readable contract describing a service's operations. JAX-WS is the standard Java API: a class annotated @WebService with @WebMethod methods is enough for the container to generate the WSDL, and wsimport generates client classes from one (removed from modern JDKs; it ships in the standalone Jakarta XML Web Services Tools distribution).
In Jakarta EE 9 the XML web-services family left the platform core and became optional — yet it is still common in banking, insurance and inter-organisation exchange, because WSDL is a formal, code-generatable contract and WS-Security signs and encrypts at the message level, not just the transport level. The modern equivalents are OpenAPI and gRPC (api-design).
JNDI is the Java Naming and Directory Interface: a phone book inside the server. The application asks for @Resource(lookup = "java:app/jdbc/BillingDS") and gets a DataSource without knowing what is behind the name — like a switchboard operator: the logical name stays fixed while the resource changes. The datasource is defined in the server (WildFly's jboss-cli.sh, Payara's asadmin create-jdbc-connection-pool) or with @DataSourceDefinition. Namespaces by visibility: java:comp/env/…, java:module/…, java:app/… (most portable) and java:global/…; java:jboss/… is vendor-specific.
Why not keep the pool inside the application? When 40 Spring Boot services each hold a 20-connection HikariCP pool, the database faces 800 connections while max_connections is far lower — a site-wide outage at peak. Container-managed pools make that budget visible in one place; with Spring Boot, manage it at platform level.
Jakarta Security (4.0 in EE 11) standardised security, which every server used to define its own way. Three concepts: an Authentication Mechanism (@BasicAuthenticationMechanismDefinition, @FormAuthenticationMechanismDefinition, or @OpenIdAuthenticationMechanismDefinition for OIDC), an Identity Store (@DatabaseIdentityStoreDefinition with callerQuery, groupsQuery, hashAlgorithm), and SecurityContext, injected to call getCallerPrincipal(). For authorisation @RolesAllowed works on EJB and CDI beans and <security-constraint> covers URL level; microservices usually use MicroProfile JWT (ms-security).
13. Packaging: WAR, EAR and fat jar
A WAR is one web module. An EAR packages several modules — multiple WARs, EJB-JARs and shared libraries in lib/ — with a META-INF/application.xml listing modules and context roots.
The rule: each WAR sees EAR/lib, but the EAR does not see classes inside a WAR, and two WARs cannot see each other. Most servers also have a module layer that may load a popular library ahead of yours. The classic result is a LinkageError or NoSuchMethodError that occurs only on the server. Controls: WEB-INF/jboss-deployment-structure.xml in WildFly, and a "parent-last" setting on many servers.
A WAR is a web module deployed onto a servlet container or application server; an EAR is a multi-module package from the era when one expensive server hosted many applications; a fat jar carries everything including the server.
For a new system the fat jar is the default: one artifact, one process, one unit of scaling, aligned with containers and Kubernetes. But state the trade-off: patching the runtime means rebuilding every service, and total memory grows because each process carries a JVM and a web server. With five small internal applications maintained by one team, a shared application server can be cheaper.
14. MicroProfile and the new generation of runtimes
Jakarta EE was designed for large monoliths. The microservice wave demanded things the spec lacked: externalised configuration, health checks, metrics, API documentation, circuit breakers and JWT. MicroProfile filled that gap: light specs sitting on the Jakarta EE Core Profile.
| Spec in MicroProfile 7.1 | Version | What it does |
|---|---|---|
| Config | 3.1 | configuration from env vars, files and ConfigMaps with a defined precedence |
| Fault Tolerance | 4.1 | @Retry, @Timeout, @CircuitBreaker, @Bulkhead, @Fallback |
| Health · Telemetry | 4.0 · 2.1 | /health/live and /health/ready · traces, metrics and logs on OpenTelemetry |
| OpenAPI · JWT Auth · Rest Client | 4.1 · 2.1 · 4.0 | generated OpenAPI document · standard JWT authentication · declarative type-safe HTTP client |
One change worth knowing: through MicroProfile 6.1 MicroProfile Metrics was part of the umbrella; from MicroProfile 7.0 it was replaced by MicroProfile Telemetry, covering all three observability pillars on OpenTelemetry, and Metrics became an independent spec — the road the whole industry took (observability).
@ApplicationScoped
public class RateClient {
@Inject @ConfigProperty(name = "pricing.currency", defaultValue = "EUR") String currency;
@Retry(maxRetries = 3, delay = 200, jitter = 100)
@Timeout(value = 2, unit = ChronoUnit.SECONDS)
@CircuitBreaker(requestVolumeThreshold = 20, failureRatio = 0.5, delay = 10000)
@Fallback(fallbackMethod = "cachedRate")
public double currentRate(String pair) { /* external call */ }
double cachedRate(String pair) { return 1.0; } // same signature
}
Config precedence, highest first: environment variables, JVM system properties, then META-INF/microprofile-config.properties — exactly what you want with Kubernetes ConfigMaps and Secrets. For health, a @Readiness @ApplicationScoped class implementing HealthCheck suffices. To call another service, annotate an interface @RegisterRestClient(configKey = "catalog-api") and set URL and timeouts in catalog-api/mp-rest/url and …/connectTimeout — the FeignClient equivalent.
The classic application server was too heavy for containers: high memory, slow startup, heavy runtime reflection. The new generation keeps the same APIs but changes the execution architecture: Quarkus moves the heavy work (CDI scanning, JPA metadata, route tables) to build time and enables GraalVM native compilation; Helidon 4 was rewritten on Java 21 virtual threads (virtual-threads); Open Liberty is feature-based, loading only what server.xml declares (restfulWS-4.0, cdi-4.1, mpConfig-3.1).
quarkus create app com.example:billing --extension='rest-jackson,hibernate-orm-panache'
quarkus dev # live-reload dev mode
./mvnw package -Dnative -Dquarkus.native.container-build=true # native binary
If you have a legacy Jakarta EE system, the Quarkus path is usually less painful than the Spring Boot path: @Inject, @ApplicationScoped, @Path, @Transactional and your JPA entities all stay as they are, and only the infrastructure layer changes. Going to Spring Boot means translating every annotation, with a far higher risk of human error. Bring this to the architecture decision meeting — usually nobody has considered it.
MicroProfile is a set of light specs under the Eclipse Foundation doing for microservices what Spring Boot and Spring Cloud do in the Spring world. It sits on the Jakarta EE Core Profile, taking CDI, JAX-RS and JSON-B from there and adding the rest.
Mapping to Spring: @ConfigProperty ↔ @Value; HealthCheck ↔ Actuator; @Retry/@CircuitBreaker ↔ Resilience4j; @RegisterRestClient ↔ FeignClient; Telemetry ↔ Micrometer with OpenTelemetry. The core difference: MicroProfile is a spec implemented by Quarkus, Helidon, Open Liberty and TomEE; Spring Boot is a product.
Full Platform has every spec, including full EJB, JMS, Connectors and Batch. Web Profile exists since Java EE 6 and covers what an ordinary web app needs: Servlet, Faces, CDI, JPA, JTA, Validation, REST and EJB Lite — without JMS or Connectors; most real applications need no more. Core Profile arrived in Jakarta EE 10 for microservices and ahead-of-time compilation: CDI Lite, RESTful Web Services, JSON-P, JSON-B, Annotations, Interceptors and Dependency Injection — the base MicroProfile and Quarkus sit on. CDI Lite is the subset implementable without runtime reflection, offering only @Dependent, @RequestScoped and @ApplicationScoped — what makes native compilation possible. A lighter profile means a smaller attack surface, faster startup and a smaller image.
15. The big table: Jakarta EE versus Spring
| Axis | Jakarta EE | Spring / Spring Boot |
|---|---|---|
| Nature · governance | spec set with several implementations and a TCK; Eclipse Foundation | framework with one implementation; one sponsoring company |
| DI · AOP · transactions · web · data · messaging · observability | CDI typed qualifiers, @Dependent default, deploy-time validation, interceptor bindings; @Transactional/CMT with XA built in; JAX-RS and Faces; JPA and Jakarta Data; JMS and MDBs; MicroProfile Telemetry |
Spring DI with string qualifiers and singleton default, pointcut AOP; Spring @Transactional, XA add-on; MVC/WebFlux; Spring Data; Spring JMS/Kafka; Micrometer + Actuator |
| Configuration · deployment · ecosystem · learning curve | web.xml/persistence.xml/MicroProfile Config; WAR/EAR or fat jar with Quarkus; core-focused; steep at first; Arquillian or Testcontainers |
application.yml and @ConfigurationProperties; fat jar; very wide (Security, Batch, Cloud); easy start, much magic; @SpringBootTest |
| Where chosen · main risk | banking, insurance, government, telecom needing vendor independence; smaller ecosystem, harder hiring | startups, SaaS, most web teams; single-vendor dependence, periodic large upgrades |
1. The fundamental difference in one sentence: "Jakarta EE is a spec with several implementations; Spring is a framework with one." 2. One practical consequence: "That is why Jakarta EE dependencies are provided and the runtime is replaceable, while Spring Boot puts everything in the fat jar." 3. Show you know both: @Inject ↔ @Autowired, @Path ↔ @RequestMapping, @Stateless ↔ @Service with @Transactional, ExceptionMapper ↔ @ControllerAdvice, MicroProfile ↔ Actuator and Spring Cloud.
4. Finish with judgement: "For a new product with a small team I would choose Spring Boot; for an organisation with a ten-year commitment and a vendor-independence requirement, Jakarta EE with Quarkus or Open Liberty is perfectly reasonable." Saying "Spring is better" or "Jakarta EE is old" loses points — the interviewer wants judgement, not fandom.
Both — and that is the correct answer. Spring competes with the platform as a whole: it replaces the EJB model, the application-server deployment model and JSF. But Spring consumes many of its specs: Spring MVC on Jakarta Servlet, Spring Data JPA on Jakarta Persistence, validation via Jakarta Validation with Hibernate Validator, plus @Inject/@Resource/@PostConstruct. The decisive evidence: Spring Framework 6 migrated to jakarta.* for exactly this reason, and Framework 7 (November 2025) sits on the Jakarta EE 11 APIs and requires Tomcat 11+ or Jetty 12.1+.
16. Migration paths
Diagram: decision tree for migrating a legacy Java EE system
flowchart TD
A[Legacy Java EE app] --> B{Is the business logic<br/>worth preserving as-is?}
B -->|No, rewrite planned| C[Strangler Fig to<br/>Spring Boot or Quarkus]
B -->|Yes| D{Heavy use of EJB,<br/>JMS, JTA, JSF?}
D -->|Yes| E[Modernize in place:<br/>Jakarta EE 11 runtime]
D -->|No, mostly CDI + JAX-RS| F[Lift to Quarkus:<br/>same annotations]
E --> G[Then containerize]
F --> G
C --> G
Path 1 — modernise in place: move to a modern runtime (Open Liberty, WildFly, Payara), switch the namespace, leave the code almost untouched — lowest risk. Path 2 — lift to Quarkus: if the app is mostly CDI + JAX-RS + JPA, most code survives. Path 3 — strangler fig toward Spring Boot: a gateway in front of the legacy system, one capability at a time (hexagonal-clean).
| Jakarta EE | Spring Boot equivalent |
|---|---|
@Stateless · @Singleton · @ApplicationScoped · @Inject · @Produces |
@Service + @Transactional · @Component · constructor injection · @Bean in @Configuration |
@Path + @GET · ExceptionMapper |
@RestController + @GetMapping · @RestControllerAdvice |
@MessageDriven · @Schedule · @RolesAllowed · JNDI DataSource · persistence.xml |
@JmsListener · @Scheduled · @PreAuthorize · spring.datasource.* · spring.jpa.* |
| MicroProfile Config / Health / Fault Tolerance | @ConfigurationProperties / Actuator / Resilience4j |
Most teams focus on annotations and miss the problem right there. In EJB with CMT, every public method is transactional automatically. When that class becomes a @Service and someone forgets @Transactional, the code compiles, tests pass, and in production writes happen without atomicity.
Document the existing boundaries before migrating, then verify with a real integration test (not a mock) that rollback happens. Equally important: the checked-exception rollback rule defaults to "do not roll back" in both worlds, but the override differs (@ApplicationException versus rollbackFor).
First I clarify the goal: migrating for what reason? End of server support, licence cost, delivery speed, hiring? Each implies a different path. Then a staged plan: (1) assessment — specs in use, libraries with no jakarta release, vendor-specific features; (2) a safety net — Testcontainers integration tests against the current system, without which any migration is a gamble; (3) raise the JDK on the same server; (4) raise libraries to their last javax release; (5) switch the namespace with OpenRewrite; (6) swap the runtime — Open Liberty or WildFly for minimal change, Quarkus if the code is mostly CDI and JAX-RS; (7) containerise and move config into env vars and Secrets; (8) decompose gradually with strangler fig, if microservices are genuinely needed.
And the management point the interviewer waits for: no big-bang rewrite unless the business domain has genuinely changed; most all-at-once rewrites fail because the business logic is undocumented and lives only in that old code.
In most cases Spring Boot — larger ecosystem, easier hiring, ready-made integration with almost every infrastructure technology. But three scenarios make me seriously choose Jakarta EE: (1) a contractual vendor-independence requirement; (2) a team that has written Jakarta EE for years, where rewrite risk exceeds the gain; (3) the smallest memory footprint and fastest startup, which Quarkus delivers with native compilation.
And the point most candidates miss: with Quarkus and Helidon, choosing Jakarta EE no longer means "a heavy application server" — the fair comparison today is "Spring Boot versus Quarkus", not "Spring versus WebLogic".
17. Command cheat sheet
| Task | Command |
|---|---|
| Build a WAR | mvn clean package (<packaging>war</packaging>) |
| WildFly: deploy · undeploy · full profile | bin/jboss-cli.sh --connect --command="deploy target/app.war --force" · … "undeploy app.war" · bin/standalone.sh -c standalone-full.xml |
| Payara · Liberty · Quarkus | asadmin deploy --force=true target/app.war · bin/server run defaultServer · quarkus dev · ./mvnw package -Dnative |
| Migrate to Jakarta EE 11 | mvn -U org.openrewrite.maven:rewrite-maven-plugin:run -Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JakartaEE11 (with -Drewrite.recipeArtifactCoordinates=…:rewrite-migrate-java:RELEASE) |
Find leftover javax · identify a WAR's generation · MicroProfile endpoints |
mvn dependency:tree | grep -iE 'javax\.(servlet|persistence)' · unzip -p app.war WEB-INF/web.xml | head -20 (java.sun.com = Java EE, jakarta.ee = EE 9+) · curl -s localhost:8080/health/ready and /openapi |
- Jakarta EE is a set of specifications; Spring is a framework. Every other difference follows: many vendors versus one,
provideddependencies versus a fat jar, committee pace versus product pace. - The naming path: J2EE → Java EE → the 2017 move to Eclipse → Jakarta EE 8 on
javax→ EE 9 onjakarta→ EE 10 with the Core Profile → EE 11 (June 2025). The namespace change is not binary compatible because the package name is part of a class's identity in the JVM; use OpenRewrite for source and Eclipse Transformer for bytecode. - The specs you meet: Servlet (one instance, many threads), CDI (typed injection, explicit scopes, events, interceptors), EJB (CMT, pooling, declarative security), JPA and JTA, JMS, Validation, JAX-RS, JNDI and Jakarta Security. MicroProfile fills the microservice gap, and Quarkus, Helidon and Open Liberty modernised it.
- In an interview: start with the fundamental difference, give one practical consequence, show the two-way annotation mapping, and finish with context-based judgement. In a migration: one variable at a time, a test safety net first, and take transaction boundaries more seriously than annotations.