Build variants and flavors: patterns, pitfalls and fixes
Most production apps live in multiple environments—development, staging, production—and often ship under different brands or to different regional markets. Android's build variants and flavors give you a clean, declarative way to manage one codebase that compiles into many apps. But misconfigure them and you'll spend weeks debugging why your API calls are hitting the wrong server or why your app IDs collide on the same device.
We'll walk through the patterns that actually work in teams, the pitfalls we've all hit, and the fixes that save you from late-night firefighting.
Why flavors matter: the production case
Consider a typical SaaS backend with three environments. Development talks to localhost or a dev API; staging runs against real infrastructure with test data; production is the live API. Your Android team needs to test all three without maintaining three separate codebases or manually editing build files before each release.
Or you're building white-label apps: the same core logic ships under Brand A, Brand B, and Brand C with different colors, API keys, and Firebase projects. Without flavors, you merge feature branches three ways and risk inconsistent behavior across variants.
Flavors solve this by letting you define separate resource directories, Java source sets, and build-time configuration per variant. The build system merges and overrides intelligently, so your main code stays clean and your variant-specific logic lives in isolated directories.
Flavors for environments or brands
The first decision is how to structure your flavors. Two common patterns:
- Environment flavors:
dev,staging,prod. Each points to a different API endpoint. Good for testing the same app through its lifecycle. - Brand flavors:
brandA,brandB,brandC. Each has distinct UI, colors, and backend. Good for white-label or multi-tenant strategies.
You can also combine them with flavor dimensions, but we'll touch on that later. For now, let's assume a single dimension: environment.
In your module's build.gradle.kts:
android {
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
}
create("staging") {
dimension = "environment"
}
create("prod") {
dimension = "environment"
}
}
}Now you have three build variants for each build type (debug, release): devDebug, devRelease, stagingDebug, stagingRelease, and so on. The Android Studio build variant selector shows all combinations.
Different app IDs and API URLs
The next step is making each flavor distinct. App ID (package name) must be unique on a device. API URLs must point to the right server. This is where buildConfigField and resValue come in, but the cleaner modern pattern is BuildConfig constants.
In build.gradle.kts:
android {
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
applicationIdSuffix = ".dev"
buildConfigField("String", "API_BASE_URL", "\"http://localhost:8000/\"")
buildConfigField("String", "LOG_LEVEL", "\"DEBUG\"")
}
create("staging") {
dimension = "environment"
applicationIdSuffix = ".staging"
buildConfigField("String", "API_BASE_URL", "\"https://api-staging.example.com/\"")
buildConfigField("String", "LOG_LEVEL", "\"INFO\"")
}
create("prod") {
dimension = "environment"
applicationIdSuffix = "" // no suffix for production
buildConfigField("String", "API_BASE_URL", "\"https://api.example.com/\"")
buildConfigField("String", "LOG_LEVEL", "\"WARN\"")
}
}
}With this setup, you install all three variants on the same emulator:
- com.example.myapp.dev
- com.example.myapp.staging
- com.example.myapp (production)
In your code, you reference these constants at compile time:
class ApiClient {
private val baseUrl = BuildConfig.API_BASE_URL
fun getUser(id: String): Call {
return service.getUser("$baseUrl/users/$id")
}
}No runtime branching, no environment files to forget. The build system inlines the correct value during compilation.
BuildConfig fields per variant
Beyond URLs, you'll often need per-variant secrets and configuration:
- Firebase project IDs (staging project vs. production project)
- API keys (dev key vs. prod key)
- Feature flags (enable_new_checkout in staging only)
- Log levels and crash reporting endpoints
Add them as buildConfigField entries. Keep them strongly typed:
productFlavors {
create("dev") {
// ...
buildConfigField("String", "FIREBASE_PROJECT_ID", "\"dev-project-123\"")
buildConfigField("String", "FIREBASE_APP_ID", "\"1:123456789:android:abc123def456\"")
buildConfigField("Boolean", "ENABLE_CHECKOUT_V2", "true")
buildConfigField("String", "CRASH_REPORTING_URL", "\"https://sentry.dev.example.com/\"")
}
create("prod") {
// ...
buildConfigField("String", "FIREBASE_PROJECT_ID", "\"prod-project-456\"")
buildConfigField("String", "FIREBASE_APP_ID", "\"1:987654321:android:xyz789uvw012\"")
buildConfigField("Boolean", "ENABLE_CHECKOUT_V2", "false")
buildConfigField("String", "CRASH_REPORTING_URL", "\"https://sentry.example.com/\"")
}
}Then reference them where you initialize third-party SDKs:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Firebase
Firebase.initialize(this, BuildConfig.FIREBASE_PROJECT_ID)
// Crash reporting
Sentry.init(this) {
it.dsn = BuildConfig.CRASH_REPORTING_URL
}
// Feature gate
if (BuildConfig.ENABLE_CHECKOUT_V2) {
enableCheckoutV2()
}
}
}This pattern keeps initialization code clean and declarative. No if-statements checking the build variant; the build system decides what runs.
Keep variant logic minimal
Here's the mistake we see most often: developers add runtime checks throughout the codebase:
// Antipattern
if (BuildConfig.FLAVOR == "dev") {
useDevApi()
} else if (BuildConfig.FLAVOR == "staging") {
useStagingApi()
} else {
useProdApi()
}This defeats the purpose of flavors. You're mixing concerns, testing becomes messy, and the code is harder to follow. Instead, let the build system decide everything at compile time.
Keep variant-specific code in separate source directories. Use the src/ layout:
src/
main/
kotlin/
com/example/myapp/
ApiClient.kt (main implementation)
dev/
kotlin/
com/example/myapp/
DevConfig.kt (dev-only helpers if needed)
staging/
prod/If you absolutely need runtime branching—say, a debug overlay that only shows in dev builds—check BuildConfig.DEBUG or a dev-only flag, not the flavor name:
if (BuildConfig.DEBUG && BuildConfig.ENABLE_DEBUG_OVERLAY) {
DebugOverlay.show()
}This is clearer and scales better. The build variant is a deployment concern, not a business logic concern.
Flavor dimensions and multi-dimensional builds
As your app grows, you might need environment and brand flavors simultaneously. Use flavor dimensions:
android {
flavorDimensions += listOf("environment", "brand")
productFlavors {
create("dev") {
dimension = "environment"
buildConfigField("String", "API_BASE_URL", "\"http://localhost:8000/\"")
}
create("prod") {
dimension = "environment"
buildConfigField("String", "API_BASE_URL", "\"https://api.example.com/\"")
}
create("brandA") {
dimension = "brand"
applicationIdSuffix = ".brandA"
resValue("string", "app_name", "Brand A")
}
create("brandB") {
dimension = "brand"
applicationIdSuffix = ".brandB"
resValue("string", "app_name", "Brand B")
}
}
}Now you can build devBrandADebug, prodBrandBRelease, and so on. The dimensions are independent, and settings stack. This gets complex fast, so only add dimensions if you truly need them.
Common mistakes
Forgetting to sync after editing build.gradle.kts
You add a new flavor or buildConfigField and Android Studio doesn't regenerate BuildConfig. Click "Sync Now" in the notification bar, or manually run ./gradlew clean build. Until you sync, your IDE won't recognize the new constant.
Hardcoding URLs or keys in the main source set
The main code has a fallback URL that overrides your BuildConfig value at runtime. Now your dev device is hitting production by accident. Keep all environment-specific values in buildConfigField or resource overrides, nowhere else.
Using BuildConfig.FLAVOR for business logic
This makes the code fragile and testable only in one variant at a time. Instead, use buildConfigField to define feature flags and settings, then check those. Your code becomes variant-agnostic and your tests run everywhere.
Not including the variant in release notes or crash reports
A crash comes in from production, but you don't know if it's from the dev or prod variant. Add a build-time constant for the variant name and log it in your crash reporting tool:
buildConfigField("String", "BUILD_VARIANT", "\"dev\"")
// In crash reporting
Sentry.init {
it.setTag("build_variant", BuildConfig.BUILD_VARIANT)
}Applying the same variant logic to multiple modules
You define flavors in the app module but your data and network modules also define the same flavors. Gradle complains about conflicting flavor dimensions. Instead, define flavors only in the app module and pass configuration via dependency injection or constants.
Key takeaways
- Use flavor dimensions to structure your variants by concern (environment, brand, region). Keep it simple; avoid three+ dimensions.
- Set the app ID suffix per flavor so you can install multiple variants side by side. Add a marker suffix like
.stagingto all non-production variants. - Define all per-variant configuration in buildConfigField or resValue. Let the build system inject URLs, API keys, feature flags, and project IDs at compile time.
- Avoid runtime flavor checks. Check feature flags and debug settings instead. Your code should not care which variant it is.
- Test your variants. Build all three (dev, staging, prod), install them, and verify each one hits the right API and shows the right branding.
- Document your flavor structure. Future maintainers need to know which flavor to use for which scenario, and how to add a new one.






Comments
Loading comments…