GUIDE — Kotlin under the Discipline, v0.1
01Status. Beta; seventh language. One document by the owner's structure: Part I is the trunk (core Kotlin), Parts II–IV are inline area overlays that inherit it and rewire only named sections. The trunk is sovereign — Kotlin Multiplatform makes the JVM just one target, so this is not an overlay on the Java trunk; instead, Part II composes with the Java ecosystem overlays (Spring) at the JVM, and Part IV defines the Discipline's first inter-trunk boundary (Kotlin/JS ↔ GUIDE-TYPESCRIPT). Sections stay isomorphic to the other guides.
02Framing note — the seventh point of the typology. Rust enforces; TS permits but compiles; Python trusts; C++ demands a subset; Go prescribes; Java bifurcates; Kotlin inverts the defaults: everything Java left dangerous-by-default is opt-in here — null, mutability, inheritance (final by default), and in Multiplatform even platform access. Where the defaults already match the axioms, this guide is thin, like Go's. The gaps it closes: the vanished failure surface (checked exceptions removed and replaced with nothing at the signature level); the GlobalScope escape hatch in an otherwise natively-structured concurrency model; the DSL/operator culture (the language's superpower is also its R-021 risk zone); and platform types at the Java border. Patterns dissolve far: Strategy = interface + registry; Visitor = sealed + exhaustive when; Observer = Flow seam; Singleton = a keyword (object) — first-class syntax for the forbidden thing, governed below.
03Scope honesty. Long-lived system/product code. Gradle build scripts (themselves Kotlin DSL) are tooling, not cells. Code generated by KSP processors follows the generated-code rule: processor input is the taggable unit; processors generating domain logic are infra-only.
Part I — Core Kotlin (trunk)
0. Language baseline
- 04Kotlin 2.1+ (K2 compiler), target latest stable. Gradle with version catalogs + convention plugins — the ecosystem reality (KMP is Gradle-only); Maven legal for pure-JVM targets. Dependency locking on (A2).
allWarningsAsErrors = true.explicitApi()is MUST for cell modules — explicit visibility and return types on every public declaration: surface honesty as a compiler flag, not a review note.- Surface ratchet: kotlinx-binary-compatibility-validator (Apache-2.0) — committed
.apidumps, diffed in PRs; an unexplained surface change is a finding before it is an API break. - Lint gate: detekt (Apache-2.0) — and its baseline file is a native brownfield ratchet (BROWNFIELD §B1 shipped inside the tool: existing findings frozen, new ones fail, the file only shrinks). Formatting via one pinned formatter (ktlint/ktfmt) — zero discipline budget.
- Suppression policy (xfail-strict posture):
@Suppressonly with the narrowest scope, the specific check name, and a// reason; a conform sweep re-runs named checks against suppressed elements and fails stale entries. - Null discipline:
!!is banned in cells (the assert-shaped escape hatch);lateinitis banned in cells (two-phase-init smell — constructor injection removes the need); platform types (T!) are quarantined: every value arriving from Java interop is pinned to explicit nullability at the boundary before it travels.valis the default;varis justified. - Tests: kotlin-test in common code; JUnit 5 on JVM (EPL-2.0 flag inherited from the Java trunk); property testing via kotest-property (Apache-2.0) — a license upgrade over Java's EPL-flagged jqwik. MockK and Mockito are banned in cell tests — same rule, same reason as every sibling guide: capability injection makes module-graph mocking unnecessary.
1. Cells
05A cell is a Gradle module (in KMP, its commonMain source set — Part III makes that physics). explicitApi + the .api dump define its surface; promotion/extraction on the usual triggers.
- 06Import-is-execution, Kotlin edition:
objectandcompanion objectinitialize lazily on first touch; top-level property initializers run at file-class init. Side effects there are banned in cells.objectis legal only as a pure namespace (statelessvals and functions); anobjectholding mutable state or doing registration is the singleton the trunk forbids, now with keyword support. - Capabilities are injected, including the ones Kotlin is famous for forgetting:
CoroutineDispatcher/CoroutineContextis an injected capability — hardcodedDispatchers.IO/Dispatchers.Mainin a cell is a finding (and the reason cell tests never need a main-dispatcher rule);Clock(kotlinx-datetime) injected; filesystem/network/env never ambient. - Messages crossing seams are
data classes ofvals (ordata objects); collections crossing seams are read-only types honestly backed.
07Cell manifest (annotation carrier, §5):
08@SpecImplements("spec://org.vibevm.core/vibevm/modules/vibe-resolver/PROP-003#solver-upgrade", r = 2)
@Cell(seam = "DepSolver", variant = "sat", replaces = "naive", flag = "solver")
class SatDepSolver(
private val provider: DepProvider,
private val clock: Clock,
private val dispatcher: CoroutineDispatcher,
) : DepSolver { /* ... */ }
2. Seams
- 09A seam is an
interfacein the seams module; closed sets aresealed. Conformance is nominal — no assertions needed. suspendfunctions are the async seam form. This is a declared divergence from the Java trunk (which prescribes blocking seams on virtual threads): each trunk follows its platform's structured-concurrency culture, and at a mixed JVM boundary a one-function bridge (runBlocking/Dispatchersadapter) converts — the divergence is owned, not hidden.- Interface default implementations follow the trunk-wide rule: trivial combinators only, never business flow (R-021).
Flowat seams is cold; hot state (StateFlow/SharedFlow) is owned by a named state holder with an explicit lifetime, exposed read-only.
3. Registry and flags
10// registry module — the only flag reader and the only module depending on cells.
fun depSolver(cfg: AppConfig, p: DepProvider, clock: Clock, io: CoroutineDispatcher): DepSolver =
when (cfg.solver) { // provenance: default | env | cli | lockfile
Solver.SAT -> SatDepSolver(p, clock, io)
Solver.NAIVE -> NaiveDepSolver(p)
}
- 11Tiers: assembly tier = which modules (and, in KMP, which targets) are built — "is the code in the artifact"; runtime tier = a config data class bound once in
main.expect/actualnever encodes product variants — it is platform adaptation only; a product flag wearingexpectclothing is a finding. - No
ServiceLoader-style discovery, no reflection wiring, no DI container in the trunk; Parts II–III legalize containers strictly as composition-root machinery.
4. Errors as contract
12Kotlin removed Java's checked exceptions — deliberately — and put nothing in the signature where they stood: exceptions here are unchecked and undeclared, the most vanished failure surface in the set. The Discipline restores it the same way as everywhere:
- 13Seam-owned sealed results, consumed by exhaustive
whenas an expression, with noelsebranch — a new permitted subtype must break every consumer's build (the default-arm ban, fourth appearance):
14sealed interface SolveResult {
data class Ok(val resolution: Resolution) : SolveResult
data class Err(val error: SolveError) : SolveResult
}
data class SolveError(val code: Code, val spec: String, val cause: Throwable? = null) {
enum class Code { CYCLE, UNSATISFIABLE }
}
15spec carries the violated REQ URI (PROP-014 §2.6). kotlin.Result<T> is not a seam type — its error channel is pinned to Throwable, which is an implementation genus, not a domain contract. Arrow's Either/Raise (Apache-2.0) is parked exactly as Effect-TS was: an optional profile for FP-shaped carriers, not the trunk binding.
- 16Exceptions = invariant violations (panic analog). Boundary adapters translate library exceptions into seam errors.
- Cancellation sanctity — the №1 coroutine footgun: a broad
catch (e: Exception)silently eatsCancellationExceptionand breaks structured cancellation; every broad catch MUST rethrow it (detekt rule + conform check). - Structured concurrency is native — keep it that way:
GlobalScopeis banned (the language's one escape hatch from its own best idea);runBlockingonly atmain/test edges; scopes are owned (coroutineScope/supervisorScopeor an injected scope with a named lifetime); a coroutine that outlives its cell is a leak by definition.
5. specmark carrier
17The same specmark annotations as Java, defined once in commonMain and visible to every target: AnnotationRetention.BINARY (in the artifact for analysis, invisible to runtime reflection), @Repeatable, @MustBeDocumented — edges render in Dokka, so the carrier doubles as the doc surface. File-level scope uses Kotlin's native syntax — @file:SpecScope("<uri>", r = N) — because the file, not the package, is Kotlin's organizational unit (no package-info needed). ≤3 edges per item or split.
6. Naming (R-020/R-021 bindings)
- 18Computed
{Variant}{Seam}→SatDepSolver; linted against the manifest. - The Kotlin theater list — placement, not amputation: the language's DSL powers (lambda-with-receiver builders,
infix, operator overloading) are banned in domain cells and legal where they shine — boundaries, configuration, test fixtures (Gradle and Ktor routing are the canonical homes). Also banned in cells: operator overloading beyond value semantics; delegated properties with side effects (by lazyof a pure value is fine;observable/vetoableis hidden control flow); extension functions on foreign types sprayed outside the owning cell;kotlin-reflect; companion-object registries;!!/lateinit(§0).
7. Replacement protocol (R-040 binding)
19Differential oracle via kotest-property driving both cells through the seam (@SpecVerifies-tagged), divergences documented. Goldens follow the promotion protocol. On JVM the Java trunk's strict @KnownFailing JUnit extension carries over as the in-source twin of tests-baseline.json; in common tests, the baseline registry carries the weight alone (Go's situation) — stated, not hidden.
8. Risk table — trunk
| Footgun | Rule | Tier |
|---|---|---|
side effects in object/companion/top-level initializers |
§1 | T-syn |
mutable state in an object; companion registry |
§1, §6 | T-syn |
hardcoded Dispatchers.* in a cell |
§1 | T-syn |
GlobalScope; runBlocking outside main/tests; scope-leaking coroutine |
§4 | T-syn |
broad catch swallowing CancellationException |
§4 | T-syn (detekt) |
else branch on a sealed when; statement-when over sealed |
§4 | T-syn |
kotlin.Result in a seam signature |
§4 | T-syn |
!! / lateinit in a cell; unpinned platform type crossing inward |
§0 | T-syn + T-sem |
| DSL builder / operator magic / side-effectful delegate in domain code | §6 | T-syn |
kotlin-reflect in a cell |
§6 | T-lex |
| MockK/Mockito in a cell test | §0 | T-syn |
expect/actual encoding a product variant |
§3 | review + T-syn |
missing/changed .api dump without rationale |
§0 | build diff |
| flag read outside the registry | R-001 | T-syn |
| public export without own/inherited spec edge | PROP-014 §3.2-6 | T-syn + index |
9. Doc layer
21KDoc + Dokka on every public declaration (explicitApi already forces you to notice them): behavior, seam error codes with REQ URIs, nullability beyond the types, coroutine context expectations (which dispatcher, cancellation behavior), Flow temperature. @MustBeDocumented specmark edges render alongside. Spec stays thin.
Part II — Server & Desktop (area overlay)
22Inherits the trunk; JVM target composes with the Java ecosystem.
- 23ArchUnit rules apply verbatim — it reads bytecode and does not care that the source was Kotlin; the ring rules ("cells import no framework") transfer without translation. Error Prone does not apply (javac-only); detekt carries the lint weight.
- Spring composition:
GUIDE-JAVA-SPRINGapplies, with Kotlin clauses: thekotlin-spring/all-open compiler plugin is scoped to boundary stereotypes only — cells stayfinal(final-by-default is a Discipline ally; opening a cell for proxying is a finding). Constructor injection is the language's natural mode;@ConfigurationPropertiesbinds to data classes. The concurrency divergence resolves per carrier, documented once: suspend seams with coroutine-aware boundary (WebFlux/MVC-with-suspend) or the Java trunk's Loom stance with bridges — one model per repository. - Ktor (Apache-2.0) is the Kotlin-native boundary framework; its routing DSL is the placement rule in action — DSL at the boundary, plain calls inward.
- Persistence: Exposed/SQLDelight-class tools live at the boundary; SQLDelight's model (typed APIs generated from SQL) fits the generated-code rule cleanly — the
.sqfiles are the taggable inputs. - Desktop: Compose for Desktop is the JVM target of Part III's Compose rules — same state-hoisting discipline, same effect rules; packaging via jpackage.
| Additional risks | Rule | Tier |
|---|---|---|
| all-open applied to cell classes | II | build config audit |
| Spring/Ktor type in a seam signature | II | ArchUnit |
runBlocking inside request handling |
II | T-syn |
Part III — Kotlin Multiplatform: Android & iOS, Compose (area overlay)
25The headline: commonMain is the cell ring made physics. Common code cannot name a platform API — no android.*, no UIKit, no DOM exist in its classpath; the capability-injection rule every other guide enforces with linters is enforced here by the compiler's world model. Cells live in commonMain; androidMain/iosMain are boundary adapters.
- 26
expect/actualis the platform-capability seam — narrow declarations (clock, storage, secure random), actuals are adapters. Business seams remain ordinary interfaces in common code. (Product variants never wearexpect— trunk §3.) - iOS: modern Kotlin/Native memory model assumed (no freeze-era folklore). The exported surface is an XCFramework governed by
explicitApi+ the.apidump; Swift/ObjC interop is boundary work,suspendcrosses to Swift only through generated bridges/completion adapters at the boundary, never raw. - Android:
Contextis the platform god-object and physically cannot enter commonMain — the architecture problem Android spent a decade on, dissolved by source-set geometry. Lifecycle, permissions, services: boundary. DI: Hilt is compile-time codegen (A3-friendlier than runtime containers) and is confined to the boundary ring; Koin (runtime locator) likewise if a carrier insists; cells arenew-constructed inside producers either way. - Compose — the framework that agrees: Compose's own law is that composition must be side-effect-free — the import-is-execution instinct, adopted by a UI runtime. Bindings: state hoisting is capability injection for UI — stateless composables take state and event lambdas; business logic never lives in a composable; side effects only in
LaunchedEffect/DisposableEffectwith honest keys;CompositionLocalis thehttp.DefaultClientof Compose — banned for domain dependencies, legal for theme/density as designed;remember { }holds pure values. State holders/ViewModels are boundary orchestration talking to seams. UI models crossing into composition are stable by construction (data classes ofvals;@Immutablewhere inference needs help). Screenshot tests are goldens under the promotion protocol.
| Additional risks | Rule | Tier |
|---|---|---|
| platform API named in commonMain | III | compiler |
domain dependency via CompositionLocal |
III | T-syn |
| side effect in composition outside effect handlers | III | T-syn (compose lints) |
| business logic in a composable; stateful composable where hoisting fits | III | review + T-syn |
GlobalScope/unowned scope in a ViewModel |
trunk §4 | T-syn |
raw suspend exported to Swift |
III | export audit |
Part IV — Kotlin/JS (area overlay)
28The Discipline's first inter-trunk boundary. A Kotlin/JS artifact consumed by TypeScript code is a border between two guides of the same Discipline — GUIDE-KOTLIN on one side, GUIDE-TYPESCRIPT on the other. The contract is explicit:
- 29
@JsExportis the seam surface — minimal, deliberate, and the generated.d.tsis committed and diffed: a cross-language surface ratchet, the.apidump's twin. TS-side conform treats the artifact as a typed boundary module; Kotlin-side conform audits the export set. - Exported signatures avoid the classic traps: no
Longacross the border (JS numbers), no rawsuspend(Promise adapters at the boundary), collections crossed as arrays/readonly shapes. dynamicis banned in cells — it is Kotlin'sany, quarantined to boundary interop;externaldeclarations are the typed border to JS libraries (the inward-facing.d.ts).- npm dependencies are locked (
kotlin-js-storecommitted — A2); IR backend + ES modules assumed. - Release map, double hop: Kotlin/JS emits source maps, so the TS guide's chain extends — minified frame → JS → Kotlin source → item → REQ; ship/retain both map layers keyed by build id. Kotlin/Wasm: not baseline yet; named, not used.
| Additional risks | Rule | Tier |
|---|---|---|
dynamic in a cell |
IV | T-syn |
Long/raw suspend/non-exportable type in @JsExport surface |
IV | compiler + export audit |
uncommitted lockfile or .d.ts drift |
IV | build diff |
31First carrier note. No Kotlin exists in vibevm; no carrier is designated. Trunk and areas ship genre-complete and unexercised under the carrier-relative house clause: rules remain DRAFT until a first Kotlin carrier exists, and at its first milestone any rule without a conform check (or explicit WISH mark) is removed rather than carried as aspiration.