Kotlin Skill
Conventions for writing idiomatic, safe, and maintainable Kotlin code.
When to Use
- •Creating or modifying
.ktfiles - •Working with coroutines and Flow
- •Implementing domain models and services
- •Writing unit tests with Kotest
Critical Patterns
1. Null Safety - NEVER Use !!
STRICTLY AVOID the !! operator. Use safe alternatives:
// ❌ NEVER do this
val name = user!!.name
// ✅ Safe call with elvis
val name = user?.name ?: "Unknown"
// ✅ requireNotNull for asserting
val name = requireNotNull(user?.name) { "User name is required" }
// ✅ let for scoped operations
user?.let {
sendEmail(it.email)
}
// ✅ takeIf/takeUnless for conditional
val activeUser = user.takeIf { it.isActive }
2. Data Classes for Models
ALWAYS use data classes for immutable models:
data class User(
val id: UserId,
val email: Email,
val name: String,
val isActive: Boolean = true,
val createdAt: Instant = Instant.now(),
)
// ✅ Value classes for type safety
@JvmInline
value class UserId(val value: UUID)
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email format" }
}
}
3. Sealed Classes for State
Use sealed classes for restricted hierarchies:
sealed interface UiState<out T> {
data class Success<T>(val data: T) : UiState<T>
data class Failure(val error: DomainError) : UiState<Nothing>
data object Loading : UiState<Nothing>
}
// Usage with when (exhaustive)
fun handleState(state: UiState<User>) = when (state) {
is UiState.Success -> displayUser(state.data)
is UiState.Failure -> showError(state.error)
UiState.Loading -> showSpinner()
}
// Domain errors
sealed interface DomainError {
data class NotFound(val id: String) : DomainError
data class Validation(val field: String, val message: String) : DomainError
data object Unauthorized : DomainError
}
4. Imports and Fully-Qualified Names
STRICTLY AVOID inline fully-qualified class or static references. Favor top-level imports and short names.
// ❌ NEVER do this (inline FQCN) val id = java.util.UUID.randomUUID() fun update(@io.swagger.v3.oas.annotations.parameters.RequestBody req: Request) // ✅ ALWAYS do this import java.util.UUID import io.swagger.v3.oas.annotations.parameters.RequestBody val id = UUID.randomUUID() fun update(@RequestBody req: Request)
Exception: Fully-qualified names are ALLOWED and encouraged in KDoc for unambiguous linking.
/** * Processes a [com.cvix.resume.domain.Resume]. */
See no-fully-qualified-references.md for detailed rules.
Testing with Kotest
This project uses Kotest for expressive, idiomatic Kotlin tests.
Spec Styles: Prefer FunSpec for simple tests, DescribeSpec for BDD-style grouping:
// FunSpec - simple flat structure
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("should create user with valid data") {
coEvery { repository.save(any()) } returns testUser
val result = service.create(validUserData)
result.shouldNotBeNull()
result.email shouldBe validUserData.email
}
test("should throw when email already exists") {
coEvery { repository.findByEmail(any()) } returns existingUser
shouldThrow<ConflictException> {
service.create(validUserData)
}.message shouldContain "already exists"
}
})
// DescribeSpec - BDD-style grouping
class EmailValueObjectTest : DescribeSpec({
describe("Email") {
context("when created with valid format") {
it("should create successfully") {
val email = Email("user@example.com")
email.value shouldBe "user@example.com"
}
}
context("when created with invalid format") {
it("should throw IllegalArgumentException") {
shouldThrow<IllegalArgumentException> {
Email("invalid-email")
}
}
}
}
})
Common Matchers:
// Equality and nullability
result shouldBe expected
result shouldNotBe null
result.shouldNotBeNull()
result.shouldBeNull()
// Collections
list shouldHaveSize 3
list shouldContain element
list.shouldContainAll(a, b, c)
list.shouldBeEmpty()
// Exceptions
shouldThrow<NotFoundException> { service.findById(unknownId) }
shouldNotThrow { service.findById(validId) }
// String matchers
name shouldStartWith "John"
error.message shouldContain "not found"
4. Error Handling with Result
Prefer Result<T> over exceptions for business logic:
// ✅ Return Result for operations that can fail
fun findUser(id: UserId): Result<User> = runCatching {
userRepository.findById(id)
?: throw NotFoundException("User not found")
}
// ✅ Chain operations
fun processUser(id: UserId): Result<ProcessedUser> {
return findUser(id)
.mapCatching { user -> validate(user) }
.mapCatching { validated -> enrich(validated) }
.onFailure { logger.error(it) { "Failed to process user $id" } }
}
// ✅ Handle result
findUser(userId).fold(
onSuccess = { user -> Response.ok(user) },
onFailure = { error -> Response.error(error.message) },
)
5. Coroutines and Flow
Embrace structured concurrency:
// ✅ Suspend functions for async operations
suspend fun fetchUser(id: UserId): User {
return withContext(Dispatchers.IO) {
userRepository.findById(id)
}
}
// ✅ Flow for streams
fun observeUsers(): Flow<List<User>> = flow {
while (true) {
emit(userRepository.findAll())
delay(5.seconds)
}
}.flowOn(Dispatchers.IO)
// ✅ Proper scope management
class UserService(
private val scope: CoroutineScope,
) {
fun startSync() {
scope.launch {
observeUsers().collect { users ->
processUsers(users)
}
}
}
}
6. Extension Functions
Use for enhancing existing types:
// ✅ Domain-specific extensions
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
fun Instant.isRecent(threshold: Duration = 24.hours): Boolean =
this.isAfter(Instant.now().minus(threshold))
// ✅ Null-safe extensions
fun String?.orEmpty(): String = this ?: ""
fun <T> List<T>?.orEmpty(): List<T> = this ?: emptyList()
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Classes/Interfaces | PascalCase | UserService, WorkspaceRepository |
| Functions/Variables | camelCase | findById, userName |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT, DEFAULT_TIMEOUT |
| Test Methods | Backticks | `should return user when exists` |
| Booleans | is, has, are prefix | isActive, hasPermission, areValid |
Code Style
// ✅ Expression body for simple functions
fun double(x: Int): Int = x * 2
// ✅ Trailing commas (helps with diffs)
data class Config(
val host: String,
val port: Int,
val timeout: Duration, // ← trailing comma
)
// ✅ Named arguments for 3+ parameters
createUser(
name = "John",
email = "john@example.com",
role = Role.USER,
)
// ✅ Prefer val over var
val items = mutableListOf<Item>() // val for reference, mutable for content
Logging
import io.github.oshai.kotlinlogging.KotlinLogging
private val logger = KotlinLogging.logger {}
class UserService {
fun processUser(user: User) {
logger.info { "Processing user: ${user.id}" }
try {
// logic
} catch (e: ProcessingException) {
logger.error(e) { "Failed to process user: ${user.id}" }
throw e
}
}
}
// ❌ NEVER log sensitive data
logger.info { "User logged in: password=${user.password}" } // WRONG!
Anti-Patterns
❌ !! operator - Use ?., ?:, requireNotNull, let
❌ Catching generic Exception - Catch specific exceptions
❌ var when val works - Prefer immutability
❌ Inheritance over composition - Prefer composition
❌ Wildcard imports - Except java.util.*, io.mockk.*
❌ Mutable public properties - Use private set or immutable data
Rules
- •No Fully Qualified References: Avoid inline fully-qualified names; favor imports.
Commands
# Run tests ./gradlew test # Run specific test class ./gradlew test --tests "com.cvix.user.UserServiceTest" # Run tests by tag ./gradlew test -PincludeTags=unit # Lint with Detekt ./gradlew detektAll # Build ./gradlew build