Don't Fall into These Traps: Common Kotlin Anti-Patterns You Must Avoid

 

Stop writing “Java in Kotlin.” Master Idiomatic Kotlin by refactoring these 5 prevalent mistakes.


Don't Fall into These Traps: Common Kotlin Anti-Patterns You Must Avoid

Kotlin is a joy to work with — concise, expressive, and packed with features that boost productivity. But like any powerful tool, it can be misused. When we carry over old habits from other languages or misinterpret Kotlin’s unique features, we risk creating “anti-patterns.”

Anti-patterns are recurring solutions to common problems that, despite their initial appeal, are actually ineffective, counterproductive, and detrimental to good design and maintainability. They’re the bad habits that look good on the surface but lead to headaches down the road.

Let’s dive into some prevalent Kotlin anti-patterns and, more importantly, learn how to embrace idiomatic Kotlin for cleaner, more robust code.

1. The Peril of GlobalScope: Unstructured Concurrency

The Anti-Pattern: Using GlobalScope.launch for starting coroutines in application code.

A key feature of Kotlin is Structured Concurrency, which mandates that every coroutine must be tied to a specific lifecycle. Ignoring this principle and using GlobalScope leads to unpredictable execution and resource leaks.

Quick Transformation: Instead of GlobalScope.launch { delay(5000); println("Done.") }, use myScope.launch { delay(5000); println("Done.") }.

Why it’s harmful: GlobalScope is like launching a rocket without a mission control. It doesn't belong to any specific lifecycle, making it incredibly difficult to manage. Coroutines launched with GlobalScope continue running until they complete or are explicitly cancelled. This often leads to resource leaks, unnecessary background work, and race conditions, especially in UI-driven applications where components have finite lifecycles.

The Idiomatic Kotlin Way: Structured Concurrency Kotlin’s Structured Concurrency ensures that coroutines are part of a hierarchy. When a parent scope is cancelled, all its children are automatically cancelled too, preventing leaks and making cancellation predictable.

// 🚫 Before: The resource leak waiting to happen
fun fetchDataGlobally() {
GlobalScope.launch {
// This coroutine lives forever, regardless of whether it's needed.
delay(5000)
println("Data fetched globally!")
}
}

// ✅ After: Tie the coroutine to a controlled scope (e.g., a ViewModel or a Service)
class DataFetcher(private val scope: CoroutineScope) {
fun fetchDataLocally() {
scope.launch {
try {
// Ensure structured termination upon scope cancellation
delay(5000)
println("Data fetched locally!")
} catch (e: Exception) {
if (e is CancellationException) {
println("Data fetching cancelled!") // Clean exit
}
}
}
}
}

Key Takeaway: Always tie your coroutines to a specific CoroutineScope that aligns with the lifecycle of the component needing the work.

2. The “Also This Is Null” Trap: Overusing also for Null Checks

The Anti-Pattern: Using ?.also { ... } as a verbose way to perform an action only if an object is not null.

While also is great for side-effects, using it purely for a non-null execution block is verbose and less clear than the highly-optimized let function.

Quick Transformation: Instead of myString?.also { print(it) }, use myString?.let { print(it) }.

Why it’s harmful: also is primarily for performing additional actions on an object and then returning the original object (T). When used purely for a null check and action, it's less readable and less direct than the let function, which is designed to execute a lambda on the non-null receiver and return the lambda's result.

The Idiomatic Kotlin Way: let for Non-Null Operations, Elvis for Defaults Use let for executing code only on a non-null value, and the Elvis operator (?:) for providing a default or fallback action.

var userEmail: String? = "alice@example.com"

// 🚫 Before: Using also, which is meant for side effects *on the original object*
userEmail?.also { email ->
println("User email is $email")
// If you returned something here, it would be discarded.
}

// ✅ After: Using let for non-null execution block
userEmail?.let { email ->
println("User email is $email")
// 'it' or 'email' is guaranteed non-null here.
// If we needed to transform and return a result, let would easily allow it.
}

