Product Flavors and Build Variants

AndroidGradleBuildArchitecture
Share on LinkedIn

Product flavors are Gradle's answer to "we need three versions of this app" — free and pro, white-label clients, staging and production endpoints — without maintaining three separate codebases. Done well, flavors isolate the 5% that differs and share the 95% that doesn't. Done badly, you get four copies of every ViewModel with slightly different API URLs and a CI matrix that takes an hour. I've refactored flavor setups that had grown to 16 variants (2 tiers × 2 regions × 2 environments × 2 build types) down to 4 by moving environment config to runtime and region to resource overlays. The goal is the minimum number of variants that satisfy actual product requirements.

Build types vs flavors vs variants

Build Type (debug/release)  ×  Product Flavor (free/pro)  =  Build Variant
         release              ×         pro                =    proRelease
         debug                ×         free               =    freeDebug

Build types control compilation behavior. Flavors control product behavior. Variants are what you build, test, and ship.

Basic flavor setup

android {
    flavorDimensions += "tier"

    productFlavors {
        create("free") {
            dimension = "tier"
            applicationIdSuffix = ".free"
            versionNameSuffix = "-free"
            buildConfigField("Boolean", "PREMIUM", "false")
            buildConfigField("String", "API_BASE", "\"https://api.example.com/v1/\"")
        }
        create("pro") {
            dimension = "tier"
            buildConfigField("Boolean", "PREMIUM", "true")
            buildConfigField("String", "API_BASE", "\"https://api.example.com/v1/\"")
        }
    }
}

Access in code:

if (BuildConfig.PREMIUM) {
    showPremiumFeatures()
}

Source set organization

src/
├── main/           # Shared code and resources
├── free/
│   ├── java/       # Free-only implementations
│   └── res/        # Free branding
├── pro/
│   ├── java/
│   └── res/
├── release/        # Release-only (ProGuard rules, etc.)
└── debug/          # Debug-only (leak canary, strict mode)

Gradle merges source sets: proRelease gets main + pro + release. Files in flavor source sets override main.

Use this pattern for flavor-specific implementations:

// main/BillingManager.kt — interface
interface BillingManager {
    fun purchase(sku: String)
}

// free/BillingManagerImpl.kt
class BillingManagerImpl : BillingManager {
    override fun purchase(sku: String) { /* show upgrade prompt */ }
}

// pro/BillingManagerImpl.kt
class BillingManagerImpl : BillingManager {
    override fun purchase(sku: String) { /* real Play Billing */ }
}

Multiple dimensions (use sparingly)

flavorDimensions += listOf("tier", "region")

productFlavors {
    create("free") { dimension = "tier" }
    create("pro") { dimension = "tier" }
    create("us") { dimension = "region" }
    create("eu") { dimension = "region" }
}
// Variants: freeUsRelease, proEuDebug, etc. — 2×2×2 = 8 variants

Before adding a second dimension, ask: can region be a resource overlay or runtime locale instead? Can environment be a build config field toggled by CI, not a flavor?

Flavor-specific dependencies

dependencies {
    "proImplementation"(libs.play.billing)
    "freeImplementation"(libs.admob)
}

Only the pro flavor gets Play Billing; only free gets ads. Keeps APK size down and avoids shipping ad SDKs to paying users.

Filtering variants in CI

Don't build all variants in CI if you only ship two:

androidComponents {
    beforeVariants { variantBuilder ->
        if (variantBuilder.flavorName == "free" && variantBuilder.buildType == "release") {
            variantBuilder.enable = false  // don't build freeRelease
        }
    }
}

Or use variant filtering in CI scripts to test proDebug + proRelease only.

Common mistakes

Environment as flavor. dev, staging, prod flavors multiply your matrix. Use build config fields injected by CI:

buildConfigField("String", "API_BASE", "\"${project.findProperty("apiBase") ?: "https://staging.api.com"}\"")

Duplicating entire modules per flavor. If more than 20% of code differs, consider separate modules with flavor-specific dependencies, not copy-paste source sets.

Hardcoded flavor checks everywhere. Centralize:

object AppConfig {
    val isPremium get() = BuildConfig.PREMIUM
    val apiBase get() = BuildConfig.API_BASE
}

One place to read flavor config; the rest of the app uses AppConfig.

For larger apps, combine flavors with modularization — flavor-specific feature modules that compile only into the variants that need them.

Product flavor dimensions

Organize flavors by meaningful product dimensions:

// build.gradle.kts
android {
    flavorDimensions += listOf("tier", "market")

    productFlavors {
        create("free") {
            dimension = "tier"
            applicationIdSuffix = ".free"
            buildConfigField("Boolean", "PREMIUM", "false")
        }
        create("pro") {
            dimension = "tier"
            buildConfigField("Boolean", "PREMIUM", "true")
        }
        create("global") {
            dimension = "market"
            buildConfigField("String", "DEFAULT_LOCALE", "\"en\"")
        }
        create("eu") {
            dimension = "market"
            buildConfigField("String", "DEFAULT_LOCALE", "\"en\"")
            buildConfigField("Boolean", "GDPR_MODE", "true")
        }
    }
}
// Variants: freeGlobalDebug, proEuRelease, etc.

Two dimensions produce 4 flavor combinations per build type. Keep dimensions independent — tier and market shouldn't overlap.

Source set hierarchy

Flavor-specific code overrides main source set:

src/
├── main/           ← shared by all variants
├── free/           ← free tier only
├── pro/            ← pro tier only
├── eu/             ← EU market only
└── proEu/          ← pro + EU combination only
// src/pro/java/com/example/BillingManager.kt
class BillingManager {
    fun purchasePremium() { /* real billing */ }
}

// src/free/java/com/example/BillingManager.kt
class BillingManager {
    fun purchasePremium() { /* show upgrade prompt */ }
}

Same class name, different implementation per flavor — no runtime if/else checks scattered through codebase.

CI variant matrix

Test critical variants in CI without building all combinations:

# .github/workflows/android.yml
strategy:
  matrix:
    variant: [freeGlobalDebug, proGlobalDebug, proEuRelease]
steps:
  - run: ./gradlew assemble${{ matrix.variant }}
  - run: ./gradlew test${{ matrix.variant }}UnitTest

Test: one debug per tier, one release for primary market. Skip exotic combinations unless they have unique code paths.

Failure modes

Production checklist

Resources

Frequently asked questions

What is the difference between a build type and a product flavor?

Build types (debug, release) control how the app is built — minification, debuggability, signing. Product flavors (free, pro, enterprise) control what the app contains — features, branding, API endpoints. A build variant is the combination of one build type and one flavor (e.g., proRelease, freeDebug).

How many flavor dimensions should an app have?

Use one dimension for most apps (tier: free/pro). Add a second dimension only when flavors vary on independent axes (e.g., tier × region). More than two dimensions creates a combinatorial explosion of variants that's hard to test and maintain. If you have 12+ variants, consider dynamic feature modules or runtime configuration instead.

How do I share code between flavors?

Put shared code in main/. Put flavor-specific code in src/<flavorName>/. Use flavor-specific source sets only for code that genuinely differs — API keys via BuildConfig, branding via resources, feature flags via flavor-specific modules. Avoid duplicating entire classes per flavor; use interfaces in main with flavor-specific implementations.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →