A mature Android application can contain hundreds of thousands of lines of Java, serve paying customers, and pass through years of production releases.
Then a new architectural proposal arrives: “We should rewrite it in Kotlin.”
Sometimes that proposal addresses a real problem. Sometimes it substitutes a language change for an architectural diagnosis.
Java remains relevant to Android. Kotlin is usually the stronger default for new Android application code. Both statements can be true.
For senior developers and engineering managers, the useful question is which parts of the system benefit from changing language—and which parts deserve stability.
This article examines Java’s current role, Kotlin interoperability, JNI boundaries, SDK design, and incremental migration without confusing modernization with a rewrite.
Java’s Legacy Is an Architectural Reality
Java was central to Android’s early application ecosystem. Framework APIs, application code, libraries, and development practices grew around it.
That history still appears in:
- Enterprise applications with long support windows.
- Vendor SDKs and device integrations.
- Java-oriented application frameworks.
- Shared JVM libraries.
- Business rules that have accumulated extensive production validation.
- Native-library wrappers exposed through JNI.
The “Java is dead” argument collapses several separate decisions into one:
- Which language should new application features use?
- Which existing components should remain unchanged?
- Which public API should a library expose?
- Which technology should implement computationally expensive work?
Those decisions do not need the same answer.
Existing Java code is not automatically technical debt. Technical debt comes from properties such as poor boundaries, unsafe assumptions, missing tests, and costly change.
A Kotlin rewrite can preserve all of those problems.
The Current Android Direction: Kotlin First, Java Compatible
Google’s Android guidance emphasizes Kotlin, and Jetpack Compose is built around Kotlin.
That makes Kotlin a practical default for new application development, particularly when teams are adopting Compose, coroutine-based APIs, and Kotlin-oriented libraries.
Java still works in Android projects. A Kotlin-first ecosystem does not require immediate conversion of every Java module.
Java Versus Kotlin: What Actually Changes?
| Concern | Java | Kotlin | Architectural implication |
|---|---|---|---|
| Nullability | References can be null; annotations and tooling improve checking | Nullable and non-null types are explicit | Boundaries still require validation |
| Asynchronous work | Executors, callbacks, futures, reactive libraries | Coroutines and Flow | Cancellation and lifecycle behavior need deliberate design |
| Data models | Often require constructors, accessors, and value methods | Data classes generate common value behavior | Generated semantics must match the domain |
| Extensions | Usually utility methods or wrappers | Extension functions and properties | Extensions do not modify the underlying class |
| UI development | Established View-based workflows | Views plus idiomatic Compose development | UI modernization can coexist with Java business logic |
| Public library APIs | Familiar JVM-facing surface | Requires attention to Java call sites | Consumer compatibility matters more than source brevity |
Null Safety Is Stronger, Not Absolute
Kotlin expresses nullability directly:
val displayName: String? = profile.nickname
val label: String = displayName ?: "Anonymous"
However, Kotlin can still fail with null-related errors through:
- Unsafe assertions using
!!. - Unannotated Java APIs.
- Initialization mistakes.
- Reflection and serialization boundaries.
- External values that violate declared expectations.
Java APIs without recognized nullability information can appear as platform types in Kotlin. The compiler cannot provide the same guarantees it provides for well-defined Kotlin types.
Annotate boundaries and normalize external data. A compiler feature does not replace a data contract.
Coroutines Improve Composition, Not Automatically Execution
Coroutines support readable asynchronous code and structured concurrency.
They do not make blocking work non-blocking by themselves:
suspend fun loadProfile(): Profile {
return blockingDatabaseCall()
}
The function is suspendable, but the database call still blocks its executing thread.
Use appropriate dispatchers for blocking work, prefer genuinely asynchronous APIs when available, and propagate cancellation.
Java can also implement reliable asynchronous systems. The trade-off is often additional coordination code, especially when several operations must share cancellation and lifecycle ownership.
Boilerplate Can Be Removed Without Improving Design
A Kotlin data class can replace many lines of Java. That is valuable, but it does not establish a better domain model.
Likewise, a concise extension function can hide an expensive operation.
Review behavior, dependencies, and ownership—not just line count.
When Java Remains the Better Engineering Choice
Maintaining Stable Enterprise Components
Consider a Java pricing engine with extensive tests and few recent defects.
Converting it solely for consistency may consume time without improving customer outcomes. It may also introduce changes to equality, initialization, exception behavior, or serialization.
Retaining Java can be appropriate when:
- The component is stable.
- Its responsibilities are clear.
- Its dependencies are supported.
- The team understands its behavior.
- Migration provides little measurable benefit.
Modernization can focus first on testing, modularity, security, and dependency maintenance.
Preserving Existing Integration Contracts
Some integrations depend on Java-oriented APIs, generated bindings, reflection, or specific binary signatures.
Java may remain useful at those boundaries even when the surrounding application is Kotlin.
Examples include:
- Vendor SDK adapters.
- Established JNI facades.
- Public library interfaces.
- Legacy serialization models.
- Generated source code.
The important property is the contract’s stability. Its implementation language can vary behind that contract.
Supporting Java SDK Consumers
An Android SDK may serve customers with Java applications, Kotlin applications, or both.
A Java-facing API can remain appropriate when customers expect conventional interfaces and predictable JVM signatures.
A Kotlin implementation can expose that API, but library authors must manage:
- Constructors and overloads.
- Static access patterns.
- Checked-exception declarations.
- Generic variance.
- Nullability annotations.
- Binary compatibility.
- Kotlin runtime dependencies.
Java relevance is therefore partly a consumer compatibility question.
Managing Team Capacity and Delivery Risk
A Java-heavy team supporting a mature product may face a learning and tooling cost during migration.
That cost includes:
- Review standards.
- Coroutine cancellation practices.
- Kotlin testing idioms.
- Compiler and plugin configuration.
- Mixed-language debugging.
- API compatibility checks.
This is a reason to plan migration, not a reason to reject Kotlin indefinitely.
When commissioning Java development services, evaluate whether the team can preserve existing behavior while modernizing selected boundaries. A vendor that proposes a complete rewrite before examining the codebase has not yet established the business case.
Java, JNI, and Native Performance: Separate the Concepts
A crucial correction: Java is not the language normally used to implement Android native libraries or direct low-level memory operations.
On Android, JNI connects managed Java or Kotlin code with native code, commonly C or C++.
Java and Kotlin application code ultimately participate in Android’s managed runtime pipeline. Neither language has a universal performance advantage across all workloads.
What Java Can Provide at a JNI Boundary
Java can offer a stable, familiar wrapper:
package com.example.audio;
public final class AudioBridge {
static {
System.loadLibrary("audio_engine");
}
private AudioBridge() {}
public static native int process(float[] samples);
}
Kotlin can call it directly:
val processedSamples = AudioBridge.process(samples)
The performance-sensitive implementation lives in the native library.
Retaining this Java facade may be sensible if its signatures, native registration, and shrinker configuration are already validated. Kotlin could expose an equivalent interface; conversion is not required to use the library.
Optimize the Boundary Before Changing the Language
JNI performance depends on factors such as:
- Transition frequency.
- Data marshalling.
- Array copying or access behavior.
- Allocation patterns.
- Thread attachment.
- Reference lifetime management.
Batch operations where appropriate. Avoid crossing the boundary once per tiny unit of work when a larger operation would preserve correctness.
JNIEnv is thread-specific; do not share it across threads. Manage local and global references deliberately.
Profile Before Making Performance Claims
Measure:
- CPU time.
- Allocation rate.
- Garbage collection.
- Startup.
- Frame timing.
- Memory.
- Battery-sensitive behavior.
Use representative release builds and workloads.
A Java-to-Kotlin conversion does not fix an inefficient algorithm, excessive allocation, or a poorly designed JNI interface.
Also distinguish native Android application development from NDK native code. The first describes platform-specific applications; the second commonly refers to C/C++ components.
Teams comparing broader native app solutions should make that terminology explicit when discussing architecture across platforms.
Practical Example: Equivalent Java and Kotlin Data Models
A fair comparison should preserve semantics.
The following alternatives represent the same immutable user value with:
- A required identifier.
- A nullable display name.
- A fallback label.
- Value equality.
They belong in separate files. Use either JavaUser or KotlinUser as appropriate; both can coexist.
Java Implementation
package com.example.users;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Objects;
public final class JavaUser {
@NonNull
private final String id;
@Nullable
private final String displayName;
public JavaUser(
@NonNull String id,
@Nullable String displayName
) {
this.id = Objects.requireNonNull(id, "id");
this.displayName = displayName;
}
@NonNull
public String getId() {
return id;
}
@Nullable
public String getDisplayName() {
return displayName;
}
@NonNull
public String label() {
return displayName == null ? id : displayName;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof JavaUser)) return false;
JavaUser user = (JavaUser) other;
return id.equals(user.id)
&& Objects.equals(displayName, user.displayName);
}
@Override
public int hashCode() {
return Objects.hash(id, displayName);
}
@Override
public String toString() {
return "JavaUser{id='" + id
+ "', displayName=" + displayName + "}";
}
}
This example assumes AndroidX annotation dependencies and an Android API level that supports the shown Objects methods.
Kotlin Implementation
package com.example.users
data class KotlinUser(
val id: String,
val displayName: String?
) {
fun label(): String = displayName ?: id
}
The Kotlin data class generates equality, hashing, a string representation, destructuring functions, and copy().
The two models provide equivalent domain behavior for this example, but their exact generated string and hash implementations are not identical.
Do not rely on implementation-specific hash values as persistent identifiers.
Calling Java From Kotlin
val user = JavaUser("user_42", null)
val id: String = user.id
val name: String? = user.displayName
println(user.label()) // user_42
Kotlin exposes conventional Java getters through property syntax. Recognized nullability annotations improve the Kotlin-facing contract.
Calling Kotlin From Java
KotlinUser user = new KotlinUser("user_42", null);
String id = user.getId();
String label = user.label();
KotlinUser renamed = user.copy(
user.getId(),
"Amina"
);
Java can call the generated getters and ordinary methods.
However, Java does not gain Kotlin named arguments or default-argument syntax. Here, copy() requires both arguments.
The practical benefit is clear: Kotlin reduces model boilerplate while the code remains accessible from Java.
The limitation is equally clear: source conversion changes API details unless you deliberately preserve them.
Interoperability Works Well—But Is Not Frictionless
Default Arguments
Kotlin default arguments do not automatically create every Java overload.
Use @JvmOverloads selectively when Java consumers need conventional overloads. Adding overloads expands the API surface and may create ambiguity.
Companion Objects and Top-Level Functions
Java access differs from Kotlin access.
-
@JvmStaticcan expose selected companion methods as static methods. - Top-level functions compile into generated file facade classes.
-
@file:JvmNamecan provide a deliberate facade name.
Treat names used by external callers as contracts.
Suspend Functions
A Kotlin suspend function does not become a convenient ordinary asynchronous Java method.
For Java consumers, provide an intentional adapter using callbacks, futures where supported, or another established asynchronous abstraction.
Define who owns cancellation and which thread invokes completion.
Collections
Kotlin’s read-only collection interfaces do not guarantee deep immutability.
Java callers or shared mutable backing collections can still affect state. Copy or wrap collections when the boundary requires stronger guarantees.
Exceptions
Java checked-exception declarations and Kotlin exception handling differ.
Use @Throws when a Java-facing declaration needs to expose checked exceptions. Confirm that behavior and documentation remain consistent.
Reflection and Serialization
Conversion can change:
- Constructor availability.
- Getter and field exposure.
- Annotation targets.
- Nullability expectations.
- Generated method names.
- Default-value behavior.
Test the actual serializer, ORM, dependency injection framework, and reflection-based consumers.
A Gradual Migration Strategy That Avoids a Rewrite
1. Inventory the Codebase
Identify:
- Frequently changed modules.
- Stable domain components.
- Public APIs.
- Generated code.
- JNI boundaries.
- Reflection-heavy integrations.
- Lifecycle-sensitive asynchronous work.
Map dependencies before choosing a conversion order.
2. Establish a Baseline
Record relevant evidence:
- Critical workflow behavior.
- Crash patterns.
- Build times.
- Startup and responsiveness.
- Test reliability.
- Known compatibility constraints.
For legacy components, characterization tests should capture observable behavior that must survive conversion.
3. Align the Toolchain
Verify compatibility among:
- Android Gradle Plugin.
- Gradle.
- The JDK running the build.
- Java compilation settings.
- Kotlin compiler settings.
- Annotation processors or symbol-processing tools.
The build JDK, source language level, bytecode target, and Android runtime API availability are separate concerns.
Installing a newer JDK does not make every JDK API available on older Android devices. Desugaring supports specified features and libraries, not the entire desktop Java platform.
Avoid copying version numbers from an unrelated project. Check the supported toolchain matrix.
4. Write New Work in Kotlin at Stable Boundaries
A practical policy can be:
- New presentation code: Kotlin.
- New Compose screens: Kotlin.
- Stable Java domain logic: retain initially.
- Public SDK interfaces: preserve compatibility.
- Generated files: manage through their generator.
This introduces Kotlin where its benefits are immediate without converting the whole system.
5. Annotate Java Boundaries
Add reliable nullability information before conversion where possible.
Also document:
- Thread expectations.
- Ownership.
- Cancellation.
- Error behavior.
- Mutability.
Better contracts improve both languages.
6. Convert Small, Testable Components
Use Android Studio’s conversion tooling as a starting point.
Then review:
- Null assertions.
- Initialization.
- Equality.
- Visibility.
- Overloads.
- Exception behavior.
- Serialization.
- JVM signatures.
Keep mechanical conversion separate from major behavioral changes. That makes regressions easier to investigate.
7. Modernize Asynchronous Behavior Separately
Replacing a callback implementation with coroutines can change cancellation, exception propagation, and execution context.
Treat that as a behavioral refactor.
For example, viewModelScope owns work for a ViewModel lifecycle, but durable work that must survive process termination needs a different strategy.
Do not replace every callback with a coroutine without deciding who owns the work.
8. Validate Release Behavior
Check:
- Shrinking and obfuscation.
- JNI registration.
- Serialization.
- Release-only reflection behavior.
- Supported Android versions.
- Java-facing compatibility.
Mixed-language debug builds are necessary evidence, but not sufficient evidence.
9. Measure Results
Evaluate:
- Defect reduction.
- Review clarity.
- Delivery time.
- Build performance.
- Runtime behavior.
- Maintenance effort.
“Percentage converted” is a progress metric, not an outcome metric.
For organizations coordinating Android with cross-platform engineering, also keep shared API contracts and domain terminology consistent across clients.
Interoperability Does Not Mean iOS Portability
Java–Kotlin interoperability on Android is JVM-oriented.
It does not make an existing Java module directly executable in a Kotlin/Native iOS application.
A multiplatform strategy may require:
- Extracting platform-independent logic.
- Replacing JVM-only dependencies.
- Designing platform abstractions.
- Reimplementing native integrations.
- Sharing a C/C++ library through different platform bindings.
JNI is an Android/JVM boundary. iOS integration uses different mechanisms.
When reviewing custom mobile development services, ask exactly what is shared: source code, business logic, API definitions, native libraries, or only product behavior.
Those are different architectures with different costs.
What Engineering Managers Should Require
A useful modernization proposal should identify:
- The problem being solved.
- Why language conversion helps.
- Which components remain unchanged.
- Compatibility obligations.
- Test evidence.
- Rollout and rollback plans.
- Toolchain changes.
- Training needs.
- Success measures.
Be cautious of proposals that equate modernization with complete replacement.
A team capable of maintaining Java, introducing Kotlin, and protecting existing contracts may create more value than a team committed to one language regardless of context.
Conclusion: Java Has a Defined Role in Kotlin-First Android
Java remains relevant for mature applications, library consumers, vendor integrations, and established JNI interfaces.
Kotlin is generally the more practical default for new Android application code because of its language features and alignment with modern Android tooling.
Neither choice removes the need for good architecture.
Preserve stable behavior. Improve weak boundaries. Convert components where the benefits justify the risk. Measure runtime performance rather than assigning it to a language.
For teams running a mixed Java–Kotlin codebase: which migration produced the greatest improvement—and which stable Java component have you deliberately chosen to keep?

Top comments (0)