// ✅ After: Using Elvis for default values
val displayName = userEmail ?: "Guest User"
println("Display name: $displayName")

Key Takeaway: Use let when you want to execute a block of code with a non-null receiver. Use also only when you need to perform a side-effect (like logging or debugging) and then continue the operation chain with the original receiver object.

3. The Inheritance Headache: Over-reliance on Deep Hierarchies

The Anti-Pattern: Designing complex class hierarchies with multiple levels of abstract classes and inheritance, often mimicking patterns from older languages.

Kotlin favors Composition over Inheritance and provides powerful tools like Sealed Classes and Interfaces to model domain logic more flexibly.

Quick Transformation: Instead of class Tiger : AbstractFeline(), use a data class Tiger(val behaviors: List<Behavior>) combined with interfaces.

Why it’s harmful: Deep inheritance creates rigid, tightly coupled designs. Changes high up the hierarchy can break many derived classes (the “fragile base class” problem). It also makes unit testing harder, as you often can’t test a derived class without involving its superclasses.

The Idiomatic Kotlin Way: Composition, Interfaces, and Sealed Classes

// 🚫 Before: Rigid Inheritance
abstract class AbstractAnimal {
abstract fun makeSound()
}
class Tiger : AbstractAnimal() {
override fun makeSound() = println("Roar!")
}

// ✅ After: Flexible Composition and Delegation
interface SoundMaker { fun makeSound() }
interface Hunter { fun hunt() }

class RoaringSound : SoundMaker { override fun makeSound() = println("Roar!") }
class LoneHunter : Hunter { override fun hunt() = println("Hunting alone!") }

// Use delegation and composition to combine behaviors
data class ComposedAnimal(
val name: String,
private val soundDelegate: SoundMaker,
private val hunterDelegate: Hunter? = null
) : SoundMaker by soundDelegate { // Delegation

fun tryHunt() {
hunterDelegate?.hunt() ?: println("$name cannot hunt.")
}
}

fun main() {
val tiger = ComposedAnimal("Tiger", RoaringSound(), LoneHunter())
tiger.makeSound() // Delegated call
tiger.tryHunt()
}

Key Takeaway: Favor composition and interfaces for defining behavior. Leverage sealed class when you have a restricted set of types that can extend a base class, especially with when expressions for exhaustive checks.

4. Ambient Singletons: Global State Gone Wild

The Anti-Pattern: Relying heavily on global object declarations, service locators, or other forms of global state for dependency management.

Global state creates tight coupling and makes code untestable. The modern, idiomatic way is to explicitly provide dependencies.

Quick Transformation: Instead of having MyProcessor.db.save(data), pass the dependency explicitly via the constructor: MyProcessor(dbImpl).save(data).

Why it’s harmful: Global singletons introduce tight coupling throughout your codebase. Classes directly depending on global objects become difficult to test in isolation because you can’t easily swap out the dependency for a mock. This makes code harder to maintain and refactor.

The Idiomatic Kotlin Way: Dependency Injection (DI) Explicitly providing dependencies (Dependency Injection) makes your code more modular, testable, and easier to understand.

// 🚫 Before: Tightly coupled global object
object GlobalCache {
fun save(key: String, value: Any) = println("Saving to global cache...")
}

class UserService {
fun updateProfile(id: String) {
GlobalCache.save("user-$id", "profileData") // Direct call to global state
}
}

// ✅ After: Dependency Injected via Constructor
interface AppCache {
fun save(key: String, value: Any)
}

class RedisCache : AppCache {
override fun save(key: String, value: Any) = println("Saving to Redis: $key")
}

class UserService(private val cache: AppCache) { // Dependency Injected
fun updateProfile(id: String) {
cache.save("user-$id", "profileData") // Testable, flexible
}
}

fun main() {
val userService = UserService(RedisCache())
userService.updateProfile("A456")
}

