In this guide
Every Android final-year project starts with the same fork in the road: Kotlin or Java? Google declared Kotlin the preferred language for Android development at I/O 2019, and every modern Android API, sample, and Jetpack library is written Kotlin-first. Yet Java still powers millions of lines of existing Android code, every classic textbook teaches it, and some college labs still standardise on it.
This guide compares the two honestly — syntax, null safety, coroutines, tooling, and what the choice means for your project report, your viva, and your placement interviews — so you can defend the decision either way.
Short answer: choose Kotlin for any new final-year Android project. Choose Java only if you are extending an existing Java codebase, your lab mandates it, or your guide insists. The rest of this guide explains why, with code.
The 30-second syntax comparison
The same "fetch a user and show their name" logic in both languages:
// Java
public class UserRepository {
private ApiService api;
public UserRepository(ApiService api) {
this.api = api;
}
public void loadUser(int id, final UserCallback callback) {
api.getUser(id).enqueue(new Callback<User>() {
@Override
public void onResponse(Call<User> call, Response<User> response) {
if (response.isSuccessful() && response.body() != null) {
callback.onSuccess(response.body());
} else {
callback.onError("Request failed");
}
}
@Override
public void onFailure(Call<User> call, Throwable t) {
callback.onError(t.getMessage());
}
});
}
}
// Kotlin
class UserRepository(private val api: ApiService) {
suspend fun loadUser(id: Int): User {
val response = api.getUser(id)
if (response.isSuccessful) {
return response.body() ?: throw IllegalStateException("Empty body")
}
throw IllegalStateException("Request failed: " + response.code())
}
}
Count what disappeared in the Kotlin version: the constructor boilerplate (handled by the primary constructor), the callback interface, the anonymous inner class, and the nesting. The Kotlin version is a straight-line suspend function that a reader can follow top to bottom. This is not cosmetic — callback nesting is where student code becomes untestable, and untestable code is where viva questions get uncomfortable.
Side-by-side syntax table:
| Task | Java | Kotlin |
|---|---|---|
| Null check | if (user != null && user.getName() != null) |
user?.name (safe call) |
| String building | "Hello " + name + ", you have " + n + " messages" |
"Hello, " + name + ", you have " + n + " messages" (concatenation) |
| Data holder | POJO: fields + getters + setters + equals + hashCode (~60 lines) |
data class User(val id: Int, val name: String) (1 line) |
| Singleton | Private constructor + static holder (~15 lines) | object AppConfig (1 line) |
| Default arguments | Constructor/method overloads | fun load(page: Int = 1, size: Int = 20) |
| Type check + cast | if (x instanceof Dog) { ((Dog) x).bark(); } |
if (x is Dog) x.bark() (smart cast) |
| Switch | switch statement |
when expression (returns a value, exhaustive with sealed classes) |
Null safety: the feature that prevents the most crashes
Tony Hoare called the null reference his "billion-dollar mistake," and Android developers have paid a share of it. NullPointerException is still one of the most common crash signatures in student apps — usually a view accessed before onCreateView finished, or a network response body assumed non-null.
Java's type system cannot distinguish "this is never null" from "this might be null." Kotlin's can:
var name: String = "Ved" // cannot hold null — compiler enforces it
var nickname: String? = null // explicitly nullable — compiler forces handling
val length = name.length // safe, no check needed
val nickLength = nickname?.length // safe call: null if nickname is null
val safeLength = nickname?.length ?: 0 // Elvis operator: default when null
val forced = nickname!!.length // non-null assertion: crashes if null — avoid
The ?. and ?: operators push null handling to the point of use, visibly, instead of letting it explode at runtime three calls later. The !! operator exists for interop edge cases — treat every !! in your code as a future crash report and eliminate them before submission.
Practical consequence for your project: an app written in idiomatic Kotlin has an entire class of crashes designed out. When an evaluator asks "how did you handle edge cases," null safety is a concrete, demonstrable answer — not a claim.
Coroutines vs threads: background work without the pain
Android forbids network and database I/O on the main thread (the NetworkOnMainThreadException every student meets in week one). Java's answers were Thread, AsyncTask, and later ExecutorService:
AsyncTaskwas deprecated in API level 30 — if your textbook teaches it, the textbook is out of date.- Raw threads work but leak easily across Activity recreation, and coordinating "run A, then B, then update UI" becomes callback spaghetti.
Kotlin coroutines make asynchronous code read like synchronous code:
viewModelScope.launch {
try {
val user = repository.loadUser(userId) // suspends, main thread stays free
val orders = repository.loadOrders(user.id)
_uiState.value = UiState.Success(user, orders)
} catch (e: IOException) {
_uiState.value = UiState.Error("Network problem: check connection")
} catch (e: HttpException) {
_uiState.value = UiState.Error("Server error: " + e.code())
}
}
What matters here:
viewModelScopecancels the work automatically when the ViewModel is cleared — no leaked threads after rotation.- Sequential code, concurrent execution — the two loads read top-to-bottom but never block the UI thread.
- Structured error handling — one try/catch instead of error callbacks threaded through three layers.
For a smart attendance tracker that uploads records in the background while the teacher keeps marking, or a doctor appointment booking app fetching slot availability, coroutines are the difference between code you can explain in a viva and code you hope nobody asks about.
Data classes, sealed classes, and the death of boilerplate
Data classes generate equals(), hashCode(), toString(), and copy() from the constructor declaration. A Java POJO with five fields needs roughly 60 lines; the Kotlin equivalent is one. copy() deserves special mention — it creates a modified clone, which is how you update immutable UI state without mutating shared objects:
val updated = oldState.copy(isLoading = false, items = newItems)
Sealed classes model UI state as a closed set of possibilities the compiler checks exhaustively:
sealed class UiState {
object Loading : UiState()
data class Success(val items: List<Slot>) : UiState()
data class Error(val message: String) : UiState()
}
// in the UI:
when (state) {
is UiState.Loading -> showSpinner()
is UiState.Success -> showSlots(state.items)
is UiState.Error -> showError(state.message)
// no else needed — the compiler verifies every case is handled
}
If you later add a UiState.Empty, the compiler flags every when that does not handle it. In Java, the equivalent instanceof chain fails silently. For a library seat booking app with loading/available/full/error states, this pattern keeps the UI logic honest.
Extension functions let you add behaviour to existing classes without inheritance — the idiomatic way to write view helpers and formatting utilities that Java would scatter across static Utils classes.
Interop: the two languages share one runtime
Kotlin compiles to JVM bytecode and calls Java seamlessly, which is why the choice is rarely all-or-nothing:
// Calling Java from Kotlin — no ceremony
val calendar = Calendar.getInstance() // Java class, used directly
val prefs = getSharedPreferences("app", Context.MODE_PRIVATE)
val token = prefs.getString("token", null) // platform type: treat as nullable
// Calling Kotlin from Java — mostly seamless
User user = new User(1, "Ved"); // data class looks like a POJO
String greeting = "Hi, " + user.getName();
Interop notes that save debugging time:
- Java methods returning references arrive in Kotlin as platform types (
String!) — the compiler cannot prove nullability, so you must. Treat every platform type as nullable until proven otherwise. - Kotlin properties appear to Java as getters/setters —
val namebecomesgetName(). @JvmStaticand@JvmOverloadsannotations smooth specific interop rough edges; reach for them only when Java callers actually need them.- Mixed codebases compile fine in one Gradle module. You can adopt Kotlin file by file in a Java project — a practical path if your guide's starter code is Java.
Tooling: kapt, KSP, and build times
Annotation processing (Room, Dagger/Hilt) historically ran through kapt (Kotlin Annotation Processing Tool), which is noticeably slower than Java's annotation processing because it generates Java stubs first. KSP (Kotlin Symbol Processing) processes Kotlin directly and is the current recommendation — Room, Moshi, and Dagger/Hilt all support it. In your build.gradle:
plugins {
id("com.google.devtools.ksp") version "2.0.20-1.0.25"
}
// then: ksp("androidx.room:room-compiler:<version>")
// instead of: kapt("androidx.room:room-compiler:<version>")
Match the KSP version to your Kotlin version (the 2.0.20-1.0.25 format is <kotlin-version>-<ksp-version>); a mismatch fails the build with a clear error, not silent breakage. Expect measurably faster clean builds than kapt — the exact saving depends on module count, but the direction is consistent.
Gradle setup for a Kotlin Android project
The Kotlin plugin wiring is where first builds break. A minimal correct build.gradle for the app module:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.app"
compileSdk = 34
defaultConfig {
applicationId = "com.example.app"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures {
viewBinding = true // or buildFeatures { compose = true } for Compose
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
The three version numbers that must agree: the Kotlin Gradle plugin version (in the project-level file or version catalog), the jvmTarget, and the JDK running the build. Mismatches produce errors like Incompatible classes were found in dependencies — when you see that, check these three first. Use a version catalog (gradle/libs.versions.toml) once you have more than a handful of dependencies; scattered version strings across Gradle files are how "it builds on my machine" starts.
The decision table
| Factor | Kotlin | Java |
|---|---|---|
| Google's current direction | Preferred language; all new Jetpack APIs and samples are Kotlin-first | Maintenance mode for Android — works, not where new APIs land |
| Null safety | Built into the type system | Nullability is convention and discipline only |
| Async code | Coroutines — sequential-looking, lifecycle-aware | Threads/executors — manual lifecycle management |
| Boilerplate | Data classes, default args, string handling — far less code | Verbose POJOs, overloads, anonymous classes |
| Learning resources | Official docs, Google codelabs, modern Stack Overflow answers | Vast but aging — many top results teach deprecated APIs |
| Existing code | Clean interop; adopt file-by-file | The incumbent — all legacy Android code is Java |
| Viva/interview signal | Shows current industry practice | Safe but dated for a 2026 project |
| Lab/college constraint | May need guide approval | Sometimes mandatory in the lab |
When Java is the right call: your department's lab standardises on Java and the evaluation rubric expects it; you are extending a provided Java starter codebase; your project guide writes and reviews only Java. These are legitimate constraints — a project that fights its environment loses marks.
When Kotlin is the right call: everything else. A new final-year Android project in 2026 should be Kotlin unless a concrete constraint says otherwise.
Migrating a Java project to Kotlin (the pragmatic path)
If you inherited Java starter code, you do not need a rewrite. Android Studio converts file by file (Code → Convert Java File to Kotlin File), and the converter handles roughly 90% correctly. The workflow:
- Convert leaf classes first (models, utils) — fewest dependencies, easiest to verify.
- Convert one Activity/Fragment at a time, running the app after each.
- Fix what the converter flags: platform types needing explicit nullability, static members needing
companion object, overloaded constructors. - Adopt coroutines in new code; leave working Java threading alone until you have a reason to touch it.
- Switch kapt to KSP once the Kotlin side stabilises.
Keep a Git branch per conversion step — the Git and GitHub guide covers the branching workflow that makes this safe.
Reading a stack trace: a worked example
Sooner or later your app will crash with something like this, and the ability to read it is what separates a stuck student from a debugging one:
FATAL EXCEPTION: main
Process: com.example.app, PID: 1234
java.lang.NullPointerException: Attempt to invoke virtual method
'java.lang.String com.example.app.User.getName()' on a null object reference
at com.example.app.ProfileActivity.onCreate(ProfileActivity.java:42)
at android.app.Activity.performCreate(Activity.java:8000)
Read it bottom-up: the framework called onCreate, and at ProfileActivity.java:42 the code called getName() on a null User. The fix is never "add a null check at line 42 and move on" — it is "why was the user null here?" (not logged in? loaded asynchronously and not ready?). In Kotlin, the equivalent code would typically not compile until you handled the nullability, which is the entire argument of this guide in one stack trace.
Java records vs Kotlin data classes
Java 16 introduced records — immutable data carriers with generated equals/hashCode/toString:
public record User(int id, String name) {}
Records close much of the POJO gap, and on a pure-JVM project they are a genuine improvement. On Android, though, two things limit them: the desugaring story for older API levels adds build complexity, and records are immutable-only with no copy() equivalent — the copy-and-modify pattern that state-driven UI relies on. If your lab mandates Java, use records for your models. If you chose Kotlin, data classes remain the more capable tool on Android.
What interviewers actually ask about Kotlin
Placement interviews for Android roles probe the same handful of Kotlin topics. Be ready to answer these out loud, with code:
- "What is the difference between
valandvar, and when would you use each?" —valis read-only (preferred; enables safer reasoning about state),varis mutable (needed for state that genuinely changes). Follow-up: "is avallist immutable?" (No — the reference is read-only, the list contents can still change unless it is a truly immutable collection.) - "How do coroutines differ from threads?" — Threads are OS-level and expensive (~1 MB stack each); coroutines are lightweight, many can share a thread pool, and structured concurrency ties their lifetime to a scope.
- "What does
suspendmean?" — The function can pause without blocking its thread and resume later; it can only be called from a coroutine or another suspend function. - "Explain null safety." — Nullable vs non-nullable types, safe calls, the Elvis operator — and why
!!is a code smell. - "When would you still write Java in an Android project?" — Interop with a legacy Java SDK, a lab constraint, or a performance-critical path where you want explicit control. "Never" is the wrong answer; it signals dogma rather than judgement.
A note on Kotlin Multiplatform
You may encounter Kotlin Multiplatform (KMP) — sharing Kotlin business logic between Android and iOS. It is a real, Google-supported direction, but it is not a final-year shortcut: the shared module covers logic only, each platform still needs its own UI, and the tooling has sharper edges than plain Android development. Learn Kotlin on Android first; evaluate KMP for a second project, not your first.
Error messages you will meet
| Error | Meaning | Fix |
|---|---|---|
NullPointerException (Java) / NPE crash |
Dereferenced a null reference | In Kotlin, make the type non-nullable or handle with ?./?:; find every !! and remove it |
lateinit property x has not been initialized |
Accessed a lateinit var before assignment |
Assign before use, or switch to nullable var with a null check |
Unresolved reference: ... |
Typo, missing import, or wrong module dependency | Check spelling and imports; ensure the dependency is in the right module's Gradle file |
Suspend function should be called only from a coroutine or another suspend function |
Called a suspend DAO/Retrofit function from regular code |
Wrap the call in viewModelScope.launch or make the caller suspend |
NetworkOnMainThreadException |
Network call on the UI thread | Move it into a coroutine on Dispatchers.IO (Retrofit + Room suspend functions handle this automatically) |
Platform declaration clash |
Kotlin generated a method colliding with a Java-defined one | Rename, or annotate with @JvmName |
What to say in the viva
Examiners ask "why Kotlin?" expecting more than "it is modern." Strong answers:
- "Kotlin is Google's preferred language for Android since 2019; all current Jetpack libraries are Kotlin-first, so the project builds on supported APIs."
- "Null safety is enforced by the type system — an entire class of
NullPointerExceptioncrashes is eliminated at compile time rather than handled at runtime." - "Coroutines with
viewModelScopegive lifecycle-aware background work; the equivalent Java threading requires manual cancellation across configuration changes." - "Kotlin interoperates with Java at the bytecode level, so existing Java libraries are used directly with no wrappers."
And if you chose Java under a lab constraint: "The lab standardises on Java for evaluation consistency; the architecture (MVVM, repository pattern) is language-independent and ports directly." A constraint you can articulate is a decision, not a default.
Checklist
- Language decision recorded in the project report with reasons (not "we used Kotlin because it is popular").
- No
AsyncTaskanywhere — background work uses coroutines (viewModelScope) or executors. - Zero
!!operators in the codebase; every nullable is handled with?.,?:, or an explicit check. - UI state modelled with a sealed class (or equivalent), handled exhaustively — no silent
elsebranches hiding states. - KSP (not kapt) for Room/Hilt annotation processing, version matched to the Kotlin plugin.
- If mixed Java/Kotlin: platform types from Java calls treated as nullable; conversion done file-by-file with Git branches.
- Coroutine scopes tied to lifecycle (
viewModelScope,lifecycleScope) — noGlobalScopein app code.
Where this fits your project
The language choice shapes the rest of your stack: offline storage with Room, networking with Retrofit, push notifications with FCM, and an MVVM layout are all written Kotlin-first in Google's current samples and documentation. Pick up any of the Android project builds — a smart attendance tracker, a doctor appointment booking app, a library seat booking app — and the Kotlin patterns in this guide map onto them directly. More Android builds live in the Android branch hub.