DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Kotlin: Android and Backend Patterns That Ship

Cursor Rules for Kotlin: Android and Backend Patterns That Ship

If you ship Kotlin in production — Android app, Ktor service, Spring Boot backend — you have probably watched Cursor or Claude Code generate code that looks fine in a tutorial and falls apart on review. Suspend functions that block the main thread. ViewModels that expose MutableStateFlow to the UI. runBlocking inside coroutines. !! operators sprinkled like seasoning. Sealed when expressions saved by an else -> Unit that quietly swallows new variants.

The fix is not better prompting. It is rules the AI must follow on every generation.

Below are 8 Cursor rules that cover the patterns where AI most often produces Kotlin that compiles but does not ship: suspend function discipline, structured concurrency, null safety, data classes, sealed exhaustiveness, extension function conventions, Android ViewModel + StateFlow shape, and Ktor/Spring backend idioms. Each rule includes the exact text to drop into .cursorrules, plus a before/after diff so you can see what changes.


1. Suspend Functions Must Be Main-Safe and Dispatcher-Aware

Without this rule, AI writes suspend fun and then calls Thread.sleep, blocking JDBC, or File.readText directly inside it. The function is suspend in name only — it blocks whichever thread it is called on, which on Android is usually the main thread.

The rule:

Every suspend function must be main-safe. If the body performs blocking I/O,
CPU work, or interacts with a blocking JDBC/JPA driver, wrap the body in
withContext(Dispatchers.IO) or Dispatchers.Default — do not require callers
to switch dispatchers. Never call Thread.sleep, runBlocking, or
.blockingGet() from inside a suspend function. Use delay() for waiting.
Suspend functions should not return Deferred, Job, or Flow — return the
plain value or throw.
Enter fullscreen mode Exit fullscreen mode

Bad — suspend in name, blocking in body:

suspend fun loadUserProfile(id: Long): UserProfile {
    Thread.sleep(50) // simulated latency, but blocks the main thread
    val row = jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?", id)
    return row.toProfile()
}
Enter fullscreen mode Exit fullscreen mode

Good — main-safe, dispatcher contained:

suspend fun loadUserProfile(id: Long): UserProfile = withContext(Dispatchers.IO) {
    delay(50)
    val row = jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?", id)
    row.toProfile()
}
Enter fullscreen mode Exit fullscreen mode

Now loadUserProfile is safe to call from viewModelScope on Android or from a Ktor handler without thinking about dispatchers. The blocking JDBC call runs on the IO pool. delay cooperates with cancellation; Thread.sleep does not.


2. Structured Concurrency — Ban GlobalScope and runBlocking in Production Code

GlobalScope.launch is the most copy-pasted snippet on Stack Overflow, and AI happily reproduces it. The result: coroutines that outlive the screen that started them, leak resources, and silently swallow exceptions because nothing is awaiting the Job.

The rule:

Never use GlobalScope outside of application-bootstrap code. Launch coroutines
from a structured scope tied to a lifecycle: viewModelScope, lifecycleScope,
or an injected CoroutineScope owned by the caller. Use supervisorScope when
sibling coroutines should fail independently. Never call runBlocking in
production code paths — it is allowed only in main(), tests, and Gradle tasks.
Always install a CoroutineExceptionHandler on top-level scopes.
Enter fullscreen mode Exit fullscreen mode

Bad — fire and forget, no error handling:

class OrderService {
    fun processOrder(order: Order) {
        GlobalScope.launch {
            paymentGateway.charge(order)
            runBlocking { notificationService.send(order.userId) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — injected scope, supervised children, explicit error handling:

class OrderService(
    private val paymentGateway: PaymentGateway,
    private val notifications: NotificationService,
    private val scope: CoroutineScope,
) {
    private val errorHandler = CoroutineExceptionHandler { _, e ->
        logger.error("Order pipeline failed", e)
    }

    fun processOrder(order: Order): Job = scope.launch(errorHandler) {
        supervisorScope {
            val charged = async { paymentGateway.charge(order) }
            charged.await()
            launch { notifications.send(order.userId) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The scope is injected, so tests pass TestScope for deterministic execution. Cancelling the scope cancels every in-flight charge or notification. A failed notification no longer kills the payment because of supervisorScope.


3. Null Safety — Ban !! and Platform-Type Leaks

!! is the Kotlin equivalent of // trust me. It silences the compiler and reintroduces every NullPointerException Kotlin was built to prevent. AI uses it constantly because it is the shortest path to a green build.

The rule:

Never use the !! operator. If a value might be null, handle it explicitly with
?., ?:, ?.let, requireNotNull(value) { "msg" } or checkNotNull(value) { "msg" }
with a message describing the invariant. When calling Java APIs that return
platform types, annotate the local with an explicit nullable or non-null
type — never let a platform type propagate. Prefer Elvis-throw
(value ?: error("invariant: ...")) over !! when a null indicates a programmer
bug.
Enter fullscreen mode Exit fullscreen mode

Bad — !! chains hide the real failure:

fun chargeDefaultCard(userId: Long, amount: Money) {
    val user = repo.findById(userId)!!
    val card = user.defaultPaymentMethod!!
    paymentGateway.charge(card.token!!, amount)
}
Enter fullscreen mode Exit fullscreen mode

Good — every null has a meaningful failure mode:

fun chargeDefaultCard(userId: Long, amount: Money) {
    val user = repo.findById(userId)
        ?: throw UserNotFoundException(userId)
    val card = user.defaultPaymentMethod
        ?: throw PaymentException("User $userId has no default payment method")
    val token = card.token
        ?: error("Invariant violated: stored card ${card.id} has no token")
    paymentGateway.charge(token, amount)
}
Enter fullscreen mode Exit fullscreen mode

When this fails in production the stack trace tells you which invariant broke and which entity was involved — not just NullPointerException at OrderService.kt:42.


4. Data Classes for Values — Immutable, copy-Driven, No var

AI defaults to plain classes with var properties because that is what Java looks like. You lose equals, hashCode, copy, destructuring, and the implicit contract that a value never mutates underneath you.

The rule:

Use data class for any type whose identity is its contents (DTOs, value
objects, UI state, API request/response). All properties must be val.
Mutation produces a new instance via copy(). Use @JvmRecord on data classes
that cross a Java boundary. Never put behavior with side effects on a data
class — only pure computed properties or extension functions. Use
@Serializable from kotlinx.serialization for wire types instead of writing
toJson/fromJson by hand.
Enter fullscreen mode Exit fullscreen mode

Bad — mutable plain class with manual boilerplate:

class UserProfile(
    var name: String,
    var email: String,
    var role: Role,
) {
    fun promote() { role = Role.ADMIN }

    override fun equals(other: Any?): Boolean {
        if (other !is UserProfile) return false
        return name == other.name && email == other.email && role == other.role
    }

    override fun hashCode() = Objects.hash(name, email, role)
}
Enter fullscreen mode Exit fullscreen mode

Good — immutable data class with copy:

@Serializable
data class UserProfile(
    val name: String,
    val email: String,
    val role: Role,
)

fun UserProfile.promoted(): UserProfile = copy(role = Role.ADMIN)
Enter fullscreen mode Exit fullscreen mode

equals, hashCode, toString, and copy are generated. The promotion is a pure function that returns a new value. @Serializable removes the JSON boilerplate. Code that holds a UserProfile cannot mutate it, which means it is safe to share across coroutines without a lock.


5. Sealed Hierarchies and Exhaustive when — Ban else -> Unit

Sealed classes are how Kotlin gives you compiler-checked case analysis. The compiler will only enforce exhaustiveness if you treat the when as an expression and refuse to add an else branch. AI reflexively adds else -> Unit or else -> {} to silence the warning, which defeats the entire point.

The rule:

Model results, UI state, events, and protocol messages with sealed interface
or sealed class. Every when expression over a sealed type must be used as an
expression (assigned, returned, or fed to a function) so the compiler enforces
exhaustiveness. Never write else -> Unit or else -> {} on a sealed when.
When adding a new variant, the build must break at every site that handles
the type — that breakage is the feature.
Enter fullscreen mode Exit fullscreen mode

Bad — else -> Unit swallows new variants:

sealed interface PaymentResult {
    data class Success(val txId: String) : PaymentResult
    data class Declined(val reason: String) : PaymentResult
    data class Error(val cause: Throwable) : PaymentResult
}

fun handle(result: PaymentResult) {
    when (result) {
        is PaymentResult.Success -> ledger.record(result.txId)
        is PaymentResult.Declined -> notify("Card declined: ${result.reason}")
        else -> Unit // a new Fraud variant added later silently does nothing
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — when as expression, no else:

fun handle(result: PaymentResult): HandledOutcome = when (result) {
    is PaymentResult.Success -> HandledOutcome.Recorded(ledger.record(result.txId))
    is PaymentResult.Declined -> HandledOutcome.Notified(notify("Declined: ${result.reason}"))
    is PaymentResult.Error -> HandledOutcome.Logged(logger.error("Charge failed", result.cause))
}
Enter fullscreen mode Exit fullscreen mode

Add a Fraud variant tomorrow and the build fails at every when site until each one is updated. That is exactly the safety net sealed hierarchies are supposed to give you.


6. Extension Function Conventions — Pure, Discoverable, Properly Located

Extension functions are powerful enough to be dangerous. AI scatters them across random files, gives them side effects, or writes extensions on Any? that pollute autocomplete everywhere.

The rule:

Use extension functions to add behavior to types you do not own (stdlib,
Android framework, third-party). Place extensions on type Foo in a file
named FooExtensions.kt under a package that mirrors the consumer module.
Extensions must be pure — no I/O, no mutation of external state. Do not
write extensions on Any or Any?. Prefer extension properties for O(1)
computed values; use functions when the operation has cost or arguments.
Internal helpers stay private; library extensions are explicit about
visibility and module.
Enter fullscreen mode Exit fullscreen mode

Bad — utility object, side effects, polluting receiver:

object StringUtils {
    fun toSlug(input: String): String =
        input.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
}

fun Any?.logAndReturn(): Any? {
    println(this) // side effect, terrible receiver
    return this
}
Enter fullscreen mode Exit fullscreen mode

Good — focused, pure extensions on real types:

// strings/StringExtensions.kt
fun String.toSlug(): String =
    lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')

val String.isBlankOrNull: Boolean
    get() = isBlank()
Enter fullscreen mode Exit fullscreen mode

The slug function is pure and discoverable via autocomplete on any String. There is no extension on Any? polluting every receiver in the project.


7. Android ViewModel + StateFlow — Expose Read-Only State, Never the Mutable

The single most common Android-AI failure is exposing MutableStateFlow directly from a ViewModel. The UI can then mutate state from anywhere, the unidirectional data flow is gone, and racing emissions corrupt the UI.

The rule:

Android ViewModels expose UI state as a single StateFlow<UiState>, where
UiState is a sealed interface or a data class. The MutableStateFlow that
backs it is private. All state updates happen inside the ViewModel via
update { ... } on the MutableStateFlow. Side-effect events (navigation,
toasts, snackbars) flow through a Channel exposed as a Flow with
receiveAsFlow(), never as a StateFlow. Coroutines launch from
viewModelScope only. UI collects with collectAsStateWithLifecycle in
Compose or repeatOnLifecycle in Views — never collect bare from
lifecycleScope.launch.
Enter fullscreen mode Exit fullscreen mode

Bad — mutable state leaked, race-prone updates, Channel as StateFlow:

class CheckoutViewModel : ViewModel() {
    val state = MutableStateFlow(CheckoutState())
    val toast = MutableStateFlow<String?>(null)

    fun submit() {
        viewModelScope.launch {
            state.value = state.value.copy(loading = true)
            val result = repo.submit(state.value.cart)
            state.value = state.value.copy(loading = false, result = result)
            toast.value = "Order placed"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — read-only state, atomic updates, events on a Channel:

class CheckoutViewModel(
    private val repo: CheckoutRepository,
) : ViewModel() {
    private val _state = MutableStateFlow(CheckoutState())
    val state: StateFlow<CheckoutState> = _state.asStateFlow()

    private val _events = Channel<CheckoutEvent>(Channel.BUFFERED)
    val events: Flow<CheckoutEvent> = _events.receiveAsFlow()

    fun submit() {
        viewModelScope.launch {
            _state.update { it.copy(loading = true) }
            val result = repo.submit(_state.value.cart)
            _state.update { it.copy(loading = false, result = result) }
            _events.send(CheckoutEvent.OrderPlaced)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Compose layer collects with collectAsStateWithLifecycle() and consumes one-shot events without re-firing them on configuration change. State updates are atomic via update { }, so concurrent submit calls cannot interleave a stale copy.


8. Ktor and Spring Backend Idioms — Inject Dependencies, Map Errors, No Logic in Routes

On the backend, AI tends to either dump everything inside the route handler (Ktor) or sprinkle @Autowired on fields and let exceptions bubble as 500s (Spring). Both produce code that is hard to test and hostile to operators.

The rule:

Backend handlers — Ktor routes or Spring @RestController methods — do four
things only: deserialize input, validate, delegate to a service, serialize
the response. They never contain business logic, database calls, or
external API calls.

Spring: use constructor injection (no @Autowired on fields). Mark fields
private val. Map domain exceptions to HTTP status codes via a single
@ControllerAdvice. Return record/data class DTOs, never JPA entities.

Ktor: install ContentNegotiation with kotlinx.serialization. Use
StatusPages to map domain exceptions to responses. Define routes in
extension functions on Route grouped by resource. Inject services through
the application's DI container (Koin) — never construct services inside a
handler.
Enter fullscreen mode Exit fullscreen mode

Bad — Ktor handler doing everything:

fun Application.orderRoutes() {
    routing {
        post("/orders") {
            val body = call.receive<Map<String, Any>>()
            val userId = (body["userId"] as Number).toLong()
            val total = (body["total"] as Number).toDouble()
            val conn = DriverManager.getConnection(System.getenv("DB_URL"))
            val stmt = conn.prepareStatement(
                "INSERT INTO orders (user_id, total) VALUES (?, ?)"
            )
            stmt.setLong(1, userId)
            stmt.setDouble(2, total)
            stmt.executeUpdate()
            call.respondText("ok")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — Ktor route delegates to an injected service, errors mapped centrally:

@Serializable
data class CreateOrderRequest(val userId: Long, val total: Double)

@Serializable
data class OrderResponse(val id: Long, val userId: Long, val total: Double)

fun Route.orderRoutes(orders: OrderService) {
    route("/orders") {
        post {
            val request = call.receive<CreateOrderRequest>()
            val created = orders.create(request.userId, request.total)
            call.respond(HttpStatusCode.Created, created.toResponse())
        }
    }
}

fun Application.installErrorHandling() {
    install(StatusPages) {
        exception<UserNotFoundException> { call, e ->
            call.respond(HttpStatusCode.NotFound, ErrorBody(e.message ?: "Not found"))
        }
        exception<ValidationException> { call, e ->
            call.respond(HttpStatusCode.BadRequest, ErrorBody(e.message ?: "Invalid"))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The route is now four lines and trivially testable: pass a fake OrderService, hit the route, assert the response. Domain exceptions become correct HTTP responses without per-handler try/catch. The Spring equivalent is @RestController with constructor injection plus a @ControllerAdvice mapping the same exception types — the shape is identical.


Drop These Into Your Project

Save the eight rule blocks to .cursorrules (or your project's CLAUDE.md) and Cursor applies them on every generation. The change shows up immediately: suspend functions that do not block the UI thread, ViewModels that expose read-only StateFlow, sealed whens the compiler protects, and backend handlers you would actually merge.

If you want the expanded pack — these 8 plus 42 more covering Compose state hoisting, Room DAO patterns, Ktor client interceptors, kotlinx.serialization edge cases, and multiplatform expect/actual discipline — it is bundled here: Cursor Rules Pack v2.

Top comments (0)