Functions are the unit of reasoning. Most rules here exist to keep that unit small, named, and side-effect-honest.
/** Shell: does the I/O, then delegates the decision to a pure core. */
suspend fun settleInvoice(id: InvoiceId, gateway: Gateway): Result<Receipt, SettleError> {
val invoice = repo.fetch(id).getOrElse { return Result.Err(it) }
val charge = computeCharge(invoice, discountPct = 0) // pure, named boolean-free args
val receipt = gateway.charge(charge).getOrElse { return Result.Err(it) }
audit.log(id, receipt) // side effect at the edge
return Result.Ok(receipt)
}
// Pure core — trivially testable, no I/O.
private fun computeCharge(invoice: Invoice, discountPct: Int): Cents {
require(discountPct in 0..100) { "discountPct out of range: $discountPct" }
require(invoice.total >= Cents.ZERO) { "total must be non-negative: ${invoice.total}" }
val kept = invoice.total.amount * (100 - discountPct) / 100
return Cents(kept)
}
private fun List<LineItem>.totalCents(): Cents = // extension on a type we own a chain over
Cents(sumOf { it.unitPrice.amount * it.quantity })The shell settleInvoice does I/O and pushes the computation into a pure helper (5.9), staying well under the 60-line ceiling (5.1). computeCharge is a block body because it holds locals (5.2) and opens with two require assertions over its inputs. discountPct = 0 is a named argument (5.4), and totalCents is an expression-bodied extension that adds a coherent operation (5.5, 5.2). Neither helper writes : Unit (5.12).
Reasoning, step by step:
- A function should do one thing — described by its name without "and."
- Hard cap: 60 lines including blanks and the closing brace. Aim 15–30.
- If a function exceeds the cap, extract a private helper, a
whenover a sealed type, or aSequencepipeline. The name of the extracted helper documents the section you removed. - Top-level functions, member functions, and lambdas all count. A 60-line lambda is the same problem as a 60-line method.
- KDoc lines don't count.
Enforcement: detekt's LongMethod rule, threshold = 60; review rejects names containing "and."
Reasoning, step by step:
fun double(x: Int): Int = x * 2is unambiguous. The body is the return value.- The moment a body needs a local
val, twowhenarms with side effects, or sequential statements, use{ ... return ... }. - Public-API expression bodies still declare return types explicitly. Inference is allowed in private helpers if it's obvious.
- Anti-pattern: stuffing a multi-step body into
run { ... }to keep using=. See 01-formatting-and-tooling.md §1.4.
Enforcement: review; detekt's ExplicitApiMode requires return types on public expression bodies.
Reasoning, step by step:
- Kotlin has default arguments. Java's overload-explosion antidote is to use them.
fun connect(host: String, port: Int = 443, timeout: Duration = 10.seconds)replaces three overloads.- Callers use named arguments to skip middle defaults:
connect(host, timeout = 5.seconds). - Caveat on JVM: default args don't directly create Java overloads — add
@JvmOverloadsif Java callers exist. See JVM guide. - Beware of using mutable default values like
mutableListOf()— each call evaluates the default fresh, but if the default is a captured singleton, every caller shares state. PreferList<T> = emptyList()patterns.
Enforcement: review; @JvmOverloads checked where Java callers exist.
5.4 — Named arguments at every call site with more than two parameters of the same type, or any boolean.
Reasoning, step by step:
connect("localhost", 8080, 10000, true, false)— readers cannot tell what those mean.connect(host = "localhost", port = 8080, timeoutMillis = 10000, useTls = true, followRedirects = false)is self-documenting.- Hard rule: booleans at call sites must be named.
setVisible(true)is fine because the function name implies the parameter;withRetries(true)is not. - Adjacent same-typed parameters (
crop(image, 10, 20, 30, 40)— which two are width/height?) must be named. - Lint: detekt's
NamedArgumentsrule, threshold = 3 (or 2 for booleans).
Enforcement: detekt's NamedArguments rule, threshold = 3 (2 for booleans).
Reasoning, step by step:
- Extensions are not polymorphic.
String.greet()resolves at compile time based on the static type. This is by design. - Use extensions to (a) add operations to types you don't own (
String.parseIso()), (b) provide infix or operator forms (Duration.times(...)), (c) attach domain-specific helpers without inflating the host class. - Don't use extensions to fake inheritance — if the operation needs polymorphism, it belongs as a method.
- Scope rule: the smallest scope that compiles is the right one. Local extension inside a function > private file-level extension > top-level extension > internal extension > public extension. Public extensions become part of your API forever.
- Don't extend types you fully own when a regular member would do — methods participate in inheritance, extensions don't.
Enforcement: review; visibility kept to the smallest scope that compiles.
Reasoning, step by step:
inlinecopies the function body (and its lambda arguments) at every call site. It eliminates lambda allocation and call overhead.- Use it when: (a) the function takes a lambda and is called in a hot path; (b) you need
reifiedtype parameters; (c) the function is small enough that inlining is cheap (~10 lines of body). - Don't inline (a) large functions — every call site bloats; (b) functions without lambda parameters where the gain is marginal; (c) functions you'll refactor heavily — inlining ABI-couples callers to the body.
crossinlineif you pass the lambda to another function that captures it.noinlineto opt a specific lambda out of inlining (e.g., to store it in a variable). The compiler errors will tell you exactly when you need these.
Enforcement: review; detekt flags inline on large bodies and parameterless functions.
Reasoning, step by step:
-
Kotlin's five scope functions look interchangeable; they aren't. Pick by what should be returned and whether the body wants
thisorit. -
Decision table:
Function Receiver in body Returns Use for letitlambda result nullable resolution, type transform runthislambda result grouped ops on receiver, return derived value withthislambda result same as run, when receiver isn't a chainapplythisreceiver configuring a freshly-created object alsoitreceiver side effect (logging, validation, mutation) -
Common mistakes:
applyto compute a derived value (userun).letto configure a builder (useapply).alsofor a transformation (uselet).
-
Anti-pattern: chaining
?.let { it.foo }?.let { it.bar }?.let { it.baz }. Use?.foo?.bar?.bazdirectly. -
Anti-pattern: scope functions to avoid declaring a local
val. A named local is often clearer than awithblock. Don't compress for compression's sake.
Enforcement: review against the decision table; detekt's NestedScopeFunctions flags ?.let chains.
Reasoning, step by step:
fun greet(vararg names: String)is convenient for ad-hoc calls (greet("a", "b", "c")).- It costs: each call allocates an array. Inside a hot loop, this is real.
- Use
varargwhen (a) the call site varies between literals, and (b) the function is the call site's terminal point. - Otherwise take
List<T>orIterable<T>. Callers can*names.toTypedArray()orlistOf(...)as they prefer.
Enforcement: review; vararg rejected on hot-path signatures that take a List cleanly.
Reasoning, step by step:
- A pure function: same input → same output, no observable side effect. Pure functions are easier to test, parallelize, and reason about.
- Push side effects (I/O, logging, state mutation) to the edges — typically a thin shell function that calls pure helpers.
- Sample shape:
// shell — does the I/O suspend fun loadAndProcess(id: UserId): Result<Report, LoadError> { val raw = http.fetch(id).getOrElse { return Result.Err(it) } val report = buildReport(raw) // pure audit.log(id, report) // side effect, separated return Result.Ok(report) } // pure core — trivially testable private fun buildReport(raw: RawData): Report = /* ... */
- This is not religion. A logger call inside a function is fine. A function that does I/O, parsing, validation, and persistence is not.
Enforcement: review; pure cores carry unit tests with no I/O fixtures.
Reasoning, step by step:
xs.map(::parseInt)is clearer thanxs.map { parseInt(it) }.- Member references work:
xs.map(String::length). Bound references too:xs.map(parser::parse). - Use lambdas when the body does more than the reference would: arg reordering, partial application, additional logic.
Enforcement: review; detekt's RedundantLambdaArrow and pass-through-lambda inspections.
Reasoning, step by step:
- The JVM has no native tail-call elimination. Naive recursion stack-overflows on deep inputs.
- Kotlin's
tailrecmodifier rewrites tail-recursive functions to loops at compile time. Use it. - Rule: no recursion in library code without
tailrec. Withtailrec, prove the recursive call is genuinely in tail position (the compiler will tell you if it isn't). - Most "tail-recursive" candidates read better as a
whileloop or afold. Reach fortailreconly when the recursive structure mirrors the problem (tree walks, parser combinators).
Enforcement: review; recursion in library code requires tailrec, which the compiler verifies.
Reasoning, step by step:
fun log(msg: String) { println(msg) }returnsUnitby inference. Writing: Unitis noise.- Exception: suspend functions where the explicit type aids reading. Even then, omit it unless the reader benefits.
Unitis the value, not the absence of one — you can pass it around. You rarely should.
Enforcement: detekt's OptionalUnit rule flags explicit : Unit.
inlineperformance trade-offs: chapter 15 (Performance).- Scope functions in detail (with the decorator pattern worked example): chapter 07 (Kotlin Idioms).
- Extension functions on JVM and
@JvmNamemangling: JVM guide chapter 01.