Key Takeaway: Strive for explicit dependency management. Inject dependencies via constructors. This promotes loose coupling and greatly improves testability.

5. Typealias for Type Safety: A False Sense of Security

The Anti-Pattern: Using typealias to rename a primitive type (like String or Int) with the intention of creating a new, distinct type for safety.

typealias is only an alternative name; it provides no new type safety. Use Value Classes for true compile-time safety.

Quick Transformation: Instead of typealias UserId = String, use @JvmInline value class UserId(val value: String).

Why it’s harmful: typealias is excellent for readability, but it does not create a new type. The compiler treats the alias as the underlying type (String in the example). This means you can accidentally pass a ProductId where a CustomerId is expected, bypassing type safety.

The Idiomatic Kotlin Way: Value Classes For true type safety and to prevent accidental type misuse, use value class. Value classes provide compile-time type safety with minimal runtime overhead.

// 🚫 Before: Typealias is just a rename, not a new type
typealias CustomerId = String
typealias ProductId = String

fun processOrder(customerId: CustomerId, productId: ProductId) { /* ... */ }

fun main() {
val myCustId: CustomerId = "C123"
val myProdId: ProductId = "P456"

// Problem: This compiles fine! Swap is possible.
processOrder(myProdId, myCustId)
}

// ✅ After: Value Class provides compile-time safety
@JvmInline
value class StrongCustomerId(val value: String)
@JvmInline
value class StrongProductId(val value: String)

fun processSafeOrder(customerId: StrongCustomerId, productId: StrongProductId) { /* ... */ }

fun mainSafe() {
val mySafeCustId = StrongCustomerId("C123")
val mySafeProdId = StrongProductId("P456")

processSafeOrder(mySafeCustId, mySafeProdId)

// 💥 Error! Type mismatch. Compiler prevents the swap.
// processSafeOrder(mySafeProdId, mySafeCustId)
}

Key Takeaway: Use typealias for readability and simplifying complex type signatures. For robust type safety, especially with primitive wrappers, use value class.

TL;DR: Quick Anti-Pattern Summary

To write clean, idiomatic Kotlin, avoid these five common pitfalls:

  • GlobalScope: Use a Lifecycle-aware CoroutineScope to enforce Structured Concurrency and prevent leaks.
  • ?.also { ... } for nulls: Use ?.let { ... } for non-null execution blocks or the Elvis Operator (?:) for providing defaults.
  • Deep Inheritance: Favor CompositionInterfaces, and Sealed Classes over complex class hierarchies.
  • Ambient Singletons: Use Dependency Injection (Constructor Injection) to manage dependencies explicitly.
  • typealias for Safety: Use the type-safe value class instead of typealias for wrapping primitives like IDs or email addresses.

Frequently Asked Questions (FAQs)

Are anti-patterns always “bad”?

Anti-patterns are generally considered bad practice because they lead to issues like maintainability problems, bugs, or reduced performance. The goal is to understand why they are anti-patterns and avoid them when better, idiomatic solutions exist.

How can I identify an anti-pattern in my own code?

Look for code that is:

  • Hard to test: Are you struggling to write isolated unit tests?
  • Hard to change: Do small modifications cause unexpected breakages elsewhere?
  • Overly verbose: Does it feel like you’re writing a lot of code for a simple task when Kotlin usually makes things concise? These are often red flags indicating an anti-pattern.

What’s the best way to learn idiomatic Kotlin?

Read the official documentation, study open-source Kotlin projects, and most importantly, use the IDE’s inspections — they are often tuned to suggest more concise, idiomatic Kotlin code.

Your Turn!

What Kotlin anti-patterns have you encountered in the wild? Or perhaps, which ones have you accidentally introduced and later refactored? Share your experiences in the comments below! Learning from each other is the best way to grow as a developer.

If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! 🙏

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Code Generation vs. Reflection: A Build-Time Reliability Analysis