Kotlin is JetBrains' modern, concise language that runs on the JVM — the official language for Android development, and increasingly popular for backend services with Spring Boot and Ktor. This roadmap shows you exactly what to learn, in what order, and realistic timelines to go from zero to a job-ready Kotlin developer.
At a glance
| Phase |
Topics |
Time estimate |
| 1 |
Kotlin fundamentals — syntax, types, null safety |
3–4 weeks |
| 2 |
Object-oriented and functional programming |
3–4 weeks |
| 3 |
Coroutines and asynchronous programming |
3–4 weeks |
| 4 |
Android with Jetpack Compose |
6–8 weeks |
| 5 |
Architecture — MVVM, DI, Clean Architecture |
3–4 weeks |
| 6 |
Data layer — Room, Retrofit, DataStore |
2–3 weeks |
| 7 |
Testing — unit, UI, integration |
2–3 weeks |
| 8 |
Publishing to the Play Store |
1–2 weeks |
| 9 |
Backend with Ktor or Spring Boot (optional) |
4–6 weeks |
| 10 |
Kotlin Multiplatform (KMP) |
4–6 weeks |
Total: 10–14 months to job-ready (at ~2 hours/day)
Phase 1 — Kotlin fundamentals
Setup
# Install via SDKMAN (Linux/macOS)
sdk install kotlin
# Or use IntelliJ IDEA (recommended) — Kotlin is bundled
# Download Android Studio for mobile development
Variables and null safety
// Immutable (prefer this)
val name: String = "Alice"
val count = 42 // type inferred
// Mutable
var score = 0
score = 10
// Nullable types — Kotlin's killer feature
val maybeNull: String? = null
val length = maybeNull?.length ?: 0 // safe call + elvis operator
val forced = maybeNull!!.length // throws NPE if null — avoid
Data types
val integer: Int = 42
val long: Long = 100_000L
val double: Double = 3.14
val float: Float = 3.14f
val boolean: Boolean = true
val char: Char = 'A'
val string: String = "Hello, Kotlin"
val multiline = """
Line 1
Line 2
""".trimIndent()
Functions
// Basic function
fun greet(name: String): String = "Hello, $name"
// Default parameters
fun connect(host: String, port: Int = 8080) = "$host:$port"
// Named arguments
connect(port = 9090, host = "localhost")
// Extension functions
fun String.capitalize(): String =
this.replaceFirstChar { it.uppercase() }
"hello".capitalize() // "Hello"
// Higher-order function
fun applyTwice(value: Int, f: (Int) -> Int) = f(f(value))
applyTwice(3) { it * 2 } // 12
Control flow
// When expression (powerful switch)
val result = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
in 70..79 -> "C"
else -> "F"
}
// For loop
for (i in 1..10) println(i)
for (i in 10 downTo 1 step 2) println(i)
// Ranges as collections
val range = (1..5).toList() // [1, 2, 3, 4, 5]
val filtered = (1..20).filter { it % 3 == 0 } // [3, 6, 9, 12, 15, 18]
Collections
// Immutable (default)
val list = listOf(1, 2, 3)
val map = mapOf("a" to 1, "b" to 2)
val set = setOf("x", "y")
// Mutable
val mutableList = mutableListOf(1, 2, 3)
mutableList.add(4)
// Collection operations
val numbers = listOf(1, 2, 3, 4, 5)
val doubled = numbers.map { it * 2 } // [2, 4, 6, 8, 10]
val evens = numbers.filter { it % 2 == 0 } // [2, 4]
val sum = numbers.reduce { acc, n -> acc + n } // 15
val grouped = numbers.groupBy { if (it % 2 == 0) "even" else "odd" }
Phase 2 — OOP and functional programming
Data classes
data class User(
val id: Long,
val name: String,
val email: String,
val role: Role = Role.USER
)
enum class Role { USER, ADMIN, MODERATOR }
// Free: equals, hashCode, toString, copy
val alice = User(1, "Alice", "alice@example.com")
val bob = alice.copy(name = "Bob", id = 2)
println(alice == bob) // false
Sealed classes — better enums
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String, val code: Int = 0) : Result<Nothing>()
object Loading : Result<Nothing>()
}
fun handle(result: Result<User>) = when (result) {
is Result.Success -> println("Got user: ${result.data.name}")
is Result.Error -> println("Error ${result.code}: ${result.message}")
Result.Loading -> println("Loading…")
}
Interfaces and abstract classes
| Concept |
Interface |
Abstract class |
| Multiple inheritance |
Yes |
No (single) |
| State (fields) |
No (default impl only) |
Yes |
| Constructors |
No |
Yes |
| Use when |
Define a contract |
Share implementation |
interface Drawable {
fun draw()
fun resize(factor: Double) { /* default impl */ }
}
abstract class Shape(val color: String) : Drawable {
abstract fun area(): Double
}
class Circle(color: String, val radius: Double) : Shape(color) {
override fun area() = Math.PI * radius * radius
override fun draw() = println("Drawing $color circle")
}
Object expressions and companions
// Singleton
object AppConfig {
const val BASE_URL = "https://api.example.com"
var debug = false
}
// Companion object (static-like)
class UserRepository {
companion object {
fun create(): UserRepository = UserRepository()
}
}
val repo = UserRepository.create()
Functional programming
// Lambdas are first-class
val multiply: (Int, Int) -> Int = { a, b -> a * b }
// Function composition
fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C = { f(g(it)) }
// Inline functions for performance
inline fun <T> measure(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block()
println("Took ${System.currentTimeMillis() - start}ms")
return result
}
// Sequence for lazy evaluation (like Java Streams)
val result = (1..1_000_000)
.asSequence()
.filter { it % 2 == 0 }
.map { it * it }
.take(5)
.toList()
Phase 3 — Coroutines
Kotlin coroutines are the cornerstone of async Kotlin — lighter than threads and more structured than callbacks.
Basics
// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
delay(1000)
println("Coroutine done")
}
println("Main continues")
job.join()
}
async / await
suspend fun fetchUser(id: Long): User { /* suspend call */ }
suspend fun fetchPosts(userId: Long): List<Post> { /* suspend call */ }
// Sequential
val user = fetchUser(1)
val posts = fetchPosts(user.id)
// Parallel
val userDeferred = async { fetchUser(1) }
val postsDeferred = async { fetchPosts(1) }
val user = userDeferred.await()
val posts = postsDeferred.await()
Coroutine scopes and contexts
| Scope |
Use case |
GlobalScope |
Top-level (avoid in production) |
viewModelScope |
Android ViewModel (auto-cancelled) |
lifecycleScope |
Android Activity/Fragment |
CoroutineScope(Dispatchers.IO) |
Backend / general |
// Dispatchers
Dispatchers.Main // Android UI thread
Dispatchers.IO // I/O operations, network, database
Dispatchers.Default // CPU-intensive work
Dispatchers.Unconfined // inherits caller (test use only)
// Switch context
withContext(Dispatchers.IO) {
// runs on IO thread pool
readFile()
}
Flow — reactive streams
import kotlinx.coroutines.flow.*
fun numberStream(): Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
// Collect
numberStream()
.filter { it % 2 == 0 }
.map { it * it }
.collect { println(it) }
// StateFlow (replaces LiveData)
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
Phase 4 — Android with Jetpack Compose
Jetpack Compose is the modern declarative UI toolkit for Android — replacing XML layouts.
First composable
@Composable
fun UserCard(user: User, modifier: Modifier = Modifier) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(8.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = user.name,
style = MaterialTheme.typography.headlineSmall
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
State management
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }) { Text("Increment") }
}
}
Key Compose concepts
| Concept |
What it does |
@Composable |
Marks a composable function |
remember |
Survives recompositions |
rememberSaveable |
Survives process death |
State<T> |
Triggers recomposition on change |
LaunchedEffect |
Side effects (coroutine) tied to composition |
derivedStateOf |
Derived state to avoid excess recompositions |
CompositionLocal |
Implicit data passing down the tree |
Navigation
// build.gradle.kts
implementation("androidx.navigation:navigation-compose:2.8.0")
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailScreen(id, navController)
}
}
}
Phase 5 — Architecture
MVVM + StateFlow
// ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: Long) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
_uiState.value = when (val result = repository.getUser(id)) {
is Result.Success -> UserUiState.Success(result.data)
is Result.Error -> UserUiState.Error(result.message)
}
}
}
}
sealed class UserUiState {
object Loading : UserUiState()
data class Success(val user: User) : UserUiState()
data class Error(val message: String) : UserUiState()
}
Hilt — Dependency Injection
// Application
@HiltAndroidApp
class MyApp : Application()
// Module
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides @Singleton
fun provideUserApi(retrofit: Retrofit): UserApi =
retrofit.create(UserApi::class.java)
}
// Inject
@HiltViewModel
class UserViewModel @Inject constructor(
private val api: UserApi
) : ViewModel()
Architecture patterns
| Pattern |
Layers |
Best for |
| MVVM |
Model, ViewModel, View |
Android standard (Google recommended) |
| MVI |
Model, View, Intent |
Unidirectional data flow, complex state |
| Clean Architecture |
Data, Domain, Presentation |
Large codebases, testability |
| MVC |
Model, View, Controller |
Legacy Android (avoid for new projects) |
Phase 6 — Data layer
Room database
// Entity
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Long,
@ColumnInfo(name = "display_name") val name: String,
val email: String,
val createdAt: Long = System.currentTimeMillis()
)
// DAO
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY display_name")
fun getAllUsers(): Flow<List<UserEntity>>
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getUserById(id: Long): UserEntity?
@Upsert
suspend fun upsertUser(user: UserEntity)
@Delete
suspend fun deleteUser(user: UserEntity)
}
// Database
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Retrofit — network calls
// API interface
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Long): UserResponse
@POST("users")
suspend fun createUser(@Body body: CreateUserRequest): UserResponse
@GET("users")
suspend fun listUsers(
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): PagedResponse<UserResponse>
}
// Repository pattern
class UserRepository(
private val api: UserApi,
private val dao: UserDao
) {
suspend fun getUser(id: Long): Result<User> = runCatching {
api.getUser(id).toDomain()
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it.message ?: "Unknown error") }
)
}
DataStore (replaces SharedPreferences)
// Preferences DataStore
val Context.dataStore by preferencesDataStore("settings")
object PreferencesKeys {
val THEME = stringPreferencesKey("theme")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications")
}
// Read
val themeFlow: Flow<String> = context.dataStore.data
.map { it[PreferencesKeys.THEME] ?: "system" }
// Write
suspend fun setTheme(theme: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.THEME] = theme
}
}
Phase 7 — Testing
Testing pyramid
| Level |
Framework |
Scope |
Speed |
| Unit |
JUnit 5 + MockK |
Functions, ViewModels |
Fast |
| Integration |
JUnit 5 + Room in-memory |
Repository + DB |
Medium |
| UI |
Compose UI tests |
Screens |
Slow |
| E2E |
Espresso / Maestro |
Full app flows |
Slowest |
Unit testing with MockK
@ExtendWith(MockKExtension::class)
class UserViewModelTest {
@MockK lateinit var repository: UserRepository
private lateinit var viewModel: UserViewModel
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
viewModel = UserViewModel(repository)
}
@Test
fun `loadUser emits Success state`() = runTest {
val user = User(1, "Alice", "alice@example.com")
coEvery { repository.getUser(1) } returns Result.Success(user)
viewModel.loadUser(1)
assertEquals(UserUiState.Success(user), viewModel.uiState.value)
}
@Test
fun `loadUser emits Error on failure`() = runTest {
coEvery { repository.getUser(1) } returns Result.Error("Not found")
viewModel.loadUser(1)
assertTrue(viewModel.uiState.value is UserUiState.Error)
}
}
Compose UI testing
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun counter_incrementsOnButtonClick() {
composeTestRule.setContent { Counter() }
composeTestRule.onNodeWithText("Count: 0").assertIsDisplayed()
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
Phase 8 — Publishing to the Play Store
Build configuration
// build.gradle.kts (app)
android {
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
signingConfig = signingConfigs.getByName("release")
}
}
}
Release checklist
| Step |
Details |
| Keystore |
Generate once, store securely |
| Signing |
Sign APK/AAB with release key |
| ProGuard/R8 |
Enable code shrinking + obfuscation |
| App Bundle (AAB) |
Upload AAB, not APK (Play Store requirement) |
| Play Console |
Create app, upload AAB, fill store listing |
| Content rating |
Complete questionnaire |
| Privacy policy |
Required for most apps |
| Testing tracks |
Internal → Closed → Open → Production |
GitHub Actions CI/CD
name: Android CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run unit tests
run: ./gradlew test
- name: Build release AAB
run: ./gradlew bundleRelease
Phase 9 — Backend with Ktor or Spring Boot
Ktor (Kotlin-native, lightweight)
// build.gradle.kts
implementation("io.ktor:ktor-server-netty:2.3.12")
implementation("io.ktor:ktor-server-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
// Application
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
install(ContentNegotiation) { json() }
routing {
route("/api/users") {
get { call.respond(userService.getAllUsers()) }
get("/{id}") {
val id = call.parameters["id"]?.toLong()
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respond(userService.getUser(id))
}
post {
val request = call.receive<CreateUserRequest>()
call.respond(HttpStatusCode.Created, userService.createUser(request))
}
}
}
}
Spring Boot with Kotlin
// @RestController with Kotlin data classes
@RestController
@RequestMapping("/api/users")
class UserController(private val service: UserService) {
@GetMapping
fun list(): List<UserDto> = service.findAll()
@GetMapping("/{id}")
fun get(@PathVariable id: Long): UserDto = service.findById(id)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@RequestBody @Valid request: CreateUserRequest): UserDto =
service.create(request)
}
// Entity with Kotlin data class
@Entity @Table(name = "users")
data class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false) val name: String,
@Column(unique = true) val email: String
)
Ktor vs Spring Boot
|
Ktor |
Spring Boot |
| Language |
Kotlin-first |
Java + Kotlin support |
| Size |
Lightweight, modular |
Full-featured, opinionated |
| Performance |
Excellent (coroutines) |
Good (WebFlux for async) |
| Learning curve |
Lower |
Higher |
| Ecosystem |
Growing |
Mature, enterprise |
| Best for |
Microservices, APIs |
Enterprise, existing Java teams |
Phase 10 — Kotlin Multiplatform (KMP)
KMP lets you share business logic between Android, iOS, backend, and web from a single codebase.
// Shared module — commonMain
expect fun currentPlatform(): String
class UserRepository {
suspend fun getUser(id: Long): User {
// shared networking logic
return api.fetchUser(id)
}
}
// androidMain
actual fun currentPlatform(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
actual fun currentPlatform(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
KMP stack
| Layer |
Library |
| Networking |
Ktor Client (KMP) |
| Serialization |
kotlinx.serialization |
| SQL |
SQLDelight |
| DI |
Koin (KMP) |
| UI (Android) |
Jetpack Compose |
| UI (iOS) |
SwiftUI + Compose Multiplatform |
| Async |
kotlinx.coroutines |
Full technology map
Kotlin Developer
├── Language
│ ├── Null safety, data classes, sealed classes
│ ├── Extension functions, lambdas
│ └── Coroutines + Flow
│
├── Android (Mobile Track)
│ ├── Jetpack Compose (UI)
│ ├── ViewModel + StateFlow (MVVM)
│ ├── Hilt (DI)
│ ├── Room (local DB)
│ ├── Retrofit (networking)
│ └── Navigation Compose
│
├── Backend (Server Track)
│ ├── Ktor (Kotlin-native)
│ └── Spring Boot (enterprise)
│
├── Cross-platform
│ └── Kotlin Multiplatform (KMP)
│
├── Testing
│ ├── JUnit 5
│ ├── MockK
│ └── Compose UI tests
│
└── DevOps
├── Gradle (Kotlin DSL)
├── GitHub Actions
└── Google Play Console
Realistic timeline (at ~2 hours/day)
| Month |
Focus |
| 1 |
Kotlin fundamentals — syntax, null safety, functions |
| 2 |
OOP, data classes, sealed classes, collections |
| 3 |
Coroutines, Flow, async programming |
| 4–5 |
Jetpack Compose UI + Navigation |
| 6–7 |
MVVM, Hilt DI, Room, Retrofit |
| 8 |
Testing — MockK, Compose UI tests |
| 9 |
Publish first Play Store app |
| 10–14 |
KMP or backend (Ktor/Spring Boot) + portfolio projects |
Portfolio projects
| Project |
Skills demonstrated |
| To-Do app |
Compose, Room, ViewModel, StateFlow |
| News reader |
Retrofit, Paging 3, Coil image loading |
| Weather app |
Location API, network calls, offline caching |
| Chat app |
WebSockets / Firebase Realtime DB, auth |
| Finance tracker |
Charts (MPAndroidChart), biometric auth, DataStore |
| KMP app |
Shared logic on Android + iOS |
Kotlin developer roles and salary
| Role |
US salary (median) |
EU salary |
Notes |
| Junior Android Developer |
$80–110k |
€40–60k |
Kotlin + Compose basics |
| Mid Android Developer |
$110–150k |
€60–85k |
2–4 years, architecture |
| Senior Android Developer |
$150–200k |
€80–110k |
System design, mentoring |
| Staff / Lead Android |
$200–260k |
€100–140k |
Team strategy |
| Kotlin Backend Developer |
$120–170k |
€60–95k |
Ktor / Spring Boot |
| KMP Engineer |
$140–190k |
€75–110k |
Android + iOS shared logic |
Note: Kotlin + Compose is one of the highest-paying mobile stacks in 2025. Android developers with KMP experience command a 15–25% premium.
Common mistakes
| Mistake |
Why it's a problem |
Fix |
Overusing !! |
Crashes at runtime on null |
Use ?. safe call or ?: default |
var instead of val |
Mutable state causes bugs |
Default to val, use var only when needed |
Blocking coroutines with Thread.sleep |
Blocks the thread |
Use delay() inside coroutines |
Launching coroutines in GlobalScope |
Memory leaks, not cancellable |
Use viewModelScope / lifecycleScope |
LiveData in new projects |
Deprecated path |
Use StateFlow + collectAsStateWithLifecycle |
Missing @Stable on Compose params |
Unnecessary recompositions |
Annotate or use @Immutable data classes |
Not using sealed class for state |
Incomplete when branches |
Model UI state with sealed classes |
| Loading images without caching |
Slow scroll, wasted data |
Use Coil with memory + disk cache |
Kotlin vs related languages
|
Kotlin |
Java |
Swift |
Dart (Flutter) |
| Primary use |
Android, JVM backend |
JVM backend, Android |
iOS, macOS |
Cross-platform mobile |
| Null safety |
Built-in (?) |
Optional (annotations) |
Built-in (?) |
Built-in (?) |
| Coroutines |
Yes (structured) |
Project Loom (Java 21+) |
async/await |
async/await |
| Android support |
First-class (official) |
Legacy |
No |
Via Flutter |
| iOS support |
KMP (shared logic) |
No |
First-class |
Via Flutter |
| Syntax verbosity |
Concise |
Verbose |
Concise |
Concise |
| Interop |
100% Java interop |
N/A |
Obj-C interop |
N/A |
| Learning curve |
Low (if Java known) |
Medium |
Medium |
Low |
FAQ
Do I need to learn Java before Kotlin?
No. Kotlin is a better entry point for Android development. You'll encounter Java in legacy codebases and can learn it alongside Kotlin, but starting with Kotlin is the modern approach.
Is Kotlin only for Android?
No — Kotlin runs on the JVM, so it works for backend development (Spring Boot, Ktor), data science (Jupyter), command-line tools, and with KMP you can target iOS, web, and desktop.
Kotlin vs Flutter for cross-platform?
Both are good choices. Kotlin Multiplatform shares business logic while keeping native UIs (Compose for Android, SwiftUI for iOS). Flutter shares the entire UI layer via its own rendering engine. KMP gives more native look-and-feel; Flutter gives a single codebase for UI.
How long does it take to get a Kotlin job?
With daily 2-hour practice: 8–12 months to junior-level Android developer. Having Java experience cuts this to 4–6 months.
Is Jetpack Compose stable for production?
Yes — Compose reached stable in 2021 and is now the default UI approach for new Android apps. Google's own apps (Maps, Gmail, YouTube) use Compose.
What IDE should I use?
Android Studio (based on IntelliJ) for mobile development. IntelliJ IDEA Community or Ultimate for backend Kotlin. Both have first-class Kotlin support maintained by JetBrains.