Kotlin Guide
Applies to: Kotlin 1.9+, Android, Spring Boot, Ktor, Multiplatform
Core Principles
- Null Safety First: Type system eliminates NullPointerExceptions
- Conciseness & Readability: Reduce boilerplate, expressive code
- Seamless Java Interoperability: Works with existing Java codebases
- Coroutines for Concurrency: Structured async programming
- Immutability by Default: Prefer
val over var, immutable collections
Language-Specific Guardrails
Kotlin Version & Setup
✓ Use Kotlin 1.9+ (2.0+ recommended for K2 compiler)
✓ Use Gradle Kotlin DSL for build configuration
✓ Pin dependency versions in version catalogs (libs.versions.toml)
✓ Include Kotlin version in build.gradle.kts
Null Safety
✓ Prefer non-nullable types by default
✓ Use safe call operator ?. instead of explicit null checks
✓ Use Elvis operator ?: for default values
✓ Avoid !! operator without explicit justification and comment
✓ Use lateinit only when initialization truly deferred (DI, Android)
✓ Use by lazy for expensive one-time initialization
✓ Use requireNotNull() or checkNotNull() with descriptive messages
Code Style (ktlint)
✓ Run ./gradlew ktlintFormat before every commit
✓ Follow official Kotlin coding conventions
✓ Line length: 100 characters (ktlint default)
✓ Use camelCase for functions and properties
✓ Use PascalCase for classes and interfaces
✓ Use SCREAMING_SNAKE_CASE for constants
✓ 4-space indentation (not tabs)
✓ Use trailing commas in multiline declarations
Immutability
✓ Prefer val over var (immutable by default)
✓ Use data class for DTOs (automatic equals/hashCode/toString/copy)
✓ Use immutable collections: List, Set, Map instead of MutableList, etc.
✓ Use .copy() for data class modifications
✓ Mark properties private unless they need to be public
Coroutines
✓ Always use structured concurrency (never GlobalScope)
✓ Use suspend functions for async operations
✓ Make suspend functions main-safe (use withContext(Dispatchers.IO))
✓ Use coroutineScope or supervisorScope for concurrent work
✓ Always handle CancellationException properly (don't catch it)
✓ Set timeouts for long-running operations (withTimeout)
✓ Use Flow for streams of data
Pattern
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class UserCreate(
val email: String,
val age: Int,
val role: String
) {
init {
require(email.contains("@")) { "Invalid email format" }
require(age > 0) { "Age must be positive" }
require(role in listOf("admin", "user", "guest")) { "Invalid role" }
}
}
// Usage
fun createUser(json: String): User {
val validated = Json.decodeFromString<UserCreate>(json)
return User(email = validated.email, age = validated.age, role = validated.role)
}
Testing
Frameworks
| Framework | Use Case |
| Kotest | Kotlin-idiomatic, multiple test styles (recommended) |
| JUnit 5 | Industry standard (verbose but mature) |
| MockK | Mocking library (mocks final classes) |
| Turbine | Flow testing library |
Guardrails
✓ Test files: *Test.kt or descriptive names
✓ Use descriptive test names with backticks
✓ Prefer Kotest's shouldBe over JUnit's assertEquals
✓ Use MockK for mocking (not Mockito for Kotlin)
✓ Use coEvery, coVerify for suspend functions
✓ Test coroutines with runTest (from kotlinx-coroutines-test)
✓ Coverage target: >80% for business logic
Example (Kotest with MockK)
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import kotlinx.coroutines.test.runTest
class UserServiceTest : StringSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
afterTest {
clearAllMocks()
}
"should return user when id is valid" {
val user = User(id = "123", email = "test@example.com")
coEvery { repository.findById("123") } returns user
val result = service.getUser("123")
result shouldBe user
coVerify(exactly = 1) { repository.findById("123") }
}
"should throw exception when email is invalid" {
val exception = shouldThrow<ValidationException> {
service.createUser(email = "invalid", age = 25)
}
exception.message shouldBe "Invalid email format"
}
})
| Tool | Purpose |
| ktlint | Code formatter (opinionated) |
| detekt | Static analysis (code smells, complexity) |
| Gradle Kotlin DSL | Build configuration |
| KSP | Kotlin Symbol Processing (faster than KAPT) |
Configuration
// build.gradle.kts
plugins {
kotlin("jvm") version "1.9.22"
kotlin("plugin.serialization") version "1.9.22"
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
id("io.gitlab.arturbosch.detekt") version "1.23.4"
}
kotlin {
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
}
}
Pre-Commit Commands
# Format
./gradlew ktlintFormat
# Lint
./gradlew ktlintCheck
# Static analysis
./gradlew detekt
# Test
./gradlew test
# Build
./gradlew build
Common Pitfalls
Don't Do This
// ❌ Using !!
val value = nullableValue!!
// ❌ Using var when val works
var user = User("John")
// ❌ GlobalScope for coroutines
GlobalScope.launch {
doWork()
}
// ❌ Mutable collections as public properties
class UserManager {
val users = mutableListOf<User>()
}
Do This Instead
// ✅ Safe call or Elvis operator
val value = nullableValue ?: defaultValue
// ✅ Immutable by default
val user = User("John")
// ✅ Structured concurrency
suspend fun doWork() = coroutineScope {
launch {
// Work here
}
}
// ✅ Immutable public API
class UserManager {
private val _users = mutableListOf<User>()
val users: List<User> get() = _users.toList()
}
Framework-Specific Patterns
Android + Jetpack Compose
// ViewModel with StateFlow
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val users = repository.getUsers()
_uiState.value = UiState.Success(users)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
sealed interface UiState {
object Loading : UiState
data class Success(val users: List<User>) : UiState
data class Error(val message: String) : UiState
}
// Composable UI
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is UiState.Loading -> LoadingIndicator()
is UiState.Success -> UserList(users = state.users)
is UiState.Error -> ErrorMessage(message = state.message)
}
}
Spring Boot + Kotlin
// REST Controller
@RestController
@RequestMapping("/api/users")
class UserController(private val service: UserService) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: String): ResponseEntity<User> {
val user = service.findById(id) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(user)
}
@PostMapping
suspend fun createUser(@Valid @RequestBody request: UserCreateRequest): User {
return service.create(request)
}
}
// Data class with validation
data class UserCreateRequest(
@field:Email(message = "Invalid email format")
val email: String,
@field:Positive(message = "Age must be positive")
val age: Int,
@field:NotBlank
val name: String
)
Optimization Guardrails
✓ Use sequences for large collections with multiple operations
✓ Use inline functions for lambdas passed to higher-order functions
✓ Use by lazy for expensive one-time initialization
✓ Avoid creating unnecessary objects in loops
✓ Use buildList, buildMap for collection builders
✓ Profile before optimizing (Android Profiler, JProfiler)
Example
// Use sequences for large datasets
val result = hugeList
.asSequence()
.filter { it.isActive }
.map { it.name }
.take(10)
.toList()
// Lazy initialization
val expensiveResource by lazy {
// Computed only once, when first accessed
loadExpensiveResource()
}
Security Best Practices
Guardrails
✓ Never hardcode secrets (use environment variables or secure storage)
✓ Validate all user inputs with data class init blocks or Bean Validation
✓ Use parameterized queries (prevent SQL injection)
✓ Hash passwords with bcrypt or Argon2
✓ Enable CSRF protection (Spring Security)
✓ Run ./gradlew dependencyCheckAnalyze for vulnerability scanning
✓ Use ProGuard/R8 for Android (obfuscation)
Example
// Password hashing (Spring Security)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
class PasswordService {
private val encoder = BCryptPasswordEncoder(12)
fun hashPassword(plainPassword: String): String {
return encoder.encode(plainPassword)
}
fun verifyPassword(plainPassword: String, hashedPassword: String): Boolean {
return encoder.matches(plainPassword, hashedPassword)
}
}
References