settings.gradle.kts declares which modules exist and where plugins and libraries are downloaded from, each module's build.gradle.kts configures that module, and the Gradle wrapper pins one Gradle version for every machine.
**settings.gradle.kts**: include(":app", ":core:network"), plus the repositories in pluginManagement and dependencyResolutionManagement. Gradle evaluates it first.
**Root build.gradle.kts**: usually just lists plugins with apply false, so every module uses the same AGP and Kotlin versions.
**Module build.gradle.kts**: applies plugins and holds the android { } block (namespace, compileSdk, minSdk, build types) and dependencies { }.
**gradle/libs.versions.toml: the version catalog: every library and plugin version in one file.
gradle.properties**: daemon memory (org.gradle.jvmargs) and build flags such as org.gradle.caching=true.
**gradlew and local.properties**: gradlew and gradle/wrapper/gradle-wrapper.properties download and run the pinned Gradle version, so build with ./gradlew and commit them; local.properties holds this machine's SDK path and is never committed.
The .kts suffix means Kotlin DSL, the default for new projects: scripts are type-checked and get autocomplete, unlike the older Groovy build.gradle.
AndroidSenior
Gradle & Modularization
Gradle KTS, version catalogs, convention plugins, KSP, build variants, configuration cache, modules
Questions closed
0 / 20
- 0Review
- 2Asked often
Questions
Android Lint is the static analyzer built into AGP: it checks code, resources and the manifest for real bugs, such as calling an API above minSdk, a missing permission or an unused resource. It becomes a quality gate when CI runs it and errors fail the build.
Running it: ./gradlew lintRelease writes HTML and XML reports under build/reports. lintVitalRelease runs automatically during release builds and checks only fatal issues.
Legacy code: baseline = file("lint-baseline.xml") records today's issues and reports only new ones, so you can switch the gate on without first fixing 800 old warnings.
Severity: warningsAsErrors = true makes the gate strict; disable += "..." or a lint.xml file silences checks that are noise for your project.
Custom rules: your own checks live in a module added with lintChecks(project(":lint-rules")); libraries ship theirs inside the AAR with lintPublish.
Not a style checker: formatting and Kotlin code smells are the job of ktlint and detekt; teams usually run all three in CI.
One classes.dex file can reference at most 65,536 methods, so larger apps are split into classes2.dex, classes3.dex and so on. With minSdk 21 or higher, ART loads all of them natively, so multidex now works without any setup.
The limit: method references in a dex file use a 16-bit index, and every library counts. An app with Firebase, Compose and a few SDKs passes 64K easily.
Why it was painful: before Android 5.0, Dalvik loaded only the primary dex. You needed multiDexEnabled = true, the androidx.multidex library and MultiDexApplication, and classes used at startup had to stay in the primary dex or old phones crashed with ClassNotFoundException.
Today: D8 and R8 produce as many dex files as needed, and most current AndroidX libraries require minSdk 21 or higher anyway. The library is history.
What still matters: R8 shrinking removes unused code, which cuts the method count, the APK size and startup class loading.
./gradlew hello prints 1, 5, 2, 3 and ./gradlew help prints only 1 and 5. The script body runs in the configuration phase of every build, a register block runs only when its task is needed, and doLast runs only when the task executes.
Gradle runs three phases: initialization (settings.gradle.kts decides which projects exist), configuration (every build script is evaluated and the task graph is built) and execution (only the requested tasks run their actions).
1 and 5: top-level statements. Every developer pays for them on every invocation, whatever task they asked for.
2: tasks.register is lazy: its block runs when hello enters the task graph. With tasks.create, both 2 and 4 would print on every build.
4 never prints: nothing needs cleanup. ./gradlew tasks would print it, because listing tasks has to create them all.
3: doLast is the execution phase. Real work belongs here or in a @TaskAction.
With the configuration cache on: a second ./gradlew hello prints only 3: the task graph comes from the cache and the script is not evaluated at all.
The classic slowdown is a git rev-parse at the top of a build script: it runs on every build, even help. Move it into providers.exec, as in the example.
They decide which classpaths a dependency lands on. implementation is visible only to this module's code, api is also on every consumer's compile classpath, compileOnly exists only while compiling, and runtimeOnly only in the packaged app.
**implementation: the default. Consumers still get the library at runtime but cannot compile against it, so a change inside it never recompiles them.
api: needed only when the library's types appear in your public signatures: a return type, a parameter, a supertype. The cost: every consumer, transitively, recompiles when that library's ABI changes.
compileOnly: annotations that disappear after compilation, or an API the runtime provides. Not packaged.
runtimeOnly: something your code never references directly, such as a logging backend.
Scoped variants**: debugImplementation(libs.leakcanary) puts a library only into debug builds; testImplementation and androidTestImplementation are the test classpaths.
The classic mistake: :app fails with "Cannot access class 'okhttp3.OkHttpClient'" because :core:network returns that type from a public function while declaring OkHttp as implementation. Switching to api makes it compile, but the better fix is often to stop exposing the type and return your own.
A version catalog (gradle/libs.versions.toml) is one shared list of libraries, plugins and versions with type-safe accessors such as libs.retrofit. It only declares versions; a BOM added with platform(...) actually aligns versions in the resolved graph, transitive ones included.
What the catalog gives: one place to bump a version, no version strings in module scripts, [bundles] for groups and [plugins] for alias(libs.plugins...). Dashes become dots: androidx-core-ktx is libs.androidx.core.ktx.
What it does not do: it does not stop a transitive dependency from pulling another version, and it enforces nothing. It is a typed list of constants.
The BOM: implementation(platform(libs.compose.bom)) adds version constraints for a whole library family, so ui, material3 and anything that pulls them in transitively end up on matching versions. Catalog entries for those libraries carry no version.
In convention plugins: the generated libs accessor does not exist inside build-logic classes; read the catalog through VersionCatalogsExtension and findLibrary("retrofit").
Updates: Renovate and Dependabot understand the TOML file, so a version bump arrives as a one-line pull request.
Build types describe how the app is built (debuggable, signed, minified), product flavors describe which app is built (free or paid, staging or prod), and a variant is one combination. Staging gets its own applicationIdSuffix, a buildConfigField for the URL and a src/staging/res overlay.
Installs next to production: applicationIdSuffix = ".staging" changes the app's identity on the device. namespace, where R and BuildConfig live, stays the same, so no code changes.
Its own URL: buildConfigField("String", "API_URL", ...) per build type; code reads BuildConfig.API_URL and contains no if. Since AGP 8, buildFeatures.buildConfig = true must be set explicitly.
Its own icon and name: put ic_launcher and app_name into src/staging/res; a variant's source set overrides the same resources in src/main. A separate google-services.json goes there too.
Library modules: they have no staging build type, so matchingFallbacks += listOf("release") tells Gradle which of their variants to use.
Build type or flavor: a build type is enough when staging means "release, but for QA". Use a flavor dimension (env with dev, staging, prod) when you need staging both debuggable and minified, at the cost of more variants: three env flavors times two build types is six.
Twelve: 2 tiers × 2 stores × 3 build types, each with its own set of tasks. freePlayDebug uses src/freePlayDebug, src/debug, src/freePlay, src/free, src/play and src/main, from highest priority to lowest.
Resources and manifests merge: a string in src/freePlay/res overrides the same string in src/free, which overrides src/main. Between single flavors, the dimension listed first in flavorDimensions wins.
Classes do not override: the same class in src/main and src/free is a duplicate class error. Put one implementation of Billing in src/free and another in src/paid, and none in main.
Every variant costs: configuration time and IDE sync grow with the number of variants, and a CI job that builds them all multiplies its time.
Drop what never ships: androidComponents.beforeVariants with enable = false removes a combination before any of its tasks are created.
Picking one: Android Studio builds one variant at a time (the Build Variants panel); on the command line it is ./gradlew assembleFreePlayDebug.
Sources compile to .class files, D8 (or R8 when isMinifyEnabled = true) turns them into classes.dex, AAPT2 compiles the resources and the merged manifest, and the result is zipped, aligned and signed.
Compile: annotation processors (KSP, kapt) run first, then kotlinc and javac produce bytecode. Libraries arrive as .jar and .aar files.
Dex: D8 turns bytecode into dex and desugars newer Java features; with isMinifyEnabled = true R8 shrinks, obfuscates, optimizes and dexes in one pass.
Resources: manifests from every module and library are merged; AAPT2 compiles res/ into binary XML plus resources.arsc (the resource ID to value table) and generates R.
Package and sign: the APK is a zip, aligned with zipalign (.so files uncompressed, on 16 KB boundaries), then signed by apksigner: v1 (JAR files in META-INF), v2 (the whole file, Android 7+), v3 (key rotation, Android 9+), v4 (incremental install).
Inside an APK: classes*.dex, resources.arsc, res/, a binary AndroidManifest.xml, lib/<abi>/, assets/, META-INF/; the v2+ signature sits in a signing block.
AAB: bundleRelease stops before the final APK: it keeps the compiled parts and resource tables in a publishing format and Play cuts the per-device APKs.
kapt runs Java annotation processors by first generating Java stubs for every Kotlin file and compiling them with javac; KSP is a Kotlin API that reads the compiler's symbols directly. Skipping the stubs makes annotation processing up to about twice as fast.
Why kapt is slow: kaptGenerateStubs reruns on almost any Kotlin change in the module, and the stubs lose Kotlin-only information such as extension functions, typealiases, internal visibility and default arguments.
KSP: processors see real Kotlin types and can be incremental. Room, Hilt and Dagger, Moshi and Glide all ship KSP processors, and Room can generate Kotlin instead of Java.
Migration: apply the com.google.devtools.ksp plugin, change kapt(...) to ksp(...), and move kapt { arguments { } } to ksp { arg(...) }. Keep the KSP version compatible with your Kotlin version.
All or nothing per module: one remaining kapt processor (data binding still needs kapt) brings the stub step back for the whole module, and most of the gain is lost.
Status: kapt is in maintenance mode. annotationProcessor is the plain Java configuration, used in modules without Kotlin.
When two paths request different versions of the same module, Gradle picks the highest one, unlike Maven's nearest-wins rule. "Duplicate class" is a different problem: two different modules contain the same class, so there is no version conflict for Gradle to resolve.
Diagnose first: ./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency okhttp shows who requested which version and why one won; dependencies prints the whole tree, and a build scan shows the same in a browser.
The classic duplicate: kotlin-stdlib and an old kotlin-stdlib-jdk8. Since Kotlin 1.8 those classes live in kotlin-stdlib, so an old transitive -jdk8 duplicates them. Aligning everything on one Kotlin version, for example with kotlin-bom, fixes it.
Align families: platform(libs.okhttp.bom) keeps okhttp, logging-interceptor and mockwebserver on one version.
Downgrade on purpose: strictly("3.9.1") with because(...) in a constraints block. force also works but hides who wanted what.
**exclude: for a vendor SDK that drags in its own copy; a last resort, because you now own the compatibility.
failOnVersionConflict()**: turns every silent upgrade into a build error, for teams that want each decision explicit.
A toolchain pins the JDK that compiles your code, whatever JDK happens to run Gradle. The error means Java (compileOptions) and Kotlin (jvmTarget) in one module target different bytecode versions; set both to the same value, ideally once in a convention plugin.
The toolchain: kotlin { jvmToolchain(17) } compiles with JDK 17 even if JAVA_HOME points to 21; the foojay resolver plugin in settings.gradle.kts downloads a missing JDK. The JDK that runs Gradle itself is a separate setting (Gradle 9 needs 17 or newer).
The error: Kotlin defaults its jvmTarget to the toolchain's version, while compileOptions may still say 11. Kotlin refuses to mix them, because the module would contain classes for two different JVM levels.
What the target means on Android: the bytecode level D8 accepts, not the device API level; that is minSdk. D8 desugars Java language features for old devices; Java library APIs such as java.time on old devices also need isCoreLibraryDesugaringEnabled = true.
One place: set it in a convention plugin, so forty modules cannot drift apart.
buildSrc is on the classpath of every build script, so any change to it makes every compiled script stale and forces full reconfiguration. An included build such as build-logic publishes plugins by id, and only modules that apply a changed plugin are affected.
buildSrc
Gradle builds buildSrc before anything else and puts its output on the classpath of every build.gradle.kts. No includeBuild line is needed, and that is why every edit is felt everywhere.
Modern Gradle treats it much like an included build (it uses the build and configuration caches), but the classpath relationship is unchanged.
It is one project with one classpath: an Android convention plugin and a small CI helper share dependencies and share invalidation.
An included build
pluginManagement { includeBuild("build-logic") } makes build-logic a separate build with its own settings, dependencies and tests. It can be split into several projects.
A convention plugin is a class implementing Plugin<Project>, or a precompiled script such as myapp.android.library.gradle.kts, whose file name becomes the plugin id.
Declare AGP and the Kotlin plugin as compileOnly there: build-logic compiles against them, and the copy that runs is the one the main build resolves. implementation can put two AGP versions on one classpath.
A plugin applied by every module still reconfigures every module when it changes; the gain comes from splitting build-logic into several projects and from unrelated helpers no longer invalidating everything.
The goal
A feature module's build file becomes plugins { id("myapp.android.feature") } plus a dependencies block, with SDK levels, Compose setup and test wiring stated once. Move to build-logic as soon as build logic grows beyond a couple of files.
The task declares no inputs or outputs, so Gradle cannot ever consider it up to date, and its action reaches for project at execution time, which the configuration cache does not allow. A typed task class with declared inputs and outputs fixes both.
Why it always reruns
Up-to-date checks compare declared inputs and outputs with the previous run. A task with no declared outputs is never up to date, and the build cache cannot store it either.
API_URL is read inside the action, so even with outputs declared Gradle would not know the result depends on it, and a changed value would be ignored.
Why the configuration cache rejects it
The cache stores the task graph at the end of configuration and runs actions against that snapshot. At execution time there is no Project, so project.layout inside doLast is reported as a problem.
Reading the environment variable through providers.environmentVariable also registers it as a configuration input, so a new value invalidates the cache entry instead of being silently reused.
The fix
An abstract task class with @get:Input for values, @get:InputFile or @get:InputFiles for files, and @get:OutputDirectory for results, using Property, RegularFileProperty and DirectoryProperty.
@CacheableTask lets other checkouts and CI reuse the output; add @PathSensitive(PathSensitivity.RELATIVE) once the task has file inputs.
Wire the output into the variant with addGeneratedSourceDirectory. The task dependency comes with it, so no dependsOn is needed.
The configuration cache stores the finished task graph and skips the whole configuration phase; the build cache stores task outputs keyed by task inputs and skips the tasks themselves. A no-op build that still takes 40 seconds is configuration time, and only the configuration cache removes it.
What each one stores
Configuration cache (org.gradle.configuration-cache=true) stores the task graph with every task's fields, keyed by the build scripts, the requested tasks and everything configuration read: environment variables, system properties, files. A hit goes straight to execution. Gradle 9 makes it the recommended mode.
Build cache (org.gradle.caching=true) stores task outputs keyed by a hash of the task's inputs and implementation. A hit unpacks the outputs instead of running the task; with a remote node, your laptop reuses what CI compiled.
They are independent: the build cache pays off on a fresh checkout or branch switch, the configuration cache on the twentieth run of the same command.
What breaks the configuration cache
A task action using Task.project or other live build objects; capture values into Property fields at configuration time.
Starting a process during configuration (ProcessBuilder, Runtime.exec); use providers.exec. Files read at configuration time are tracked as inputs, so changing them invalidates the entry.
A plugin that is not compatible yet. The HTML report under build/reports/configuration-cache names the plugin and the line; org.gradle.configuration-cache.problems=warn lets you migrate step by step.
Why the remote build cache misses
Absolute paths in inputs: file inputs without @PathSensitive(PathSensitivity.RELATIVE) hash differently in every checkout.
A different JDK on CI and on laptops; the toolchain is an input of every compile task.
Non-deterministic outputs, such as a timestamp in a generated file, which change every downstream key.
Tasks that are not cacheable at all. Compare two builds in a build scan, or run with -Dorg.gradle.caching.debug=true to see each input's hash.
Usual setup: CI pushes to the remote cache, developers only pull.
Measure before changing anything: a build scan or Android Studio's Build Analyzer on a realistic incremental build shows whether time goes to configuration, to a few slow tasks, or to a long chain of modules waiting on each other. Each has a different fix.
Measure
Time the build developers actually run: change one line in a core module and run :app:assembleDebug. A clean build hides most problems.
--scan shows configuration time per plugin, the task timeline, cache hits and misses; Gradle Profiler repeats a scenario to compare changes reliably.
Cheap wins
gradle.properties: org.gradle.caching, org.gradle.configuration-cache, org.gradle.parallel, and enough daemon heap (org.gradle.jvmargs) so the build is not stuck in garbage collection.
kapt to KSP; current versions of Gradle, AGP and Kotlin, which usually ship build speed fixes.
No R8 in debug builds, and only the buildFeatures a module uses.
No dynamic versions: 1.+ forces network checks and changes cache keys.
Lazy task APIs (register, named) and no work at the top level of build scripts.
The module graph
Parallel execution helps only where modules do not depend on each other. A deep chain (:feature → :domain → :data → :core) is a critical path that nothing parallelizes.
api used where implementation would do, and one large :core module that everyone uses, make a one-line change recompile most modules.
Small, stable API modules between features keep the graph wide and shallow, so more modules compile at the same time and fewer recompile.
It pays off when many modules use a service whose implementation changes often: consumers compile against a small, stable :api module, so editing the implementation recompiles only :impl and :app. For small or rarely changed modules it only doubles the module count.
What the split buys
Smaller recompiles: features depend on :core:cart:api (interfaces and models). A change inside :core:cart:impl does not change the API module's ABI, so no feature recompiles.
Parallelism: features no longer wait for the heavy implementation (Retrofit, Room, code generation) to compile; they only need the small API module.
Enforced boundaries: features cannot reach Room DAOs or DTOs because those are not on their classpath. The compiler enforces what code review used to.
Tests and swapping: features test against fakes of the interface, and replacing the implementation is a one-line change in :app.
When it is ceremony
The interface changes as often as the implementation, so consumers recompile anyway.
Only one module uses the service.
The module is small and stable; the extra module adds configuration time, IDE sync time and boilerplate (interface, DI binding) for no measurable gain.
Someone adds :impl as a dependency of a feature "just for one class", which cancels the benefit. A module graph check in CI catches it.
How it fits DI
Only :app depends on every :impl; Hilt modules with @Binds live inside :impl and are collected when :app is compiled. Measure with a build scan before and after: the result should be fewer recompiled modules on a typical change.
Ban moving versions, lock the resolved graph and verify artifact checksums. Then a dependency change can only arrive as a reviewed pull request with a lockfile diff, never as a side effect of building on a different day.
Stop moving targets
Dynamic versions (2.+, latest.release) and -SNAPSHOT resolve to different artifacts over time. A range written into libs.versions.toml is just as dangerous.
resolutionStrategy.failOnNonReproducibleResolution() fails the build on any of them.
Fixed versions are not enough: a third-party POM can declare a range or + for its own dependency, and a republished artifact keeps its version. The resolved graph then differs between days, which only a lockfile catches.
Lock and verify
Dependency locking: dependencyLocking { lockAllConfigurations() } and ./gradlew dependencies --write-locks write a gradle.lockfile with the exact resolved graph, transitive dependencies included. A different result fails the build until the lockfile is regenerated in a pull request.
Verification metadata: ./gradlew --write-verification-metadata sha256 help creates gradle/verification-metadata.xml; Gradle then rejects any artifact whose checksum changed. That is the supply-chain half of the problem.
Repository filtering: exclusiveContent or content { includeGroup(...) } makes internal groups resolve only from the internal repository, so a public package with the same name cannot replace them.
Keep it reviewable
Renovate or Dependabot open the version bumps; the lockfile and checksum diff is what reviewers check.
Pin the toolchain too: the Gradle wrapper with distributionSha256Sum, the JDK through a toolchain, and the AGP and Kotlin versions in the catalog.
Beyond applying maven-publish: publish an explicit variant so consumers get the right artifact and metadata, ship consumer R8 rules inside the AAR, and guard the public API so you notice a breaking change before your consumers do.
Publishing the variant
AGP creates no publishable component until you ask: android { publishing { singleVariant("release") { withSourcesJar() } } }, then a MavenPublication with from(components["release"]).
Gradle Module Metadata (the .module file next to the POM) carries variant attributes such as build type and flavors, which a POM cannot express.
Version it with semantic versioning and never republish the same version; consumers and caches assume a version is immutable.
What travels inside the AAR
consumerProguardFiles("consumer-rules.pro") packages your keep rules, and the consumer's R8 applies them automatically. Without it, consumers discover your reflection only in their release builds.
Ship only rules your code needs. A -dontobfuscate or a broad -keep class ** in a library ruins the size of every app that uses it.
Your manifest merges into theirs: permissions, <provider> entries and <queries> are part of your public API.
Not breaking consumers
The binary compatibility validator commits a .api file; apiCheck fails a pull request that changes a public signature. Kotlin 2.2 added a similar experimental abiValidation block to the Kotlin Gradle plugin.
Mark everything you did not mean to expose internal. Adding a parameter with a default value is source-compatible but not binary-compatible: already compiled consumers call a method that no longer exists.
Keep transitive dependencies on implementation unless their types are in your API, so consumers do not inherit your libraries' versions on their compile classpath.
The arrow is reversed: the dynamic feature depends on :app, not the other way round, so the base cannot import any feature class. It installs the module through SplitInstallManager and then opens the screen by class name or an intent.
In the build
:app lists dynamicFeatures += setOf(":feature:scan"); the feature applies com.android.dynamic-feature and depends on project(":app").
A dynamic feature has no build types or flavors of its own; it inherits the base's variants, signing and versioning. R8 runs once across the base and its features.
Delivery is set in the feature's manifest: <dist:on-demand/>, or install-time with conditions such as minimum SDK or country.
Release delivery goes through an app bundle (bundleRelease); Android Studio deploys the splits for you in development, and bundletool installs a release bundle locally.
At runtime
SplitInstallManager from com.google.android.play:feature-delivery requests the module. It is a UI flow, not a function call: handle downloading, REQUIRES_USER_CONFIRMATION for large modules, INSTALLED and failures.
Call SplitCompat.installActivity in the feature's activities (or use SplitCompatApplication), otherwise the new module's resources are not visible until the app restarts.
Code shared between base and feature goes through interfaces defined in the base, implemented in the feature and found by reflection or ServiceLoader.
The testing trap
Android Studio installs all splits during development, so an on-demand request succeeds instantly and you never see the download, the confirmation or a failure. Test those with FakeSplitInstallManager and through an internal test track on Play.