name: clean-code
description: Enforce clean Kotlin: naming conventions, null safety (no !!), value classes, scope functions, sealed interfaces, expression bodies, immutability, and single-responsibility — applied during code review or when writing new Kotlin for Android/KMP
argument-hint: ""
user-invocable: true
Try<T>Try<T> is this project's typed error wrapper (Arrow's Either<AppError, T>).
Success(value) — operation succeededFailure(error: AppError) — operation failed, never throwsUse .fold {}, .map {}, .getOrElse {} — never unwrap with !!
typealias Try<T> = Either<AppError, T>
// Usage
suspend fun findWord(id: WordId): Try<Word> // returns Failure if not found
| Construct | Convention | Example |
|---|---|---|
| Class, Interface, Object, Enum | PascalCase |
UserRepository |
| Function, property, variable | camelCase |
fetchUserProfile() |
const val, top-level immutable |
SCREAMING_SNAKE_CASE |
MAX_RETRY_COUNT |
| Package | lowercase, no underscores | com.example.feature.auth |
@Composable function |
PascalCase (they are types) |
WordCard() |
| Enum entries | SCREAMING_SNAKE_CASE |
ReviewQuality.HARD |
dueWords not list, isExpired not flaguserRepository not usrRepoisLoading, hasError, canSubmitloadWords(), deleteUser(), calculateScore()Avoid redundant context: inside UserRepository, use findById() not findUserById()
// Good
val dueWords = words.filter { it.isDue() }
fun calculateNextReviewDate(word: Word, quality: Int): LocalDate
// Bad
val list2 = wrds.filter { it.d }
fun calc(w: Word, q: Int): LocalDate
Test names communicate intent — use backtick names in JUnit5:
@Test fun `given expired card, when charging, then returns Failure`()
@Test fun `calculateScore returns zero when no words reviewed`()
@Test fun `isDue returns false when reviewed within the last hour`()
If you need "and" to describe a function, split it.
// Bad — validates AND charges AND logs AND notifies
fun processPayment(card: Card, amount: Int) { ... }
// Good — composed from named responsibilities
fun processPayment(card: Card, amount: Int): Try<Receipt> = tryOf {
validate(card)
val receipt = charge(card, amount)
logPayment(receipt)
notifyUser(receipt)
receipt
}
Prefer expression bodies for single-expression functions — removes ceremony.
// Bad
fun isDue(): Boolean {
return nextReviewDate <= LocalDate.now()
}
// Good
fun isDue(): Boolean = nextReviewDate <= LocalDate.now()
fun dueCount(): Int = words.count { it.isDue() }
fun label(bucket: Int): String = when (bucket) {
0 -> "New"
in 1..2 -> "Learning"
else -> "Mastered"
}
Default parameters over overloads.
// Bad
fun createWord(original: String, translated: String, notes: String, tags: List<String>, difficulty: Int)
// Good
data class CreateWordParams(
val original: String,
val translated: String,
val notes: String = "",
val tags: List<String> = emptyList(),
val difficulty: Int = 1,
)
fun createWord(params: CreateWordParams): Try<Word>
Prefer val over var. Prefer immutable collections. Mutate by replacing, not by modifying in place.
// Bad
var words = mutableListOf<Word>()
words.add(newWord)
// Good
val words: List<Word> = emptyList()
val updated = words + newWord // replace, don't mutate
// Bad — mutable data class property
data class User(var name: String)
// Good — immutable + copy()
data class User(val name: String)
val renamed = user.copy(name = "Ali")
Use @JvmInline value class to make IDs and domain primitives type-safe at compile time.
// Bad — silent bug: arguments in wrong order, compiler won't catch it
fun createUser(id: Int, tenantId: Int, age: Int)
// Good — compiler enforces correct types
@JvmInline value class UserId(val value: Int)
@JvmInline value class TenantId(val value: Int)
@JvmInline value class Age(val value: Int)
fun createUser(id: UserId, tenantId: TenantId, age: Age)
Design APIs to return non-null types. Use sealed types or Try<T> for absence/failure.
// Bad — caller must always check null
suspend fun findWord(id: WordId): Word?
// Good — absence represented explicitly
suspend fun findWord(id: WordId): Try<Word> // Failure = not found
// or
sealed interface FindResult {
data class Found(val word: Word) : FindResult
data object NotFound : FindResult
}
?. and ?: over !!!! only when non-null is guaranteed by invariant — add a comment explaining whyrequireNotNull(value) { "reason" } for intentional assertions at system boundaries
// Good
val name = user?.profile?.displayName ?: "Anonymous"
// Acceptable — with justification
val view = binding.root // binding is always non-null after onCreateView
// Never
val name = user!!.profile!!.displayName
| Function | Object ref | Returns | Use for |
|---|---|---|---|
let |
it |
lambda result | Null-safe transform, scoped variable |
run |
this |
lambda result | Configuration + compute result |
with |
this |
lambda result | Group operations on a non-null receiver |
apply |
this |
receiver | Builder-style initialisation |
also |
it |
receiver | Side effects (logging, validation) |
// let — safe-call chain + transform
user?.let { sendWelcomeEmail(it.email) }
// apply — builder-style setup
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("userId", userId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
// also — side effect without breaking chain
fetchWords()
.also { logger.log("Fetched ${it.size} words") }
.map { it.toDomain() }
Avoid
runCatching {}— useTry<T>/Eitherso errors are typed and explicit.
sealed interface AuthResult {
data class Success(val user: User) : AuthResult
data class Failure(val reason: AuthError) : AuthResult
data object Cancelled : AuthResult
}
// Exhaustive — no else needed
when (result) {
is AuthResult.Success -> onSuccess(result.user)
is AuthResult.Failure -> showError(result.reason)
AuthResult.Cancelled -> onCancelled()
}
Extend existing types with domain behaviour without inheritance.
// Domain extensions
fun Word.isDue(): Boolean = nextReviewDate <= LocalDate.now()
fun List<Word>.dueCount(): Int = count { it.isDue() }
// Formatting
fun LocalDate.toDisplayString(): String =
format(DateTimeFormatter.ofPattern("MMM d, yyyy"))
companion object — Factory Methods and Constantsclass AuthToken private constructor(val value: String) {
companion object {
fun from(raw: String): Try<AuthToken> =
if (raw.isBlank()) Failure(AuthError.InvalidToken) else Success(AuthToken(raw))
val Empty = AuthToken("")
}
}
typealias for Readabilitytypealias WordId = Int // signals intent at call sites
typealias ReviewHistory = List<ReviewEntry>
typealias OnWordSelected = (Word) -> Unit
Prefer when over long if-else chains.
val label = when (bucket) {
0 -> "New"
in 1..2 -> "Learning"
in 3..4 -> "Familiar"
else -> "Mastered"
}
when {
isLoading -> showLoading()
hasError -> showError()
items.isEmpty() -> showEmpty()
else -> showContent()
}
No magic numbers or strings in business logic.
object ReviewQuality {
const val AGAIN = 0
const val HARD = 2
const val GOOD = 4
const val EASY = 5
}
object Timeouts {
val networkRequest = 30.seconds
val cacheExpiry = 24.hours
}
KDoc on public API in shared modules.
// Bad — the what is obvious
// Filter words by due date
val due = words.filter { it.isDue() }
// Good — explains the why (SRS algorithm detail)
// Words reviewed within the last hour are excluded to prevent over-drilling
val due = words.filter { it.isDue() && it.lastReviewed.isBefore(oneHourAgo) }
| Anti-Pattern | Prefer Instead |
|---|---|
!! |
Safe calls, Elvis, requireNotNull |
try-catch for control flow |
Try<T>, .fold {} |
var in data classes |
val + copy() |
| Long parameter lists | Data class params |
| Deeply nested lambdas | Coroutines / named functions |
Any / unchecked casts |
Generics, sealed types |
Empty catch {} |
Always handle or rethrow |
| Commented-out code | Delete it |
runCatching {} |
Try<T> / Either — typed errors |
Primitive IDs (Int, String) |
@JvmInline value class |
LiveData in new code |
StateFlow / SharedFlow |
| Mocks in tests | Fakes (FakeXxxRepository) |
Run through this when reviewing or writing Kotlin:
!! without a justification commentvar in data classes — use val + copy()value classTry<T> or a sealed type — no null leaking outtry-catch for control flowis*, has*, or can*value class, not raw Int/Stringgiven_when_then or should_X_when_Y