Brief
The what: A quick snapshot of the context
I spent the last 9 months making our monolithic legacy app more flexible.
The last thing blocking the multi-module and multi-variant project was our network layer.
I thought it would be an easy two-day task.
But then I saw this…
@Provides
@Singleton
fun provideOkHttpClient(
environment: EnvironmentRepository,
): OkHttpClient {
val builder = OkHttpClient.Builder()
if (environment.isDebugBuild()) {
builder.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
},
)
} else {
builder.certificatePinner(
CertificatePinner.Builder()
.add("api.yourapp.com", "sha256/...")
.build(),
)
}
return builder.build()
}Looks like nothing suspicious at first glance.
Add a logging interceptor for the debug build, and certificate pinning for the prod build.
So we can see API requests and proxy them during development, and prevent fraud in the production app.
But when I dived deeper, the whole network layer setup fell apart…

🐞 The Issue: Runtime logic decides what to include in the build variant


While 9 of 10 devs struggle to integrate AI, I generate 90% of my code while drinking tea and clicking approve on MRs.
How?
I don't invent secret prompts
I don't switch models for each task
I don't spend time refactoring AI code
I created architecture that holds up in production
I created AI infrastructure that builds on it
I automated code review to avoid hallucinations
So I get the code like it was written by a senior engineer with over 10 years of experience, not random garbage.
You can't say: "Claude, build this feature". It's not gonna work.
The formula is: architecture AI can't break + documentation AI can read + skills AI can follow.
Then you can finish building the feature while your tea is still hot.
Want this workflow in your codebase?

Breakdown
The when: Reverse engineering the cause and the cost
In this issue we’ll talk more about network layer setup issues, but it doesn’t mean this is the only one.
Some of the most common:
selecting environment-based URLs
providing debug payment methods
overriding user locale for QA purposes
hidden debug menus to test complex scenarios
But your app, and any other (no exceptions), has dozens or even hundreds of issues like this.
R8 will handle this. It won’t
If you build a scalable application, you can’t avoid writing tests.
One of the main challenges during testing is to have control over your dependencies.
Let’s take a simpler case.
fun getUserCountry(): String =
if (BuildConfig.DEBUG) {
"US"
} else {
countryDataSource.getCountry()
}You provided a stubbed user country to simplify testing on the dev environment, but:
you lost control over the environment
you can’t test the prod scenario
your test coverage report will fail (you’ll never reach more than 50% coverage)
So you extract the environment details into a separate repository.
interface EnvironmentRepository {
fun isDebug(): Boolean
}And now your logic looks like this:
fun getUserCountry(): String =
if (environmentRepository.isDebug()) {
"US"
} else {
countryDataSource.getCountry()
}You made the right move in terms of app scale:
you provided a single source of truth
you have control over dependencies
you can test both cases
But the interesting thing comes after this.
You might think: “I have R8 and it will exclude unreachable code”.
It won’t.
It works only on compile-time constants, and the moment you extracted BuildConfig.DEBUG into a separate repository and provided it through the runtime dependency graph, it lost its ability.
→ You improved the architecture, but lost control over what’s included in the build.
→ EnvironmentRepository guarantees the single source of truth. But runtime logic decides what’s included in the build.
Scale requires SRP
Let’s return to our network setup.
Beyond the default debug and prod builds, our app now needs a stage environment in order to process “test payments” during QA.
So we extended our OkHttpClient to include the test payments header.
if (environment.isDebugBuild()) {
builder.addInterceptor(loggingInterceptor)
} else if (environment.isStage()) {
builder.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("X-Debug-Payment-Enabled", "true")
.build()
chain.proceed(request)
}
} else {
builder.certificatePinner(prodPinner)
}Then every time a network requirement related to any environment changes - we need to update the same method over and over.
And no one can guarantee that making changes to stage will not break prod.
→ One environment’s change is every environment’s risk.
Bugs that only users will notice
Now the reverse.
You’re probably not the only developer on the project.
And even so, you’re a human and can’t keep all the edge cases and environment-dependent logic in mind.
When you add or remove a build type or product flavor, you’re not revisiting all 50 places in your project where you have code like this:
if (BuildConfig.DEBUG) {...}Then one of three things happens:
the
elsecondition satisfies the new build type logic - nothing happensyou remembered and updated the environment-related logic - nothing happens
the
elsecondition doesn’t work for the new build variant and you forgot about it - you’ll learn only from a support ticket on prod
→ No compile-time safety. Changing environments won’t make your build or tests fail. You probably won’t even notice.
Because of this one if(isDebug) check:
you broke how R8 works
you lost control over tests
you never pass the test coverage gate
you made project modularization challenging
you violated SRP and made the app harder to scale
you have bugs only users notice
Is it worth it?

✅ The Fix: Split implementations by their build variant source set

Guideline
The how: Implementation guideline and recommendations
🧠 Remember: Source sets are meant for providing separate implementations across build variants, not boolean flags for runtime logic.
Step 1 - Main knows nothing about environments
The only thing the network client needs is to specify its dependencies.
It doesn’t need to know how these dependencies are constructed, or what they require.
// src/main/java/com/yourapp/network/NetworkClientModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkClientModule {
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
environmentRepository: EnvironmentRepository
): Retrofit = Retrofit.Builder()
.baseUrl(environmentRepository.getBaseUrl())
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.build()
}Step 2 - Dependencies are private
Each source set variant implementation might have its own dependencies.
If it’s not something reusable from the main source set, it should be encapsulated in the variant source set as well.
Debug OkHttpClient needs LoggingInterceptor - keep it in the debug source set.
// src/debug/java/com/yourapp/network/InterceptorModule.kt
@Module
@InstallIn(SingletonComponent::class)
object InterceptorModule {
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
}Production OkHttpClient needs CertificatePinner - keep it in the release source set.
// src/release/java/com/yourapp/network/CertificateModule.kt
@Module
@InstallIn(SingletonComponent::class)
object CertificateModule {
@Provides
@Singleton
fun provideCertificatePinner(): CertificatePinner =
CertificatePinner.Builder()
.add("api.yourapp.com", "sha256/...")
.build()
}Step 3 - Provide clients as per build variant
For debug build variant
// src/debug/java/com/yourapp/network/OkHttpModule.kt
@Module
@InstallIn(SingletonComponent::class)
object OkHttpModule {
@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
}For release build variant
// src/release/java/com/yourapp/network/OkHttpModule.kt
@Module
@InstallIn(SingletonComponent::class)
object OkHttpModule {
@Provides
@Singleton
fun provideOkHttpClient(
certificate: CertificatePinner
): OkHttpClient = OkHttpClient.Builder()
.certificatePinner(certificate)
.build()
}What’s included in the build will be resolved automatically by the build variant and the DI framework.
If a build-variant-specific implementation is missing - you’ll get a compile-time error, so you never ship bugs on prod.
🧠 Pro tip: if you have more than two build variants, and only one requires an override, keep the common implementation in a shared source set and the build-specific implementation in its own build variant source set.

Whenever you're ready, here’s how I can help you:
The exact system I used to build 11 apps, with 800+ screens, for 34M users. 10 years of production decisions and the reasoning behind every line, so your architecture survives any change without refactor, and AI builds on it correctly.