Libraries & Ecosystem · کتابخانهها و اکوسیستم پایهBeginner ~41 دقیقه مطالعه~35 min read
ابزار Build: Maven در برابر GradleBuild Tools: Maven vs Gradle
یاد میگیری چرا به ابزار Build نیاز داری و دو غول این حوزه — Maven با فلسفهی «قرارداد بر پیکربندی» و Gradle با فلسفهی «Build برنامهپذیر» — را از ریشه میفهمی: چرخهی حیات و POM و scopeها و حل تعارض وابستگی در Maven، و taskها و فازهای configuration/execution و انواع cache در Gradle، تا بدانی کِی کدام را انتخاب کنی.You'll learn why build tools exist and understand the two giants from the ground up — Maven with its "convention over configuration" philosophy and Gradle with its "programmable build" philosophy: Maven's lifecycle, POM, scopes and dependency conflict resolution, and Gradle's tasks, configuration/execution phases and its family of caches — so you know exactly when to reach for which.
سلام. بگذار همین اول یک حقیقت را صاف کنیم: یک پروژهی جاوای واقعی فقط چند فایل .java نیست. تو دهها کتابخانهی بیرونی داری، باید کد را کامپایل کنی، تستها را اجرا کنی، منابع را بستهبندی کنی، یک فایل نهایی (JAR یا WAR) بسازی و شاید آن را جایی منتشر کنی. اگر بخواهی اینها را دستی با javac و java انجام دهی، در یک پروژهی کوچک هم دیوانه میشوی. ابزار Build همان دستیاری است که تمام این کارهای تکراری و پیچیده را برایت خودکار میکند. در این فصل قرار نیست فقط دستورها را حفظ کنی؛ قرار است بفهمی هر کدام از این دو ابزار — Maven و Gradle — چه فلسفهای دارند، چه دردی را درمان میکنند و در مصاحبهی سنیور چطور باید بینشان مقایسه کنی.
مسیری که با هم میرویم:
- ابزار Build چیست و چرا وجود دارد — قبل از هر ابزاری، خودِ مسئله را حس کنیم.
- Maven — فلسفهی «قرارداد بر پیکربندی»، فایل POM، چرخهی حیات و فازها و goalها.
- مدیریت وابستگی — scopeها، وابستگیهای گذرا (transitive)، حل تعارض نسخه، و BOM.
- پروژهی چندماژولی (multi-module) و مفهوم reactor.
- Gradle — فلسفهی «Build برنامهپذیر»، DSL (Groovy/Kotlin)، taskها و گراف آنها.
- سرعت در Gradle — فاز configuration در برابر execution، ساخت افزایشی، build cache، daemon و configuration cache.
- کِی کدام را انتخاب کنیم، Buildهای بازتولیدپذیر، دامها و ۱۲ پرسش مصاحبه با پاسخ کامل.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از اینکه وارد جزئیات شویم، چند اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا در ذهنت جا بیندازمشان.
- Artifact (آرتیفکت): خروجیای که Build میسازد یا مصرف میکند — معمولاً یک فایل JAR (کتابخانهی جاوا)، WAR (اپلیکیشن وب) یا POM. هر آرتیفکت با سهگانهی
groupId:artifactId:versionشناخته میشود؛ درست مثل نام و نامخانوادگی و کد ملیِ یک بسته. - Dependency (وابستگی): کتابخانهی بیرونیای که پروژهات به آن نیاز دارد، مثل Jackson برای کار با JSON یا JUnit برای تست.
- Repository (مخزن): انباری که آرتیفکتها در آن نگهداری میشوند. مخزن مرکزی جاوا Maven Central است؛ هر دو ابزار Maven و Gradle از همینجا وابستگیها را دانلود میکنند.
- Declarative در برابر Imperative: «اعلانی» یعنی چه چیزی میخواهی را بیان میکنی و ابزار تصمیم میگیرد چطور انجامش دهد (مثل سفارش غذا از منو). «دستوری» یعنی خودت چگونگی را قدمبهقدم میگویی (مثل نوشتن دستور پخت). این تمایز، قلب تفاوت Maven و Gradle است.
ابزار Build چیست و چرا اصلاً وجود دارد؟
تصور کن میخواهی برای صد مهمان غذا بپزی. میتوانی خودت تکتک سیبزمینیها را پوست بکنی، هر بار دنبال ادویه بگردی و همهچیز را از صفر انجام دهی — این همان کامپایل دستی با javac است؛ در آشپزی خانگی جواب میدهد اما در مقیاس رستوران فاجعه است. ابزار Build مثل آشپزخانهی حرفهای یک رستوران است: مواد اولیه (وابستگیها) خودکار از انبار میآیند، مراحل پخت (کامپایل، تست، بستهبندی) یک ترتیب مشخص و استاندارد دارند و تو فقط میگویی «غذای امشب را آماده کن». ابزار بقیهی کار را میداند.
بیایید مسئله را دقیق ببینیم. چرخهی عمر یک قطعه کد جاوا تا رسیدن به محصول این کارها را لازم دارد:
- حل و دانلود وابستگیها: فهمیدن اینکه پروژه به چه کتابخانههایی و چه نسخههایی نیاز دارد و آوردنشان از مخزن.
- کامپایل: تبدیل
.javaبه.class. - اجرای تستها: unit test و integration test.
- بستهبندی (package): ساختن JAR/WAR از کلاسها و منابع.
- انتشار (publish/deploy): فرستادن آرتیفکت به یک مخزن تا دیگران استفاده کنند.
ابزار Build همهی اینها را با یک فایل پیکربندی و چند دستور کوتاه انجام میدهد. مهمتر از راحتی، ابزار Build بازتولیدپذیری (reproducibility) میآورد: هر توسعهدهنده و هر سرور CI با همان پیکربندی، همان خروجی را میسازد. جملهی معروف «روی کامپیوترِ من که کار میکرد» دقیقاً همان دردی است که ابزار Build درمانش میکند.
در دنیای جاوا دو ابزار غالباند: Maven (از ۲۰۰۴) با فلسفهی «قرارداد بر پیکربندی» و ساختار اعلانیِ سفتوسخت، و Gradle (از ۲۰۰۷–۲۰۱۲) با فلسفهی «Build برنامهپذیر و انعطافپذیر». (ابزار قدیمیتری بهنام Ant هم بود که کاملاً دستی و بدون مدیریت وابستگی کار میکرد؛ امروز عملاً تاریخی است و ما رویش تمرکز نمیکنیم.) کل این فصل در واقع داستان کشمکش «سادگی و استانداردِ Maven» در برابر «قدرت و سرعتِ Gradle» است.
Maven: فلسفهی «قرارداد بر پیکربندی»
Maven مثل یک فرم اداری استاندارد است. تو داخل خانههای از پیش تعریفشده مقدارها را پر میکنی و همهی فرمها در همهی ادارات شبیه هماند. مزیتش این است که هر کسی هر فرمی را ببیند فوراً میفهمد کجا چه چیزی است — نظم و یکنواختی. عیبش این است که اگر بخواهی کاری خارج از خانههای فرم بکنی، باید با انعطافِ کمِ فرم بجنگی. این دقیقاً تجربهی Maven است: نظم فوقالعاده، انعطاف محدود.
اصل بنیادین Maven «قرارداد بر پیکربندی» (Convention over Configuration) است. یعنی Maven پیشفرضهای عاقلانهای دارد و اگر تو از آنها پیروی کنی، تقریباً چیزی برای پیکربندی نمیماند. مثلاً ساختار پوشهها قراردادی است:
my-app/
├── pom.xml
└── src/
├── main/
│ ├── java/ ← کد اصلی اینجاست
│ └── resources/ ← فایلهای منبع (config, properties)
└── test/
├── java/ ← کد تست اینجاست
└── resources/
تو لازم نیست به Maven بگویی «کد در کجاست»؛ چون قرارداد این است که در src/main/java باشد. همین یک تصمیم، هزاران خط پیکربندی را حذف میکند.
فایل POM: شناسنامهی پروژه
فایل pom.xml (مخفف Project Object Model، یعنی «مدل شیءگرای پروژه») همزمان شناسنامهی پروژه است (این پروژه کیست؟ چه نسخهای؟) و هم لیست خرید آن (به چه کتابخانههایی نیاز دارد؟). یک برگه که همهچیز پروژه در آن اعلان شده است.
سادهترین POM ممکن اینطور است:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- سهگانهی هویت این پروژه (coordinates) -->
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
سه چیز در این فایل هست که باید حسشان کنی: coordinates (همان groupId:artifactId:version که هویت پروژه است)، properties (متغیرهای قابل استفادهی مجدد، مثل نسخهی جاوا)، و dependencies (لیست خرید کتابخانهها). اینکه POM با XML نوشته میشود، هم نقطهی قوت است (استاندارد، قابل اعتبارسنجی با schema، قابل خواندن توسط ابزارها) و هم نقطهی ضعف مشهور Maven (پرحرف و طولانی).
چرخهی حیات، فاز و goal — سه کلمهای که همه با هم قاطی میکنند
اینجا مهمترین مفهوم Maven است و اکثر افراد آن را نیمهفهمیده رها میکنند. سه اصطلاح داریم که باید دقیق از هم تفکیک کنی:
یک چرخهی حیات (lifecycle) مثل کل خط تولید یک کارخانه است. این خط از چند فاز (phase) پشتسرهم تشکیل شده: ایستگاه ۱، ایستگاه ۲، ایستگاه ۳... و قانون طلایی این است که برای رسیدن به ایستگاه ۵، محصول باید از ایستگاههای ۱ تا ۴ عبور کرده باشد. حالا در هر ایستگاه، یک یا چند کارگر (که همان goalها هستند) کار واقعی را انجام میدهند — یکی پیچ میبندد، یکی رنگ میزند. پس: چرخه = کل خط، فاز = ایستگاه، goal = کارِ مشخصی که یک plugin در آن ایستگاه انجام میدهد.
Maven سه چرخهی حیات داخلی دارد: clean (پاکسازی)، default (ساخت اصلی) و site (تولید مستندات). مهمترینشان default است. فازهای اصلی آن به ترتیب:
| فاز | چه کاری میکند | goalِ معمولِ متصل |
|---|---|---|
validate |
بررسی درستی پروژه و در دسترس بودن اطلاعات لازم | — |
compile |
کامپایل کد اصلی (src/main/java) |
compiler:compile |
test |
اجرای unit testها با یک framework مثل JUnit | surefire:test |
package |
بستهبندی کد کامپایلشده به JAR/WAR | jar:jar |
verify |
اجرای بررسیهای کیفی روی نتیجهی integration test | failsafe:verify |
install |
نصب آرتیفکت در مخزن محلی (~/.m2) |
install:install |
deploy |
آپلود آرتیفکت به مخزن راه دور برای اشتراک | deploy:deploy |
نکتهی کلیدی: وقتی مینویسی mvn package، Maven همهی فازهای قبل از package را هم اجرا میکند — یعنی validate، compile و test هم اجرا میشوند. این ترتیبی و انباشتی بودن، ذاتِ مدل Maven است.
mvn clean # فقط فاز clean: پوشهی target را پاک میکند
mvn compile # validate → compile
mvn test # validate → compile → test
mvn package # ... → test → package (JAR ساخته میشود)
mvn verify # ... → package → verify
mvn install # ... → verify → install (در ~/.m2 نصب میشود)
# ترکیب رایج: اول پاکسازی، بعد ساخت کامل و نصب
mvn clean install
این تصمیم طراحی یعنی تو هیچوقت نمیتوانی «اشتباهاً» بدون تست بستهبندی کنی. اگر package را صدا بزنی، تستها حتماً اجرا میشوند (مگر عمداً skip کنی). این همان «امنیت از طریق قرارداد» است که Maven به آن افتخار میکند: ترتیب درست، بهصورت پیشفرض و اجتنابناپذیر، تضمین شده است.
اگر چیزی یک کلمهی ساده بود مثل mvn package، آن یک فاز است. اگر شکل plugin:goal داشت مثل mvn dependency:tree یا mvn compiler:compile، آن یک goal مشخص از یک plugin است که مستقیم و بدون اجرای فازهای قبلی صدا زده میشود. dependency:tree نمونهی طلاییِ goalی است که برای عیبیابی وابستگیها مدام استفادهاش میکنی.
مدیریت وابستگی در Maven
حالا به قلب ماجرا میرسیم — جایی که ابزارهای Build واقعاً میدرخشند: مدیریت وابستگی. سه مسئله را باید حل کنند: scope (دامنه)، transitive (گذرا بودن) و conflict (تعارض نسخه).
Scope: هر وابستگی کجا لازم است؟
همهی ابزارها در همهی مراحل ساخت خانه لازم نیستند. داربست فقط هنگام ساخت لازم است و در خانهی نهایی نباید بماند. سیمکشی هم موقع ساخت و هم موقع سکونت (اجرا) لازم است. ابزارهای بازرسی فقط برای تست کیفیتاند و ربطی به سکونت ندارند. scope در Maven دقیقاً همین است: تعیین میکند هر وابستگی در کدام مرحله (کامپایل، اجرا، تست) و آیا در بستهی نهایی حضور داشته باشد.
| Scope | در classpath کامپایل | در classpath اجرا (runtime) | در آرتیفکت نهایی | مثال |
|---|---|---|---|---|
compile (پیشفرض) |
بله | بله | بله | Jackson، Guava |
provided |
بله | خیر (محیط فراهم میکند) | خیر | Servlet API، Lombok |
runtime |
خیر | بله | بله | درایور JDBC |
test |
فقط در تست | فقط در تست | خیر | JUnit، Mockito |
system |
بله | بله | خیر | (منسوخ؛ استفاده نکن) |
import |
— | — | — | فقط برای BOM (پایینتر) |
نکتهی ظریف: provided یعنی «این وابستگی موقع کامپایل لازم است اما محیط اجرا (مثلاً سرور اپلیکیشن یا خودِ JDK) خودش آن را فراهم میکند، پس داخل JAR/WAR من قرارش نده». نمونهی کلاسیکش Servlet API است که سرور Tomcat فراهم میکند.
وابستگیهای گذرا (Transitive)
وقتی کتابخانهی A را به پروژه اضافه میکنی، A خودش ممکن است به B و C نیاز داشته باشد، و B هم به D. تو فقط A را دعوت کردی، اما A دوستانش را هم با خودش آورد. این دوستانِ دعوتنشده همان وابستگیهای گذرا (transitive dependencies) هستند. Maven بهطور خودکار کل این زنجیره را حل و دانلود میکند — همان کاری که Ant قدیم نمیکرد و باید دستی انجام میدادی.
برای دیدن این درخت، از این goal استفاده کن:
mvn dependency:tree
خروجی چیزی شبیه این است — میبینی که spring-boot-starter-web کلی چیز دیگر با خودش آورده:
com.example:my-app:jar:1.0.0
+- org.springframework.boot:spring-boot-starter-web:jar:3.4.1:compile
| +- org.springframework.boot:spring-boot-starter:jar:3.4.1:compile
| +- org.springframework:spring-web:jar:6.2.1:compile
| \- com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile
\- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
حل تعارض نسخه: قانون «نزدیکترین برنده است»
حالا مسئلهی جدی: چه میشود اگر A به نسخهی ۱٫۰ از کتابخانهی X نیاز داشته باشد و B به نسخهی ۲٫۰ از همان X؟ classpath نمیتواند دو نسخه از یک کلاس داشته باشد. Maven باید یکی را انتخاب کند.
Maven نسخهای را انتخاب میکند که در درخت وابستگی به ریشه نزدیکتر باشد (کوتاهترین مسیر). اگر دو نسخه در فاصلهی برابر باشند، آنکه اول اعلان شده برنده میشود. توجه کن: این «بالاترین نسخه» نیست! ممکن است Maven عمداً نسخهی پایینتر را انتخاب کند چون در درخت نزدیکتر بود. این تفاوت با Gradle (که «بالاترین نسخه» را میگیرد) یک سؤال کلاسیک مصاحبه است.
مثال ملموس: فرض کن این درخت را داری.
my-app
+- A → X:1.0 (عمق ۲)
\- B
\- C
\- X:2.0 (عمق ۴)
اینجا X:1.0 برنده میشود، چون به ریشه نزدیکتر است (عمق ۲ در برابر ۴) — با اینکه نسخهاش پایینتر است! اگر این رفتار مشکل ایجاد کند، دو ابزار داری:
<!-- راه اول: exclusion — حذف یک وابستگی گذرا از زیرِ B -->
<dependency>
<groupId>com.example</groupId>
<artifactId>B</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- راه دوم و بهتر: نسخه را مستقیم و در عمق ۱ اعلان کن تا برنده شود -->
<dependency>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
<version>2.0.0</version>
</dependency>
چون Maven «نزدیکترین» را میگیرد نه «بالاترین» را، ممکن است ناخواسته یک نسخهی قدیمی و آسیبپذیر (با CVE امنیتی) روی classpath بنشیند، فقط چون آن نسخه اتفاقاً به ریشه نزدیکتر بوده. همیشه با mvn dependency:tree وضعیت واقعی را ببین و برای قفلکردن نسخهها از dependencyManagement استفاده کن.
dependencyManagement و BOM
تصور کن یک فروشگاه زنجیرهای یک «لیست قیمت مرجع» مرکزی دارد. هر شعبه که جنسی میفروشد لازم نیست قیمت را دوباره بنویسد؛ فقط میگوید «فلان جنس» و قیمت از لیست مرکزی میآید. dependencyManagement دقیقاً همین لیست مرجع است: نسخهها را یکجا تعیین میکنی، و در dependencies فقط نام کتابخانه را مینویسی بدون نسخه. یک BOM (مخفف Bill of Materials، یعنی «صورتحساب اقلام») هم یک POM جداست که فقط و فقط شامل همین لیست مرجع است تا چند پروژه از آن به اشتراک استفاده کنند.
بخش dependencyManagement نسخه را اعلام میکند اما وابستگی را به classpath اضافه نمیکند؛ فقط میگوید «اگر و هر جا این کتابخانه استفاده شد، این نسخهاش را بگیر»:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version> <!-- نسخه یکجا تعیین شد -->
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<!-- بدون نسخه! نسخه از dependencyManagement میآید -->
</dependency>
</dependencies>
و برای وارد کردن یک BOM آماده (مثل BOM اسپرینگ بوت که نسخهی دهها کتابخانه را هماهنگ میکند) از scope=import استفاده میکنی:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.1</version>
<type>pom</type>
<scope>import</scope> <!-- کل لیست نسخههای اسپرینگ را import کن -->
</dependency>
</dependencies>
</dependencyManagement>
یک BOM مثل اسپرینگ بوت تضمین میکند تمام کتابخانههای بههممرتبط، نسخههای سازگار را داشته باشند. تو دیگر بازیِ خطرناکِ «کدام نسخهی Jackson با کدام نسخهی Spring میسازد» را بازی نمیکنی؛ تیم اسپرینگ این را یکبار برایت تست کرده و در BOM قفل کرده است. این «هماهنگی نسخهها» یکی از مهمترین دلایل پایداری پروژههای بزرگ است.
پروژهی چندماژولی و reactor
پروژههای واقعی معمولاً از چند بخش تشکیل شدهاند: یک ماژول core، یک api، یک web. Maven این را با ساختار parent/child مدیریت میکند.
یک POM والد (parent) مثل شرکت مادر است: سیاستهای مشترک (نسخهی جاوا، نسخهی وابستگیها، پیکربندی pluginها) را یکجا تعریف میکند، و ماژولهای فرزند این سیاستها را به ارث میبرند. خودِ والد کدی ندارد؛ packaging آن pom است، یعنی صرفاً یک هماهنگکننده است.
<!-- pom.xml والد -->
<project ...>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging> <!-- توجه: pom نه jar -->
<modules>
<module>core</module>
<module>web</module>
</modules>
</project>
وقتی از پوشهی والد mvn install بزنی، Maven چیزی بهنام reactor را فعال میکند.
reactor موتوری در Maven است که همهی ماژولها را میبیند، وابستگی بینشان را تحلیل میکند و ترتیب درست ساختشان را محاسبه میکند. اگر web به core وابسته باشد، reactor خودش میفهمد که باید اول core را بسازد. یعنی تو نگرانِ ترتیب دستی نیستی؛ reactor گراف وابستگی ماژولها را حل میکند — دقیقاً همان کاری که برای وابستگیهای خارجی هم انجام میشد، اما اینبار برای ماژولهای داخلی.
Gradle: فلسفهی «Build برنامهپذیر»
حالا به رقیب میرسیم. اگر Maven «فرم اداری استاندارد» بود، Gradle چیز دیگری است.
اگر Maven یک فرم پرکردنی بود، Gradle یک جعبهی لِگوست. بهجای پرکردن خانههای ثابت، تو با قطعات برنامهنویسی هرچه بخواهی میسازی. این قدرت فوقالعادهای میدهد — هر منطق دلخواهی را میتوانی در Build بگنجانی — اما مسئولیت هم میآورد: چون همهچیز ممکن است، دو پروژهی Gradle میتوانند کاملاً متفاوت به نظر برسند، و این همان «انعطافی» است که گاهی به هرجومرج میانجامد.
Gradle بهجای XML از یک DSL (زبان مخصوص دامنه) استفاده میکند که در واقع کدِ واقعیِ Groovy یا Kotlin است. این یعنی فایل Build تو یک برنامه است، نه فقط داده. فایل Build در Gradle build.gradle (برای Groovy) یا build.gradle.kts (برای Kotlin) نام دارد.
از Gradle نسخهی ۸ به بعد و بهطور کامل در Gradle ۹، وقتی با gradle init پروژهی جدید میسازی، Kotlin DSL (فایل .gradle.kts) پیشفرض است. دلیلش این است که Kotlin تایپدار (typed) است، پس IDE میتواند تکمیل خودکار (auto-completion) و بررسی خطا در زمان نوشتن بدهد — چیزی که Groovy پویا نمیداد. Gradle ۹ روی JDK ۱۷ به بالا اجرا میشود، Groovy 4.0 و Kotlin 2.2 را درون خود دارد. اگر پروژهی جدید میسازی، Kotlin DSL انتخاب پیشفرض و توصیهشده است.
بیایید همان پروژهی قبلی را اینبار با Gradle (Kotlin DSL) ببینیم:
// build.gradle.kts
plugins {
java
}
group = "com.example"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
}
tasks.test {
useJUnitPlatform()
}
فوراً دو چیز به چشم میآید: اول اینکه خیلی کوتاهتر از XML معادلش است. دوم اینکه وابستگیها با یک رشتهی فشرده "group:artifact:version" نوشته میشوند. و بهجای <scope>، اینجا پیکربندی (configuration) داریم: implementation و testImplementation.
compile قدیم Maven تقریباً معادل implementation در Gradle است (اما مهمتر: implementation وابستگی را از مصرفکنندههای پروژهات پنهان میکند تا کامپایلها سریعتر شوند)، provided معادل compileOnly، runtime معادل runtimeOnly، و test معادل testImplementation. تفکیک api در برابر implementation — یعنی «آیا این وابستگی به بیرون درز کند یا نه» — یکی از قویترین ابزارهای Gradle برای شتاب کامپایل در پروژههای بزرگ است.
Task: واحد کارِ Gradle
در Maven واحد کار «فاز» بود؛ در Gradle واحد کار task است. هر کاری که Gradle انجام میدهد — کامپایل، تست، ساخت JAR — یک task است.
یک task مثل یک مرحله از دستور پخت است: «تخممرغها را هم بزن». اما بعضی مراحل به مراحل دیگر وابستهاند: «کیک را بپز» وابسته به «خمیر را آماده کن» است. Gradle از این وابستگیها یک گراف جهتدار بدوندور (DAG) میسازد و تصمیم میگیرد چه چیزی قبل از چه چیزی اجرا شود. وقتی gradle build میزنی، Gradle این گراف را میسازد و فقط taskهای لازم را به ترتیب درست اجرا میکند.
میتوانی task دلخواه خودت را تعریف کنی — این همان «برنامهپذیری» است که Maven نداشت:
tasks.register("hello") {
doLast {
println("Hello from a custom Gradle task!")
}
}
// یک task که به task دیگری وابسته است
tasks.register("deployDocs") {
dependsOn("build") // اول build را اجرا کن، بعد این را
doLast {
println("Deploying documentation...")
}
}
gradle tasks # فهرست همهی taskهای موجود
gradle build # ساخت کامل (کامپایل + تست + بستهبندی)
gradle hello # اجرای task سفارشی ما
gradle deployDocs # اول build، بعد deployDocs
دو فاز حیاتی: configuration در برابر execution
اینجا مهمترین مفهوم Gradle است که فهمیدنش تو را از یک کاربر سطحی به یک کاربر حرفهای تبدیل میکند. هر اجرای Gradle در واقع دو فاز جدا دارد (در واقع سه فاز با initialization، اما تمرکز روی این دو است):
فاز configuration: Gradle کل اسکریپتهای Build را میخواند و اجرا میکند تا گراف taskها را بسازد. این فاز همیشه و کاملاً اجرا میشود — حتی برای taskهایی که قرار نیست اجرا شوند. کدی که مستقیم در بدنهی task یا در سطح اسکریپت مینویسی، در همین فاز اجرا میشود.
فاز execution: حالا Gradle فقط taskهایی را که واقعاً لازماند (طبق گراف) اجرا میکند. کدِ داخل بلوکهای doLast { } و doFirst { } در همین فاز اجرا میشود.
اشتباه کلاسیک این است که کار سنگین (مثل خواندن فایل یا تماس شبکه) را مستقیم در بدنهی task بگذاری — که در فاز configuration و در هر اجرای Gradle انجام میشود، حتی وقتی آن task را صدا نزدهای. کار واقعی باید داخل doLast برود تا فقط در فاز execution و فقط وقتی لازم است اجرا شود.
این مثال تفاوت را روشن میکند:
tasks.register("demo") {
// این خط در فاز CONFIGURATION اجرا میشود — در هر اجرای gradle، همیشه
println("Configuring the task")
doLast {
// این خط فقط در فاز EXECUTION اجرا میشود — فقط وقتی demo واقعاً اجرا شود
println("Executing the task")
}
}
اگر gradle help بزنی (که task demo را اجرا نمیکند)، باز هم "Configuring the task" چاپ میشود، اما "Executing the task" نه. این تله بزرگترین منبع کندی در Buildهای بد است.
چرا Gradle سریع است: چهار ابزار سرعت
شهرت Gradle به سرعتش است. این سرعت از چهار مکانیزم میآید که باید همه را بشناسی.
۱. ساخت افزایشی (Incremental Build)
اگر دیروز کل خانه را تمیز کردهای و امروز فقط یک اتاق کثیف شده، آدم عاقل کل خانه را دوباره نمیشوید — فقط همان یک اتاق را. Gradle هر task را با «ورودیها و خروجیهایش» میشناسد؛ اگر ورودیها از دفعهی قبل عوض نشده باشند، آن task را UP-TO-DATE علامت میزند و کاملاً از آن رد میشود.
وقتی gradle build را دوبار پشتسرهم بزنی، بار دوم میبینی کنار بیشتر taskها نوشته UP-TO-DATE — یعنی Gradle تشخیص داد چیزی عوض نشده و کاری نکرد. این مقایسهی ورودی/خروجی، هستهی سرعت Gradle است.
۲. Build Cache
ساخت افزایشی فقط در همین کامپیوتر و همین پروژه کار میکند. اما build cache یک قدم جلوتر است: خروجی هر task را با یک اثرانگشت (hash) از ورودیهایش ذخیره میکند. اگر یک همکار دیگر (یا سرور CI) دقیقاً همان ورودیها را داشته باشد، بهجای اجرای دوباره، خروجیِ آماده را از cache برمیدارد — حتی روی کامپیوتری دیگر. یعنی اگر همکارت یک ماژول را کامپایل کرده و در cache مشترک گذاشته، تو دیگر لازم نیست دوباره کامپایلش کنی.
# فایل gradle.properties — فعالکردن build cache
org.gradle.caching=true
۳. Daemon
راهاندازی JVM هر بار زمان میبرد (بارگذاری کلاسها، گرمشدن JIT). Gradle Daemon یک فرایند JVM است که در پسزمینه زنده میماند بین Buildها. پس دفعهی بعد که Gradle را صدا میزنی، JVM از قبل گرم و آماده است و لازم نیست از صفر بالا بیاید. daemon امروز بهطور پیشفرض روشن است.
۴. Configuration Cache (جدیدترین و مهمترین)
یادت هست گفتیم فاز configuration همیشه اجرا میشود؟ در پروژههای بزرگ این فاز خودش میتواند چند ثانیه طول بکشد. Configuration Cache نتیجهی فاز configuration را ذخیره میکند و اگر اسکریپتهای Build عوض نشده باشند، کل فاز configuration را بهطور کامل رد میکند و مستقیم میرود سراغ execution. علاوه بر این، taskهای مستقل را بهصورت موازی (parallel) اجرا میکند.
تا Gradle ۹ این ویژگی «توصیهشده اما پیشفرض خاموش» است؛ برنامه این است که در Gradle ۱۰ بهطور خودکار روشن شود. برای فعالکردنش:
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.parallel=true
برای اینکه فاز configuration قابل ذخیره باشد، اسکریپت تو نباید در زمان اجرای task به وضعیت زمان configuration دسترسی داشته باشد؛ مثلاً استفاده از project داخل doLast ممنوع است. اگر Build قدیمی داری، فعالکردن configuration cache ممکن است خطاهایی نشان دهد که باید کد Build را اصلاح کنی. این «قیمتِ» سرعت است — اما ارزشش را دارد.
Version Catalog: پاسخ Gradle به BOM
Gradle هم راهی برای متمرکز کردن نسخهها دارد: فایل libs.versions.toml.
# gradle/libs.versions.toml
[versions]
jackson = "2.18.2"
junit = "5.11.4"
[libraries]
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
// و در build.gradle.kts:
dependencies {
implementation(libs.jackson.databind)
testImplementation(libs.junit.jupiter)
}
مزیتش این است که نسخهها یکجا، تایپدار و با تکمیل خودکار در IDE مدیریت میشوند.
کِی کدام را انتخاب کنیم؟
حالا که هر دو را فهمیدی، بیایید صادقانه مقایسه کنیم. هیچکدام «بهتر» بهطور مطلق نیستند؛ هر کدام برای موقعیتیاند.
| معیار | Maven | Gradle |
|---|---|---|
| فلسفه | قرارداد بر پیکربندی (اعلانی) | Build برنامهپذیر (اعلانی + دستوری) |
| زبان پیکربندی | XML (pom.xml) |
Groovy/Kotlin DSL |
| منحنی یادگیری | ملایم؛ همهجا شبیه هم | تندتر؛ انعطاف بیشتر = پیچیدگی بیشتر |
| سرعت (build افزایشی) | محدود | عالی (cache، daemon، config-cache) |
| حل تعارض نسخه | نزدیکترین برنده | بالاترین نسخه برنده |
| انعطاف/سفارشیسازی | محدود به pluginها | نامحدود (کد دلخواه) |
| اکوسیستم/بلوغ | بسیار بالغ و پایدار | بالغ، پرکاربرد در اندروید |
| قابل پیشبینی بودن | بسیار بالا | به انضباط تیم بستگی دارد |
Maven را انتخاب کن اگر: تیمی داری که سادگی و یکنواختی برایش مهمتر از سرعت است، پروژه استاندارد است و میخواهی هر تازهواردی فوراً بفهمد چه خبر است. Gradle را انتخاب کن اگر: پروژه بزرگ و چندماژولی است و زمان Build برایت حیاتی است، به منطق سفارشی در Build نیاز داری، یا روی اندروید کار میکنی (که Gradle در آن استاندارد رسمی است). بسیاری از پروژههای سازمانیِ محافظهکار روی Maven میمانند و کاملاً حق دارند.
تا امروز، خط پایدار Maven نسخهی 3.9.x است (برای اجرا به JDK ۸ به بالا نیاز دارد). Maven ۴ هنوز در مرحلهی Release Candidate است (نسخهی rc-5 در اواخر ۲۰۲۵) و هنوز GA نشده؛ برای اجرا به JDK ۱۷ نیاز دارد، مدل POM را به 4.1.0 میبرد، «Build POM» را از «Consumer POM» جدا میکند و نوع بستهبندی bom را رسمی میکند. سمت Gradle، خط پایدار ۹.x است (۹.۶ به بالا) که روی JDK ۱۷+ اجرا میشود.
Buildهای بازتولیدپذیر (Reproducible Builds)
یک هدف مهم که هر دو ابزار دنبالش هستند: بازتولیدپذیری. یعنی اگر امروز و شش ماه دیگر و روی کامپیوتری دیگر همان کد را build کنی، بایتبهبایت همان آرتیفکت خروجی بیاید.
سه دشمن اصلی داری: (۱) نسخههای شناور مثل 2.+ یا latest.release — که هر بار ممکن است نسخهی متفاوتی بیاورند؛ همیشه نسخهها را ثابت (pinned) بنویس. (۲) timestamp و ترتیب فایلها داخل JAR — که هر دو ابزار حالت «reproducible» دارند تا اینها را نرمال کنند. (۳) وابسته بودن به محیط مثل نسخهی JDK محلی — که با Java toolchain در Gradle یا پیکربندی maven.compiler.release در Maven مهارش میکنی. یک Build بازتولیدپذیر، پایهی امنیت زنجیرهی تأمین (supply chain) و اعتماد به آرتیفکتهاست.
دامها و بهترین شیوهها
بیایید مهمترین تلهها را یکجا جمع کنیم — همانهایی که در کد واقعی یا مصاحبه گاز میگیرند:
- گیر افتادن در «بالاترین نسخه» در برابر «نزدیکترین نسخه»: Maven نزدیکترین را میگیرد، Gradle بالاترین را. اگر این را ندانی، تعارض نسخهها گیجت میکند.
- کار سنگین در فاز configuration گرِیدل: هر کد سطحبالا یا داخل بدنهی task (نه
doLast) در هر اجرا اجرا میشود. کار واقعی را درdoLastبگذار. - فراموشکردن
dependencyManagement/BOM: بدون قفلکردن مرکزی نسخهها، هر ماژول میتواند نسخهی متفاوتی بکشد و به «جهنم وابستگی» برسی. - استفاده از
compileقدیمی بهجایimplementationدر Gradle:implementationوابستگی را از مصرفکنندهها پنهان میکند و کامپایلها را سریعتر و ماژولها را مستقلتر میکند. - نسخههای شناور (
+): بازتولیدپذیری را نابود میکنند. همیشه نسخهی ثابت. - skip کردن تستها بهعنوان عادت:
-DskipTestsگاهی لازم است اما اگر عادت شود، امنیتِ قراردادیِ چرخهی حیات را دور میزنی.
بهترین شیوهها: نسخهها را مرکزی مدیریت کن (BOM یا version catalog)؛ مرتب dependency:tree یا gradle dependencies را ببین؛ برای پروژهی جدید Gradle، Kotlin DSL و configuration cache را روشن کن؛ Wrapper (چه mvnw چه gradlew) را در پروژه commit کن تا همه با همان نسخهی ابزار build کنند.
هر دو ابزار یک «Wrapper» دارند: ./mvnw و ./gradlew. اینها اسکریپتهای کوچکی هستند که نسخهی دقیقِ ابزار Build را که پروژه به آن نیاز دارد، خودکار دانلود و استفاده میکنند. با commit کردن Wrapper، دیگر لازم نیست Maven یا Gradle روی سیستم کسی نصب باشد و همه — و سرور CI — با دقیقاً همان نسخه build میکنند. این سادهترین قدم بهسوی بازتولیدپذیری است.
پرسشهای مصاحبه
حالا وقت آن است که همهچیز را با پرسشهای واقعی مصاحبهی سنیور محک بزنیم. اول خودت جواب بده، بعد پاسخ را باز کن.
یک phase یک مرحله در چرخهی حیات build است (مثل compile، test، package) و ترتیبی و انباشتی اجرا میشود — یعنی صدا زدن package همهی فازهای قبلش را هم اجرا میکند. یک goal کارِ مشخصی است که یک plugin انجام میدهد (مثل compiler:compile). goalها به فازها متصل (bind) میشوند؛ وقتی یک فاز اجرا میشود، goalهای متصل به آن اجرا میشوند. میتوانی یک goal را هم مستقل صدا بزنی، مثل mvn dependency:tree.
Maven از «نزدیکترین تعریف در درخت (nearest-wins)» استفاده میکند: نسخهای که در درخت وابستگی به ریشه نزدیکتر باشد برنده میشود، و در فاصلهی برابر، اولین اعلانشده. این لزوماً بالاترین نسخه نیست. در مقابل، Gradle بهطور پیشفرض بالاترین نسخهی درخواستشده را انتخاب میکند. این تفاوت مهم است چون در Maven ممکن است ناخواسته یک نسخهی قدیمی و آسیبپذیر روی classpath بنشیند.
dependencies وابستگی را واقعاً به classpath اضافه میکند. dependencyManagement فقط نسخه (و پیکربندی) را اعلام میکند بدون اضافهکردن آن — میگوید «اگر و هر جا این کتابخانه استفاده شد، این نسخهاش را بگیر». معمولاً dependencyManagement را در POM والد میگذاری تا نسخهها را مرکزی قفل کنی و ماژولهای فرزند فقط نام کتابخانه را بدون نسخه بنویسند.
یک BOM (Bill of Materials) یک POM از نوع pom است که فقط شامل یک بخش dependencyManagement بزرگ است و مجموعهای از نسخههای سازگار را با هم تعریف میکند (مثل spring-boot-dependencies). با <scope>import</scope> این لیست را داخل dependencyManagement پروژهی خودت وارد میکنی تا نسخهی همهی آن کتابخانهها یکجا و هماهنگ تعیین شود، بیآنکه همه را به classpath اضافه کند.
compile (پیشفرض) در همهجا حاضر است: کامپایل، اجرا و آرتیفکت نهایی. provided موقع کامپایل هست اما در آرتیفکت نهایی نیست، چون محیط اجرا آن را فراهم میکند (مثل Servlet API). runtime موقع کامپایل نیست اما در اجرا و آرتیفکت هست (مثل درایور JDBC). test فقط در کامپایل و اجرای تستها حاضر است و به آرتیفکت اصلی درز نمیکند (مثل JUnit).
در فاز configuration، Gradle کل اسکریپتهای Build را میخواند و اجرا میکند تا گراف taskها را بسازد؛ این فاز همیشه و کامل اجرا میشود، حتی برای taskهایی که اجرا نمیشوند. در فاز execution، فقط taskهای لازم واقعاً اجرا میشوند (کدِ داخل doLast/doFirst). اهمیتش این است: اگر کارِ سنگین را مستقیم در بدنهی task بگذاری، در هر اجرای Gradle (حتی gradle help) اجرا میشود و Build را کند میکند. کار واقعی باید در doLast باشد.
هر دو وابستگی را در زمان کامپایل و اجرا فراهم میکنند، اما api وابستگی را به مصرفکنندههای پروژهات هم درز میدهد (transitive به بیرون)، در حالی که implementation آن را پنهان میکند. استفاده از implementation هر جا ممکن است، بهتر است چون تغییر در آن وابستگی، مصرفکنندهها را مجبور به کامپایل مجدد نمیکند — نتیجه: کامپایلهای سریعتر و ماژولهای مستقلتر. api را فقط وقتی بهکار ببر که تایپ آن وابستگی در API عمومی خودت ظاهر شود.
هر سه برای سرعتاند اما لایههای متفاوت: incremental build با مقایسهی ورودی/خروجی یک task، اگر چیزی عوض نشده آن را UP-TO-DATE میکند (فقط همین ماشین/پروژه). build cache خروجی taskها را با hash ورودیها ذخیره میکند و میتواند بین ماشینها و در CI به اشتراک گذاشته شود. configuration cache یک لایه بالاتر است و نتیجهی خودِ فاز configuration را ذخیره میکند تا کل آن فاز رد شود. اولی خروجی task را نگه میدارد، سومی گرافِ Build را.
Daemon یک فرایند JVM طولعمر (long-lived) در پسزمینه است که بین Buildها زنده میماند. مشکلی که حل میکند: راهاندازی JVM و گرمشدن JIT هر بار زمانبر است؛ با نگهداشتن یک JVM گرمِ آماده، اجراهای بعدی خیلی سریعتر شروع میشوند. امروز بهطور پیشفرض روشن است.
reactor موتوری است که در یک build چندماژولی همهی ماژولها را میبیند، وابستگی بینشان را تحلیل میکند و ترتیب درست ساختشان را (توپولوژیک) محاسبه میکند. اگر ماژول web به core وابسته باشد، reactor خودش core را اول میسازد. یعنی برنامهنویس نگرانِ ترتیب دستی ماژولها نیست.
سه کار: (۱) نسخهها را ثابت (pinned) بنویس، هرگز شناور مثل 2.+. (۲) نسخهی JDK را با Java toolchain در Gradle یا maven.compiler.release در Maven ثابت کن تا وابسته به JDK محلی نباشی. (۳) حالت reproducible را در بستهبندی روشن کن تا timestamp و ترتیب فایلها داخل JAR نرمال شوند. و مهمتر از همه، Wrapper (mvnw/gradlew) را commit کن تا همه با همان نسخهی ابزار build کنند.
Maven برای پروژههای استاندارد که سادگی، یکنواختی و قابل پیشبینی بودن اولویت است و میخواهی هر تازهواردی فوراً POM را بفهمد. Gradle برای پروژههای بزرگ و چندماژولی که زمان Build حیاتی است (بهخاطر cacheها و اجرای موازی)، برای وقتی منطق سفارشی در Build لازم داری، و برای اندروید که Gradle استاندارد رسمی آن است. جملهی سنیور: «Maven نظم و همگرایی میفروشد، Gradle قدرت و سرعت؛ انتخاب به این بستگی دارد که تیم به کدام بیشتر نیاز دارد.»
- ابزار Build کارهای تکراری (حل وابستگی، کامپایل، تست، بستهبندی، انتشار) را خودکار و بازتولیدپذیر میکند.
- Maven فلسفهی «قرارداد بر پیکربندی» دارد: ساختار ثابت،
pom.xmlبا XML، و چرخهی حیاتی از فازهای ترتیبی (validate → compile → test → package → verify → install → deploy) که در هر فاز goalهای pluginها اجرا میشوند. - مدیریت وابستگی Maven شامل scopeها (compile/provided/runtime/test)، وابستگیهای گذرا، حل تعارض با «نزدیکترین برنده»، و BOM/dependencyManagement برای قفل مرکزی نسخههاست. reactor ترتیب ماژولها را حل میکند.
- Gradle فلسفهی «Build برنامهپذیر» دارد: DSL با Groovy/Kotlin (امروز Kotlin DSL پیشفرض)، taskها روی یک گراف DAG، و دو فاز حیاتی configuration در برابر execution.
- سرعت Gradle از چهار ابزار میآید: ساخت افزایشی، build cache (اشتراکی)، daemon، و configuration cache (که برنامه است در Gradle ۱۰ پیشفرض شود).
- تفاوت کلیدی حل تعارض: Maven «نزدیکترین»، Gradle «بالاترین نسخه».
- انتخاب: Maven برای سادگی و یکنواختی، Gradle برای سرعت و انعطاف. نسخهها را ثابت کن، Wrapper را commit کن، و نسخهها را مرکزی مدیریت کن.
Let's get one thing straight up front: a real Java project is not just a handful of .java files. You depend on dozens of external libraries, you must compile the code, run the tests, bundle up resources, produce a final artifact (a JAR or WAR), and maybe publish it somewhere. If you tried to do all of this by hand with javac and java, you'd lose your mind on even a small project. A build tool is the assistant that automates all this repetitive, intricate work. In this chapter you won't just memorize commands — you'll actually understand the philosophy behind each of the two tools (Maven and Gradle), what pain each one cures, and how to compare them in a senior interview.
The path we'll walk together:
- What a build tool is and why it exists — feel the problem before any tool.
- Maven — the "convention over configuration" philosophy, the POM file, the lifecycle, phases and goals.
- Dependency management — scopes, transitive dependencies, version conflict resolution, and BOMs.
- Multi-module projects and the reactor.
- Gradle — the "programmable build" philosophy, the DSL (Groovy/Kotlin), tasks and their graph.
- Speed in Gradle — configuration vs execution phase, incremental build, build cache, daemon, and configuration cache.
- When to pick which, reproducible builds, pitfalls, and 12 interview questions with full answers.
Part 0 — a few words you must feel before we start
Before we dive into detail, a few terms recur throughout this chapter. Let me plant them in your mind now.
- Artifact: the output a build produces or consumes — usually a JAR (a Java library), a WAR (a web app), or a POM. Each artifact is identified by the triple
groupId:artifactId:version, like the name, surname, and national ID of a package. - Dependency: an external library your project needs, like Jackson for JSON or JUnit for testing.
- Repository: a warehouse where artifacts are stored. Java's central one is Maven Central; both Maven and Gradle download dependencies from it.
- Declarative vs imperative: "declarative" means you state what you want and the tool decides how (like ordering from a menu). "Imperative" means you spell out the how, step by step (like writing a recipe). This distinction is the very heart of the difference between Maven and Gradle.
What is a build tool, and why does it even exist?
Imagine cooking for a hundred guests. You could peel every potato yourself, hunt for spices each time, and do everything from scratch — that's compiling by hand with javac; fine for home cooking, a disaster at restaurant scale. A build tool is like a professional restaurant kitchen: ingredients (dependencies) arrive automatically from the pantry, the cooking steps (compile, test, package) follow a fixed, standard order, and you just say "prepare tonight's dish." The kitchen knows the rest.
Let's see the problem precisely. The lifecycle of a piece of Java code on its way to becoming a product needs all of this:
- Resolve and download dependencies: figure out which libraries at which versions the project needs, and fetch them from a repository.
- Compile: turn
.javainto.class. - Run tests: unit and integration tests.
- Package: build a JAR/WAR from classes and resources.
- Publish/deploy: push the artifact to a repository so others can use it.
A build tool does all of this from one config file and a couple of short commands. More important than convenience, a build tool brings reproducibility: every developer and every CI server, given the same config, produces the same output. The infamous "but it worked on my machine" is exactly the pain a build tool cures.
The Java world has two dominant tools: Maven (since 2004) with its "convention over configuration" philosophy and a rigid declarative structure, and Gradle (2007–2012) with its "programmable, flexible build" philosophy. (An older tool, Ant, worked fully manually with no dependency management; it's effectively historical today and we won't focus on it.) This whole chapter is really the story of the tug-of-war between "Maven's simplicity and standardization" and "Gradle's power and speed."
Maven: the "convention over configuration" philosophy
Maven is like a standardized official form. You fill in values in predefined boxes, and every form in every office looks the same. The upside: anyone who sees any form instantly knows where everything is — order and uniformity. The downside: if you want to do something outside the boxes, you fight the form's low flexibility. That's exactly the Maven experience: superb order, limited flexibility.
Maven's founding principle is "Convention over Configuration." It ships with sensible defaults, and if you follow them, there's almost nothing left to configure. The folder structure, for instance, is conventional:
my-app/
├── pom.xml
└── src/
├── main/
│ ├── java/ ← main code goes here
│ └── resources/ ← resource files (config, properties)
└── test/
├── java/ ← test code goes here
└── resources/
You never tell Maven "where the code is" because the convention is that it lives in src/main/java. That one decision erases thousands of lines of configuration.
The POM file: the project's identity card
The pom.xml file (short for Project Object Model) is at once the project's identity card (who is this project? what version?) and its shopping list (which libraries does it need?). One page where everything about the project is declared.
The simplest possible POM looks like this:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- this project's identity triple (coordinates) -->
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Three things to feel here: coordinates (the groupId:artifactId:version that identifies the project), properties (reusable variables like the Java version), and dependencies (the shopping list). That the POM is written in XML is both a strength (standard, schema-validatable, machine-readable) and Maven's famous weakness (verbose and long-winded).
Lifecycle, phase, and goal — three words everyone conflates
Here is Maven's single most important concept, and most people leave it half-understood. Three terms you must keep sharply distinct:
A lifecycle is like a factory's whole production line. That line is made of several phases in sequence: station 1, station 2, station 3… and the golden rule is: to reach station 5, the product must have passed through stations 1 to 4. Now at each station, one or more workers (those are the goals) do the actual work — one tightens a bolt, one paints. So: lifecycle = the whole line, phase = a station, goal = the specific job a plugin performs at that station.
Maven has three built-in lifecycles: clean (cleanup), default (the main build), and site (documentation). The most important is default. Its main phases, in order:
| Phase | What it does | Typical bound goal |
|---|---|---|
validate |
Check the project is correct and all needed info is available | — |
compile |
Compile the main code (src/main/java) |
compiler:compile |
test |
Run unit tests with a framework like JUnit | surefire:test |
package |
Bundle compiled code into a JAR/WAR | jar:jar |
verify |
Run quality checks on integration-test results | failsafe:verify |
install |
Install the artifact into the local repo (~/.m2) |
install:install |
deploy |
Upload the artifact to a remote repo for sharing | deploy:deploy |
The key point: when you run mvn package, Maven also runs every phase before package — that means validate, compile, and test all run too. This sequential, cumulative nature is the essence of the Maven model.
mvn clean # only the clean phase: deletes the target folder
mvn compile # validate → compile
mvn test # validate → compile → test
mvn package # ... → test → package (the JAR is built)
mvn verify # ... → package → verify
mvn install # ... → verify → install (installed into ~/.m2)
# common combo: clean first, then a full build and install
mvn clean install
This design decision means you can never "accidentally" package without testing. If you call package, the tests definitely run (unless you deliberately skip them). This is the "safety by convention" Maven prides itself on: the correct order is guaranteed, by default and unavoidably.
If something is a single plain word like mvn package, it's a phase. If it has the plugin:goal shape like mvn dependency:tree or mvn compiler:compile, it's a specific goal of a plugin, invoked directly without running the preceding phases. dependency:tree is the golden example of a goal you'll reach for constantly to debug dependencies.
Dependency management in Maven
Now we reach the heart of the matter — where build tools truly shine: dependency management. They must solve three problems: scope, transitivity, and version conflict.
Scope: where is each dependency needed?
Not every tool is needed at every stage of building a house. Scaffolding is needed only during construction and must not remain in the finished house. Wiring is needed both during construction and while living there (runtime). Inspection tools are only for testing quality and have nothing to do with living there. A Maven scope is exactly this: it decides at which stage (compile, runtime, test) a dependency is needed, and whether it ends up in the final bundle.
| Scope | On compile classpath | On runtime classpath | In final artifact | Example |
|---|---|---|---|---|
compile (default) |
yes | yes | yes | Jackson, Guava |
provided |
yes | no (env supplies it) | no | Servlet API, Lombok |
runtime |
no | yes | yes | JDBC driver |
test |
test only | test only | no | JUnit, Mockito |
system |
yes | yes | no | (deprecated; avoid) |
import |
— | — | — | BOM only (see below) |
A subtle point: provided means "this dependency is needed at compile time, but the runtime environment (say an app server, or the JDK itself) supplies it, so don't put it inside my JAR/WAR." The classic example is the Servlet API, supplied by a server like Tomcat.
Transitive dependencies
When you add library A to your project, A itself may need B and C, and B may need D. You invited only A, but A brought its friends along. Those uninvited friends are the transitive dependencies. Maven automatically resolves and downloads this entire chain — exactly what old Ant did not do, forcing you to do it by hand.
To see this tree, use this goal:
mvn dependency:tree
The output looks something like this — you can see spring-boot-starter-web dragged a lot of other things along:
com.example:my-app:jar:1.0.0
+- org.springframework.boot:spring-boot-starter-web:jar:3.4.1:compile
| +- org.springframework.boot:spring-boot-starter:jar:3.4.1:compile
| +- org.springframework:spring-web:jar:6.2.1:compile
| \- com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile
\- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
Version conflict resolution: the "nearest-wins" rule
Now the serious problem: what if A needs version 1.0 of library X, and B needs version 2.0 of the same X? A classpath cannot hold two versions of one class. Maven must pick one.
Maven picks the version that is closer to the root in the dependency tree (the shortest path). If two versions are at equal depth, the one declared first wins. Note: this is not "highest version"! Maven may deliberately pick a lower version simply because it sat closer in the tree. This contrast with Gradle (which picks the highest version) is a classic interview question.
A concrete example. Suppose you have this tree:
my-app
+- A → X:1.0 (depth 2)
\- B
\- C
\- X:2.0 (depth 4)
Here X:1.0 wins because it's closer to the root (depth 2 vs 4) — even though it's the lower version! If that behavior causes trouble, you have two tools:
<!-- Option 1: exclusion — remove a transitive dependency from under B -->
<dependency>
<groupId>com.example</groupId>
<artifactId>B</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Option 2, better: declare the version directly at depth 1 so it wins -->
<dependency>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
<version>2.0.0</version>
</dependency>
Because Maven picks the "nearest," not the "highest," you can unwittingly end up with an old, vulnerable version (with a security CVE) on the classpath, just because it happened to be closer to the root. Always inspect the real situation with mvn dependency:tree, and use dependencyManagement to lock versions.
dependencyManagement and BOM
Imagine a chain store keeps a central "master price list." Each branch that sells an item needn't rewrite the price; it just names the item and the price comes from the central list. dependencyManagement is exactly that master list: you set versions in one place, and in dependencies you write only the library name, without a version. A BOM (Bill of Materials) is a separate POM that contains only that master list, so multiple projects can share it.
The dependencyManagement section declares a version but does not add the dependency to the classpath; it only says "if and wherever this library is used, take this version of it":
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version> <!-- version set in one place -->
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<!-- no version! it comes from dependencyManagement -->
</dependency>
</dependencies>
And to import a ready-made BOM (like Spring Boot's, which harmonizes dozens of library versions), you use scope=import:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.1</version>
<type>pom</type>
<scope>import</scope> <!-- import Spring's whole version list -->
</dependency>
</dependencies>
</dependencyManagement>
A BOM like Spring Boot's guarantees that all interrelated libraries carry compatible versions. You no longer play the dangerous game of "which Jackson version works with which Spring version"; the Spring team tested that once for you and locked it in the BOM. This "version harmony" is one of the biggest reasons large projects stay stable.
Multi-module projects and the reactor
Real projects usually consist of several parts: a core module, an api, a web. Maven manages this with a parent/child structure.
A parent POM is like a parent company: it defines shared policies (Java version, dependency versions, plugin configuration) in one place, and child modules inherit them. The parent itself has no code; its packaging is pom, meaning it's purely a coordinator.
<!-- parent pom.xml -->
<project ...>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging> <!-- note: pom, not jar -->
<modules>
<module>core</module>
<module>web</module>
</modules>
</project>
When you run mvn install from the parent folder, Maven activates something called the reactor.
The reactor is the engine in Maven that sees all modules, analyzes the dependencies between them, and computes the correct build order. If web depends on core, the reactor figures out that core must be built first. So you don't worry about manual ordering; the reactor resolves the module dependency graph — exactly what it did for external dependencies, but this time for internal modules.
Gradle: the "programmable build" philosophy
Now we reach the rival. If Maven was a "standardized official form," Gradle is something else entirely.
If Maven was a fill-in form, Gradle is a box of Lego. Instead of filling fixed boxes, you build whatever you want out of programming pieces. This gives tremendous power — you can bake any logic you like into the build — but it brings responsibility too: because anything is possible, two Gradle projects can look completely different, and that's the "flexibility" that sometimes tips into chaos.
Instead of XML, Gradle uses a DSL (domain-specific language) that is actually real Groovy or Kotlin code. That means your build file is a program, not just data. The build file is named build.gradle (for Groovy) or build.gradle.kts (for Kotlin).
Since Gradle 8, and fully in Gradle 9, when you scaffold a new project with gradle init, Kotlin DSL (the .gradle.kts file) is the default. The reason: Kotlin is typed, so the IDE can offer auto-completion and error checking as you type — something dynamic Groovy couldn't. Gradle 9 runs on JDK 17+, embeds Groovy 4.0 and Kotlin 2.2. For a new project, Kotlin DSL is the default and recommended choice.
Let's see that same earlier project, this time in Gradle (Kotlin DSL):
// build.gradle.kts
plugins {
java
}
group = "com.example"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
}
tasks.test {
useJUnitPlatform()
}
Two things jump out immediately: first, it's far shorter than the equivalent XML. Second, dependencies are written as a compact "group:artifact:version" string. And instead of <scope>, here we have configurations: implementation and testImplementation.
Maven's old compile roughly maps to Gradle's implementation (but crucially, implementation hides the dependency from your project's consumers to speed up compiles), provided maps to compileOnly, runtime to runtimeOnly, and test to testImplementation. The api vs implementation split — "should this dependency leak outward or not" — is one of Gradle's strongest tools for compile speed in large projects.
Task: Gradle's unit of work
In Maven the unit of work was the "phase"; in Gradle the unit of work is the task. Everything Gradle does — compile, test, build a JAR — is a task.
A task is like one step of a recipe: "beat the eggs." But some steps depend on others: "bake the cake" depends on "prepare the batter." Gradle builds a directed acyclic graph (DAG) from these dependencies and decides what runs before what. When you run gradle build, Gradle builds this graph and runs only the needed tasks in the correct order.
You can define your own custom task — that's the "programmability" Maven lacked:
tasks.register("hello") {
doLast {
println("Hello from a custom Gradle task!")
}
}
// a task that depends on another task
tasks.register("deployDocs") {
dependsOn("build") // run build first, then this
doLast {
println("Deploying documentation...")
}
}
gradle tasks # list all available tasks
gradle build # full build (compile + test + package)
gradle hello # run our custom task
gradle deployDocs # build first, then deployDocs
Two vital phases: configuration vs execution
Here is Gradle's most important concept, the one that turns you from a surface user into a pro. Every Gradle run actually has two distinct phases (three with initialization, but we focus on these two):
Configuration phase: Gradle reads and executes all build scripts to build the task graph. This phase always runs, in full — even for tasks that won't be executed. Code you write directly in a task's body or at script level runs in this phase.
Execution phase: now Gradle runs only the tasks actually needed (per the graph). Code inside doLast { } and doFirst { } blocks runs in this phase.
The classic mistake is putting heavy work (like reading a file or a network call) directly in a task's body — which runs during configuration on every Gradle invocation, even when you never called that task. Real work must go inside doLast so it runs only in the execution phase and only when needed.
This example makes the difference clear:
tasks.register("demo") {
// this line runs in the CONFIGURATION phase — on every gradle run, always
println("Configuring the task")
doLast {
// this line runs only in the EXECUTION phase — only when demo actually runs
println("Executing the task")
}
}
If you run gradle help (which does not run the demo task), "Configuring the task" still prints, but "Executing the task" does not. This trap is the single biggest source of slowness in bad builds.
Why Gradle is fast: four speed tools
Gradle's fame rests on its speed. That speed comes from four mechanisms you must all know.
1. Incremental build
If you cleaned the whole house yesterday and today only one room got dirty, a sensible person doesn't re-scrub the entire house — just that one room. Gradle knows each task by "its inputs and outputs"; if the inputs haven't changed since last time, it marks the task UP-TO-DATE and skips it entirely.
Run gradle build twice in a row and, the second time, you'll see most tasks marked UP-TO-DATE — Gradle detected nothing changed and did nothing. This input/output comparison is the core of Gradle's speed.
2. Build cache
Incremental build works only on this machine and this project. But the build cache goes a step further: it stores each task's output keyed by a fingerprint (hash) of its inputs. If another teammate (or the CI server) has exactly the same inputs, instead of re-running, they pull the ready output from the cache — even on a different machine. So if your colleague already compiled a module and put it in the shared cache, you never need to compile it again.
# gradle.properties — enable the build cache
org.gradle.caching=true
3. Daemon
Starting a JVM takes time every time (loading classes, warming up the JIT). The Gradle Daemon is a JVM process that stays alive between builds. So next time you invoke Gradle, the JVM is already warm and ready, with no cold start. The daemon is on by default today.
4. Configuration cache (the newest and most important)
Remember we said the configuration phase always runs? On large projects that phase alone can take several seconds. The configuration cache stores the result of the configuration phase, and if the build scripts haven't changed, it skips the entire configuration phase and jumps straight to execution. On top of that, it runs independent tasks in parallel.
As of Gradle 9 this feature is "recommended but off by default"; the plan is for it to turn on automatically in Gradle 10. To enable it:
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.parallel=true
For the configuration phase to be cacheable, your script must not access configuration-time state during task execution; e.g. using project inside doLast is forbidden. If you have an old build, enabling the configuration cache may surface errors you must fix in the build code. That's the "price" of the speed — but it's worth it.
Version catalog: Gradle's answer to the BOM
Gradle also has a way to centralize versions: the libs.versions.toml file.
# gradle/libs.versions.toml
[versions]
jackson = "2.18.2"
junit = "5.11.4"
[libraries]
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
// and in build.gradle.kts:
dependencies {
implementation(libs.jackson.databind)
testImplementation(libs.junit.jupiter)
}
The advantage: versions are managed in one place, typed, and with auto-completion in the IDE.
When to pick which?
Now that you understand both, let's compare honestly. Neither is "better" absolutely; each fits a situation.
| Criterion | Maven | Gradle |
|---|---|---|
| Philosophy | Convention over configuration (declarative) | Programmable build (declarative + imperative) |
| Config language | XML (pom.xml) |
Groovy/Kotlin DSL |
| Learning curve | Gentle; looks the same everywhere | Steeper; more flexibility = more complexity |
| Speed (incremental) | Limited | Excellent (cache, daemon, config-cache) |
| Conflict resolution | Nearest-wins | Highest version wins |
| Flexibility / customization | Limited to plugins | Unlimited (arbitrary code) |
| Ecosystem / maturity | Very mature and stable | Mature, dominant on Android |
| Predictability | Very high | Depends on team discipline |
Pick Maven if: you have a team that values simplicity and uniformity over speed, the project is standard, and you want every newcomer to instantly understand what's going on. Pick Gradle if: the project is large and multi-module and build time is critical to you, you need custom logic in the build, or you work on Android (where Gradle is the official standard). Many conservative enterprise projects stay on Maven, and they're perfectly right to.
As of today, Maven's stable line is 3.9.x (needs JDK 8+ to run). Maven 4 is still at the Release Candidate stage (rc-5 in late 2025) and not yet GA; it needs JDK 17 to run, moves the POM model to 4.1.0, separates the "build POM" from the "consumer POM," and makes the bom packaging type official. On the Gradle side, the stable line is 9.x (9.6+), which runs on JDK 17+.
Reproducible builds
An important goal both tools pursue: reproducibility. It means if you build the same code today, six months from now, and on a different machine, you get byte-for-byte the same artifact.
You have three main enemies: (1) floating versions like 2.+ or latest.release — which may pull a different version each time; always write pinned versions. (2) Timestamps and file order inside the JAR — both tools have a "reproducible" mode to normalize these. (3) Environment dependence like the local JDK version — tame it with a Java toolchain in Gradle or the maven.compiler.release setting in Maven. A reproducible build is the foundation of supply-chain security and of trust in your artifacts.
Pitfalls and best practices
Let's gather the top traps in one place — the ones that bite in real code or interviews:
- Getting caught by "highest version" vs "nearest version": Maven picks nearest, Gradle picks highest. If you don't know this, version conflicts will confuse you.
- Heavy work in Gradle's configuration phase: any top-level code or code in a task's body (not
doLast) runs on every invocation. Put real work indoLast. - Forgetting
dependencyManagement/BOM: without centrally locking versions, each module can pull a different version and you land in "dependency hell." - Using old
compileinstead ofimplementationin Gradle:implementationhides the dependency from consumers, making compiles faster and modules more independent. - Floating versions (
+): they destroy reproducibility. Always pin versions. - Skipping tests as a habit:
-DskipTestsis sometimes needed, but as a habit it bypasses the lifecycle's built-in safety.
Best practices: manage versions centrally (BOM or version catalog); regularly inspect dependency:tree or gradle dependencies; for a new Gradle project, enable Kotlin DSL and the configuration cache; commit the Wrapper (mvnw or gradlew) so everyone builds with the same tool version.
Both tools have a "Wrapper": ./mvnw and ./gradlew. These are small scripts that automatically download and use the exact build-tool version the project needs. By committing the Wrapper, nobody needs Maven or Gradle installed on their machine, and everyone — plus the CI server — builds with exactly the same version. It's the simplest step toward reproducibility.
Interview Questions
Now it's time to test everything with real senior-interview questions. Try answering each yourself first, then open the answer.
A phase is a stage in the build lifecycle (like compile, test, package), executed sequentially and cumulatively — calling package also runs all preceding phases. A goal is the specific work a plugin performs (like compiler:compile). Goals are bound to phases; when a phase runs, the goals bound to it run. You can also invoke a goal directly and standalone, like mvn dependency:tree.
Maven uses "nearest definition in the tree (nearest-wins)": the version closer to the root in the dependency tree wins, and at equal depth, the first-declared. This is not necessarily the highest version. Gradle, by contrast, defaults to picking the highest requested version. The difference matters because in Maven you can unwittingly end up with an old, vulnerable version on the classpath.
dependencies actually adds a dependency to the classpath. dependencyManagement only declares a version (and configuration) without adding it — it says "if and wherever this library is used, take this version." You typically put dependencyManagement in the parent POM to lock versions centrally, so child modules write only the library name without a version.
A BOM (Bill of Materials) is a POM of type pom that contains only a large dependencyManagement section defining a set of compatible versions together (like spring-boot-dependencies). With <scope>import</scope> you import that list into your own project's dependencyManagement, so all those libraries get a single, harmonized version — without adding them all to the classpath.
compile (default) is present everywhere: compile, runtime, and the final artifact. provided is present at compile time but not in the final artifact, because the runtime environment supplies it (like the Servlet API). runtime is absent at compile time but present at runtime and in the artifact (like a JDBC driver). test is present only when compiling and running tests and never leaks into the main artifact (like JUnit).
In the configuration phase, Gradle reads and executes all build scripts to build the task graph; this phase runs always and in full, even for tasks that won't be executed. In the execution phase, only the needed tasks actually run (code inside doLast/doFirst). Why it matters: if you put heavy work directly in a task body, it runs on every Gradle invocation (even gradle help) and slows the build. Real work must go in doLast.
Both provide the dependency at compile and runtime, but api leaks the dependency to your project's consumers too (transitively outward), whereas implementation hides it. Prefer implementation wherever possible, because a change in that dependency won't force consumers to recompile — result: faster compiles and more independent modules. Use api only when that dependency's types appear in your own public API.
All three are for speed but at different layers: incremental build compares a task's inputs/outputs and marks it UP-TO-DATE if nothing changed (this machine/project only). Build cache stores task outputs keyed by an input hash and can be shared across machines and in CI. Configuration cache is a layer higher and stores the result of the configuration phase itself so the whole phase is skipped. The first keeps task outputs; the third keeps the build graph.
The Daemon is a long-lived background JVM process that stays alive between builds. The problem it solves: starting a JVM and warming up the JIT is costly every time; by keeping a warm, ready JVM around, subsequent runs start much faster. It's on by default today.
The reactor is the engine that, in a multi-module build, sees all modules, analyzes the dependencies between them, and computes the correct (topological) build order. If module web depends on core, the reactor builds core first. So the developer doesn't worry about manually ordering modules.
Three things: (1) pin versions, never floating like 2.+. (2) Fix the JDK version with a Java toolchain in Gradle or maven.compiler.release in Maven so you don't depend on the local JDK. (3) Enable reproducible mode in packaging so timestamps and file order inside the JAR are normalized. And most importantly, commit the Wrapper (mvnw/gradlew) so everyone builds with the same tool version.
Maven for standard projects where simplicity, uniformity, and predictability are the priority and you want every newcomer to instantly understand the POM. Gradle for large, multi-module projects where build time is critical (thanks to caches and parallelism), when you need custom logic in the build, and for Android, where Gradle is the official standard. The senior line: "Maven sells order and convergence, Gradle sells power and speed; the choice depends on which your team needs more."
- A build tool automates the repetitive work (dependency resolution, compile, test, package, publish) and makes it reproducible.
- Maven follows "convention over configuration": a fixed structure, an XML
pom.xml, and a lifecycle of sequential phases (validate → compile → test → package → verify → install → deploy) where each phase runs plugin goals. - Maven's dependency management includes scopes (compile/provided/runtime/test), transitive dependencies, conflict resolution by "nearest-wins," and BOM/dependencyManagement for central version locking. The reactor resolves module order.
- Gradle follows the "programmable build" philosophy: a DSL in Groovy/Kotlin (Kotlin DSL is the default today), tasks on a DAG, and the two vital phases of configuration vs execution.
- Gradle's speed comes from four tools: incremental build, build cache (shareable), daemon, and configuration cache (planned to be default in Gradle 10).
- The key conflict-resolution difference: Maven picks "nearest," Gradle picks "highest version."
- Choosing: Maven for simplicity and uniformity, Gradle for speed and flexibility. Pin versions, commit the Wrapper, and manage versions centrally.