Migrate from Java
Problem
Migrating a Java codebase to Kotlin requires understanding syntax differences, null safety implications, and idiomatic Kotlin patterns. Automated conversion tools help but produce unidiomatic code. A gradual, strategic migration yields better results.
This guide provides a complete migration strategy with syntax comparisons and best practices.
Migration Strategy
Incremental Migration Approach
Migrate gradually, file by file or module by module.
// ✅ Phase 1: New code in Kotlin
// Write all new features in Kotlin
// Java and Kotlin coexist seamlessly
// ✅ Phase 2: Convert utility classes
// Start with utility/helper classes (no dependencies)
// These are easiest to migrate
// ✅ Phase 3: Convert data classes
// Migrate POJOs to Kotlin data classes
// Massive boilerplate reduction
// ✅ Phase 4: Convert business logic
// Migrate service/business logic classes
// Take advantage of Kotlin features
// ✅ Phase 5: Convert infrastructure
// Migrate framework integration code last
// Ensure stability throughoutStrategy: Bottom-up approach minimizes breaking changes.
Using IntelliJ IDEA Converter
IntelliJ provides automated Java to Kotlin conversion.
// ✅ Steps to convert:
// 1. Open Java file in IntelliJ IDEA
// 2. Code → Convert Java File to Kotlin File (Ctrl+Alt+Shift+K)
// 3. Review generated code
// 4. Refactor to idiomatic Kotlin
// 5. Fix null safety warnings
// 6. Run tests
// ❌ Don't commit converted code as-is
// ✅ Always refactor after automatic conversionImportant: Automatic conversion produces working but unidiomatic code.
Syntax Comparison
Class Declarations
Java verbose class declarations vs Kotlin concise syntax.
Java:
public class User {
private final String id;
private final String name;
private String email;
public User(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public boolean equals(Object o) {
// ... 15 lines of equals implementation
}
@Override
public int hashCode() {
// ... 5 lines of hashCode implementation
}
@Override
public String toString() {
return "User{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
", email='" + email + '\'' +
'}';
}
}Kotlin:
// ✅ Same functionality, 3 lines
data class User(
val id: String,
val name: String,
var email: String
)Reduction: 50+ lines → 5 lines with same functionality.
Null Safety
Java’s nullable references vs Kotlin’s null safety system.
Java:
// ❌ Null safety not enforced
public String getUserEmail(User user) {
if (user != null && user.getProfile() != null) {
return user.getProfile().getEmail();
}
return null;
}
// ❌ NullPointerException waiting to happen
public int getEmailLength(User user) {
return user.getProfile().getEmail().length(); // 💥
}Kotlin:
// ✅ Null safety enforced by compiler
fun getUserEmail(user: User?): String? {
return user?.profile?.email // Safe call chain
}
// ✅ Compiler error prevents NPE
fun getEmailLength(user: User): Int {
return user.profile.email.length // ✅ Guaranteed non-null
}
// ✅ Explicit null handling
fun getEmailLengthSafe(user: User?): Int {
return user?.profile?.email?.length ?: 0 // Elvis operator
}Key difference: Kotlin distinguishes String (never null) from String? (may be null).
Function Syntax
Java methods vs Kotlin functions.
Java:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public String formatResult(int result) {
return "Result: " + result;
}
public void printResult(int result) {
System.out.println(formatResult(result));
}
}Kotlin:
// ✅ Top-level functions (no class required)
fun add(a: Int, b: Int): Int {
return a + b
}
// ✅ Expression body
fun add(a: Int, b: Int) = a + b
// ✅ String templates
fun formatResult(result: Int) = "Result: $result"
// ✅ Unit return type (void equivalent)
fun printResult(result: Int) {
println(formatResult(result))
}Benefits: Functions don’t need classes, expression bodies reduce boilerplate.
Collections
Java’s verbose collection APIs vs Kotlin’s expressive operations.
Java:
// ❌ Verbose filtering and mapping
List<String> names = new ArrayList<>();
for (User user : users) {
if (user.isActive()) {
names.add(user.getName().toUpperCase());
}
}
// ❌ Stream API is better but still verbose
List<String> names = users.stream()
.filter(User::isActive)
.map(user -> user.getName().toUpperCase())
.collect(Collectors.toList());Kotlin:
// ✅ Concise, readable collection operations
val names = users
.filter { it.isActive }
.map { it.name.uppercase() }
// ✅ Even more concise
val names = users.filter { it.isActive }.map { it.name.uppercase() }Improvement: Kotlin collections are more expressive and concise.
Null Safety Migration
Converting Nullable Types
Map Java’s potential nulls to Kotlin nullable types.
Java:
// ❌ Everything is potentially null
public User findUser(String id) {
// May return null
return database.query(id);
}
public String getDefaultEmail() {
return config.get("email"); // May return null
}Kotlin:
// ✅ Explicit nullability
fun findUser(id: String): User? {
return database.query(id) // Explicitly nullable
}
fun getDefaultEmail(): String {
return config.get("email") ?: "default@example.com" // Never null
}
// ✅ Or keep nullable if appropriate
fun getConfigEmail(): String? {
return config.get("email")
}Rule: Make return types non-nullable when possible using Elvis operator.
Handling @Nullable and @NotNull
Kotlin respects Java nullability annotations.
Java with annotations:
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class UserService {
@NotNull
public User getUser(@NotNull String id) {
// Implementation
}
@Nullable
public User findUser(@NotNull String id) {
// Implementation
}
}Kotlin conversion:
// ✅ Annotations translate to Kotlin types
class UserService {
fun getUser(id: String): User { // @NotNull → String and User
// Implementation
}
fun findUser(id: String): User? { // @Nullable → User?
// Implementation
}
}Tip: Add nullability annotations to Java code before migration for smoother conversion.
Platform Types
Kotlin uses platform types for Java code without annotations.
// Java method without annotations
// public String getName() { ... }
// ✅ Kotlin sees platform type String!
val name: String! = javaObject.name // Platform type
// ✅ Treat as non-null
val name: String = javaObject.name // Compiler allows, runtime may throw
// ✅ Treat as nullable (safer)
val name: String? = javaObject.name
// ✅ Best practice: explicit null check
val name = javaObject.name ?: "Unknown"Warning: Platform types bypass null safety - handle carefully.
Extension Functions
Converting Utility Methods
Java static utility methods become Kotlin extension functions.
Java:
// ❌ Utility class pattern
public class StringUtils {
public static boolean isValidEmail(String email) {
return email != null && email.contains("@");
}
public static String truncate(String text, int maxLength) {
if (text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
}
}
// Usage
if (StringUtils.isValidEmail(email)) {
String truncated = StringUtils.truncate(email, 20);
}Kotlin:
// ✅ Extension functions
fun String.isValidEmail(): Boolean {
return contains("@")
}
fun String.truncate(maxLength: Int): String {
if (length <= maxLength) return this
return substring(0, maxLength) + "..."
}
// ✅ Usage - more natural
if (email.isValidEmail()) {
val truncated = email.truncate(20)
}Benefit: Extension functions feel like native methods.
Converting Builder Patterns
Replace Java builders with Kotlin’s apply/also.
Java:
// ❌ Builder pattern
public class HttpRequest {
private String url;
private String method;
private Map<String, String> headers;
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = builder.headers;
}
public static class Builder {
private String url;
private String method = "GET";
private Map<String, String> headers = new HashMap<>();
public Builder url(String url) {
this.url = url;
return this;
}
public Builder method(String method) {
this.method = method;
return this;
}
public Builder header(String key, String value) {
this.headers.put(key, value);
return this;
}
public HttpRequest build() {
return new HttpRequest(this);
}
}
}
// Usage
HttpRequest request = new HttpRequest.Builder()
.url("https://api.example.com")
.method("POST")
.header("Authorization", "Bearer token")
.build();Kotlin:
// ✅ Simple data class with defaults
data class HttpRequest(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap()
)
// ✅ Usage with named parameters
val request = HttpRequest(
url = "https://api.example.com",
method = "POST",
headers = mapOf("Authorization" to "Bearer token")
)
// ✅ Or with apply for complex setup
val request = HttpRequest(url = "https://api.example.com").apply {
// Additional setup if needed
}Simplification: Named parameters + default values eliminate builder pattern.
Smart Casts
Eliminating instanceof Checks
Kotlin’s smart casts eliminate redundant type checks.
Java:
// ❌ Explicit casting required
public void processShape(Shape shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape; // Redundant cast
System.out.println("Radius: " + circle.getRadius());
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape; // Redundant cast
System.out.println("Width: " + rectangle.getWidth());
}
}Kotlin:
// ✅ Smart casts - no explicit casting
fun processShape(shape: Shape) {
when (shape) {
is Circle -> println("Radius: ${shape.radius}") // Automatically cast
is Rectangle -> println("Width: ${shape.width}") // Automatically cast
}
}
// ✅ In if expressions
fun processShape(shape: Shape) {
if (shape is Circle) {
println("Radius: ${shape.radius}") // Smart cast to Circle
}
}Benefit: Compiler automatically casts after type check.
When Expression
Replacing Switch Statements
Java’s switch vs Kotlin’s when expression.
Java:
// ❌ Statement, not expression
public String getStatusMessage(Status status) {
String message;
switch (status) {
case PENDING:
message = "Waiting for approval";
break;
case APPROVED:
message = "Processing";
break;
case COMPLETED:
message = "Done";
break;
case REJECTED:
message = "Failed";
break;
default:
message = "Unknown status";
}
return message;
}Kotlin:
// ✅ Expression with return value
fun getStatusMessage(status: Status): String = when (status) {
Status.PENDING -> "Waiting for approval"
Status.APPROVED -> "Processing"
Status.COMPLETED -> "Done"
Status.REJECTED -> "Failed"
} // No default needed if all cases covered (sealed class)
// ✅ With multiple conditions
fun getCategory(value: Int) = when {
value < 0 -> "Negative"
value == 0 -> "Zero"
value in 1..10 -> "Small"
value in 11..100 -> "Medium"
else -> "Large"
}Advantage: when is an expression (returns value) and more powerful than switch.
Sealed Classes for Type Safety
Converting Inheritance Hierarchies
Java’s open hierarchies vs Kotlin’s sealed classes.
Java:
// ❌ Open hierarchy - any class can extend
public abstract class Result {
public static class Success extends Result {
private final String data;
public Success(String data) {
this.data = data;
}
public String getData() {
return data;
}
}
public static class Error extends Result {
private final String message;
public Error(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
}
// ❌ Need default case in switch
public void handleResult(Result result) {
if (result instanceof Result.Success) {
Result.Success success = (Result.Success) result;
System.out.println(success.getData());
} else if (result instanceof Result.Error) {
Result.Error error = (Result.Error) result;
System.err.println(error.getMessage());
} else {
// Default case required
}
}Kotlin:
// ✅ Sealed class - all subclasses known at compile time
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
data class Loading(val progress: Int) : Result()
}
// ✅ Exhaustive when - no else needed
fun handleResult(result: Result) = when (result) {
is Result.Success -> println(result.data)
is Result.Error -> System.err.println(result.message)
is Result.Loading -> println("Loading: ${result.progress}%")
} // Compiler ensures all cases handledBenefit: Sealed classes provide exhaustive when expressions (compiler guarantees all cases handled).
Property Delegation
Replacing Lazy Initialization
Java’s lazy initialization patterns vs Kotlin’s delegates.
Java:
// ❌ Verbose lazy initialization
public class DatabaseService {
private Connection connection;
public Connection getConnection() {
if (connection == null) {
synchronized (this) {
if (connection == null) {
connection = Database.connect();
}
}
}
return connection;
}
}Kotlin:
// ✅ Lazy delegate - thread-safe by default
class DatabaseService {
val connection: Connection by lazy {
Database.connect()
}
}
// ✅ Usage is transparent
val service = DatabaseService()
val conn = service.connection // Initialized on first accessSimplification: Thread-safe lazy initialization in one line.
Observable Properties
Replace Java listeners with Kotlin delegates.
Java:
// ❌ Manual property change notification
public class User {
private String name;
private List<PropertyChangeListener> listeners = new ArrayList<>();
public String getName() {
return name;
}
public void setName(String name) {
String oldValue = this.name;
this.name = name;
notifyListeners("name", oldValue, name);
}
public void addPropertyChangeListener(PropertyChangeListener listener) {
listeners.add(listener);
}
private void notifyListeners(String property, Object oldValue, Object newValue) {
for (PropertyChangeListener listener : listeners) {
listener.propertyChange(new PropertyChangeEvent(this, property, oldValue, newValue));
}
}
}Kotlin:
// ✅ Observable delegate
class User {
var name: String by Delegates.observable("") { property, oldValue, newValue ->
println("${property.name} changed from $oldValue to $newValue")
}
}
// ✅ Usage
val user = User()
user.name = "Alice" // Automatically triggers observerCoroutines vs Threads
Asynchronous Code Migration
Java’s threads and futures vs Kotlin coroutines.
Java:
// ❌ Callback hell
public void fetchUserData(String userId, Callback callback) {
new Thread(() -> {
try {
User user = userApi.getUser(userId);
Profile profile = profileApi.getProfile(user.getId());
Posts posts = postsApi.getPosts(user.getId());
callback.onSuccess(new UserData(user, profile, posts));
} catch (Exception e) {
callback.onError(e);
}
}).start();
}
// ❌ CompletableFuture better but still complex
public CompletableFuture<UserData> fetchUserData(String userId) {
return CompletableFuture.supplyAsync(() -> userApi.getUser(userId))
.thenCompose(user ->
CompletableFuture.supplyAsync(() -> profileApi.getProfile(user.getId()))
.thenCombine(
CompletableFuture.supplyAsync(() -> postsApi.getPosts(user.getId())),
(profile, posts) -> new UserData(user, profile, posts)
)
);
}Kotlin:
// ✅ Sequential-looking async code
suspend fun fetchUserData(userId: String): UserData {
val user = userApi.getUser(userId) // Suspend, don't block
val profile = profileApi.getProfile(user.id)
val posts = postsApi.getPosts(user.id)
return UserData(user, profile, posts)
}
// ✅ Parallel execution
suspend fun fetchUserDataParallel(userId: String): UserData = coroutineScope {
val user = async { userApi.getUser(userId) }
val profile = async { profileApi.getProfile(userId) }
val posts = async { postsApi.getPosts(userId) }
UserData(user.await(), profile.await(), posts.await())
}Advantage: Coroutines provide sequential syntax for asynchronous code.
Common Pitfalls
Over-Using !! Operator
// ❌ Don't force unwrap nulls
fun getEmail(user: User?): String {
return user!!.email!! // Defeats null safety purpose
}
// ✅ Handle nulls properly
fun getEmail(user: User?): String? {
return user?.email
}
// ✅ Or provide default
fun getEmail(user: User?): String {
return user?.email ?: "no-email@example.com"
}Why problematic: !! throws NullPointerException, negating Kotlin’s null safety.
Not Using Data Classes
// ❌ Converted POJO, not idiomatic
class User(val id: String, val name: String) {
override fun equals(other: Any?): Boolean {
// Manually implemented equals
}
override fun hashCode(): Int {
// Manually implemented hashCode
}
}
// ✅ Use data class
data class User(val id: String, val name: String)Why problematic: Missing out on Kotlin’s data class benefits.
Mutable Collections by Default
// ❌ Converted Java code often uses mutable collections
val users = mutableListOf<User>() // Mutable
// ✅ Prefer immutable by default
val users = listOf<User>() // Immutable
// ✅ Use mutable only when needed
val users = mutableListOf<User>()
users.add(newUser) // Now mutation is intentionalVariations
Gradual Migration with @JvmName
Maintain Java compatibility during migration.
// ✅ Keep Java-friendly method names
@JvmName("getUserDisplayName")
fun User.displayName(): String = "$name ($email)"
// Java can call: UserExtensionsKt.getUserDisplayName(user)Using @JvmStatic for Static Methods
// ✅ Make companion object methods static in Java
class Config {
companion object {
@JvmStatic
fun load(): Config {
return Config()
}
}
}
// Java: Config.load() (static method)Migration Testing Strategy
// ✅ Keep existing tests, add Kotlin tests
// 1. Convert Java test to Kotlin
// 2. Run both versions side-by-side
// 3. Remove Java test after verification
// Example: JUnit test migration
class UserServiceTest {
@Test
fun `should create user successfully`() {
val service = UserService()
val user = service.create("Alice", "alice@example.com")
assertNotNull(user)
assertEquals("Alice", user.name)
}
}Related Patterns
Learn more:
- Beginner Tutorial - Java Interop - Interoperability basics
- Handle Java Interoperability - Calling Java from Kotlin
- Extension Functions - Extending Java classes
- Coroutines Guide - Async migration patterns
Cookbook recipes:
- Java Interop Patterns - Quick reference
- Null Safety Patterns - Handling nulls
- Collection Operations - Modern collection APIs