"Classes Without Ceremony" — Constructors, Properties and Objects
Kabir starts typing getters and Lena stops him. We cover properties instead of fields, accessors and explicit backing fields, primary constructors and init order, final-by-default classes, Kotlin's different protected and internal, objects and companions instead of static, nested vs inner classes, and enums with entries.
Story Opening
They were pairing on the catalog’s Product class, Kabir driving. He typed what fourteen years had taught his fingers:
// Fragment of story/JavaShapedProduct.kt// What Kabir typed first: a Java class, transliterated.class JavaShapedProduct { private val name: String private var pricePaise: Long
constructor(name: String, pricePaise: Long) { this.name = name this.pricePaise = pricePaise }
fun getName(): String = name fun getPricePaise(): Long = pricePaise fun setPricePaise(pricePaise: Long) { this.pricePaise = pricePaise }}Lena waited until he reached for the equals method, then took the keyboard and typed one line under it:
// Fragment of story/JavaShapedProduct.kt// What Lena typed: the same JVM class, as far as Java callers can tell.class Product(val name: String, var pricePaise: Long)“Jackson needs getters,” Kabir said. “Hibernate needs a constructor. Our Java reporting job calls getPricePaise() directly.”
“Then let’s ask the bytecode what it has,” said Lena, and opened a terminal.
Java → Kotlin: The Quick Map
| Java | Kotlin | Note |
|---|---|---|
| Field + getter + setter | val / var property | The field is an implementation detail and may not exist |
| Constructor assigning fields | Primary constructor class P(val name: String) | Parameters with val/var become properties |
| Constructor body | init { } blocks | Run in declaration order, interleaved with initialisers |
| Overloaded constructors | Default arguments, or constructor(…) : this(…) | Secondary constructors must delegate |
| Classes open by default | Classes and members final by default | open to allow extension |
@Override (optional) | override (mandatory) | An override stays open unless marked final |
Iface.super.m() | super<Iface>.m() | Same rule for conflicting defaults |
| Package-private (default) | — | Doesn’t exist |
protected | protected | Subclasses only: no package access (for Kotlin callers) |
| (module boundary via JPMS) | internal | Visible within one compilation module |
static members | companion object | A real object; @JvmStatic for Java callers |
| Singleton pattern | object | Thread-safe, lazily initialised |
| Anonymous class | object : Iface { } | Can modify captured locals |
static class Nested | class Nested | Nested is static by default |
| Inner class | inner class | Must ask for the outer reference |
Enum.values() | Enum.entries | A cached list instead of a new array |
Conceptual Deep-Dive
A property is an API, not a field
In Java, a “property” is a convention: a private field, a getter and maybe a setter, kept in sync by hand and recognised by tools because of their names. Kotlin turns the convention into a language construct. var pricePaise: Long declares the API, a getter and a setter, and the compiler decides whether a backing field is needed to support it.
Three consequences follow:
- Callers never see the field.
product.pricePaise = 15_900always calls the setter, from Kotlin and from Java. So you can add validation later without changing a single caller. In Java, the same change from a public field to a setter breaks every call site. - Some properties have no field at all. A property computed in its getter costs nothing to store, and from the outside it is indistinguishable from a stored one.
- Reading a property is a method call. That’s why Part 2’s smart casts refused to work on
openor custom-getter properties: the second read might return something different.
Closed by default
Java classes are open unless marked final. Kotlin flips that: classes and members are final unless marked open. This is Effective Java’s item “design and document for inheritance or else prohibit it”, made the default. A subclass that overrides a method the base class calls from its constructor, or that depends on undocumented self-use, is a fragility you now have to opt into explicitly.
The practical cost lands in Spring, which subclasses your beans to create proxies for @Transactional, @Configuration and friends. Part 12 shows the kotlin-spring compiler plugin, which opens those classes for you.
No static: objects instead
Kotlin has no static keyword. What Java expresses with static members, Kotlin expresses with real objects: an object declaration is a class and its single instance, and a companion object is an object attached to a class that holds what would have been its static members. Because they are objects, they can implement interfaces, be passed as arguments and hold state. Static members can do none of that. On the JVM each is still a class with a static field holding the instance, as the technical section shows.
Technical Explanation
What a property compiles to
Lena’s terminal answered Kabir’s question:
public final class com.shelfwise.part03.story.Product { private final java.lang.String name; private long pricePaise; public com.shelfwise.part03.story.Product(java.lang.String, long); public final java.lang.String getName(); public final long getPricePaise(); public final void setPricePaise(long);}This is exactly the class Kabir was typing by hand: private fields, a constructor, JavaBean getters and a setter for the var. Java code uses it the way it always has:
// CallsProduct.java, in the same moduleProduct product = new Product("Toor Dal 1kg", 16_500);product.setPricePaise(15_900);System.out.println(product.getName() + " " + product.getPricePaise()); // -> Toor Dal 1kg 15900The reporting job and Jackson’s serialiser see ordinary getters. Deserialising is another matter. With no no-arg constructor and no setters for the vals, Jackson needs jackson-module-kotlin to call the primary constructor, and Hibernate needs a no-arg constructor and non-final classes. Part 13 sets up both.
Accessors, field and private set
class ShelfSlot(val aisle: Int, val position: Int, capacity: Int) { // A property with an initialiser has a backing field. 'capacity' (no val) is only a constructor parameter. var unitsOnShelf: Int = 0 // A custom setter: 'field' is the backing field. Writing 'unitsOnShelf = value' here would recurse. set(value) { require(value in 0..maxUnits) { "slot holds 0..$maxUnits units, got $value" } field = value }
val maxUnits: Int = capacity
// A custom getter with no initialiser and no 'field': computed on every read, no backing field at all. val code: String get() = "A$aisle-P$position"
val isEmpty: Boolean get() = unitsOnShelf == 0
// Readable everywhere, writable only inside the class. var lastRestockedBy: String = "nobody" private set
fun restock(units: Int, by: String) { unitsOnShelf += units // goes through the setter, so the range check applies lastRestockedBy = by }}
fun main() { val slot = ShelfSlot(aisle = 7, position = 3, capacity = 24) println(slot.code) // -> A7-P3 println(slot.isEmpty) // -> true
slot.restock(20, by = "kabir") println("${slot.unitsOnShelf} by ${slot.lastRestockedBy}") // -> 20 by kabir // slot.lastRestockedBy = "lena" // error: cannot access 'lastRestockedBy': it is private in 'com.shelfwise.part03.properties.ShelfSlot'.
try { slot.restock(10, by = "kabir") } catch (e: IllegalArgumentException) { println(e.message) // -> slot holds 0..24 units, got 30 }}The bytecode shows which properties earned a field:
public final class com.shelfwise.part03.properties.ShelfSlot { private final int aisle; private final int position; private int unitsOnShelf; private final int maxUnits; private java.lang.String lastRestockedBy; public final int getUnitsOnShelf(); public final void setUnitsOnShelf(int); public final java.lang.String getCode(); // no field: computed public final boolean isEmpty(); // no field, and no "get" prefix for is-names public final java.lang.String getLastRestockedBy(); // no setter emitted: it is private ...}The rules:
- A property gets a backing field when it has an initialiser, or when an accessor uses
field.codeandisEmptyhave neither, so they have no field. - Inside an accessor,
fieldis the only way to touch the storage. Assigning to the property itself calls the setter again, and that recursion ends in aStackOverflowError. capacityhas noval, so it is a constructor parameter only. Initialisers likemaxUnits = capacitycan read it; methods cannot.- A property whose name starts with
isgets a getter with the same name (isEmpty()), following the JavaBean convention for booleans.
Explicit backing fields (Stable in 2.4)
A common Kotlin pattern exposes a read-only view of internally mutable state. Until 2.4 it needed two properties: a private val _changes = mutableListOf<Long>() and a public val changes: List<Long> get() = _changes. Now one property can declare its field’s type separately:
// Fragment of properties/BackingFields.kt// Explicit backing field (Stable in Kotlin 2.4.0): one property, two types.// Outside the class it is a List<Long>; inside, the compiler knows the field is a MutableList.class PriceHistory { val changes: List<Long> field = mutableListOf()
fun record(pricePaise: Long) { changes += pricePaise // smart-cast to MutableList<Long> inside the class }}
fun main() { val history = PriceHistory() history.record(16_500) history.record(15_900) println(history.changes) // -> [16500, 15900] // Outside the class, += on a read-only List means 'changes = changes + 1', which needs a var: // history.changes += 1 // error: 'val' cannot be reassigned. // The read-only view is a compile-time guarantee only: the object really is a MutableList. println(history.changes is MutableList<*>) // -> true}field = mutableListOf() declares the backing field’s own type. Inside the class, changes is smart-cast to that type. Outside, callers see only List<Long>. The bytecode is one private field and one getter, exactly what the two-property version compiled to. Explicit backing fields were Experimental in Kotlin 2.3 (behind -Xexplicit-backing-fields) and are Stable, with no flag, since 2.4.0.
Note the last line of the example: the list is mutable, and a caller who casts can mutate it. Part 7 has more on the difference between read-only and immutable.
Constructors and initialisation order
// The primary constructor is part of the class header. 'val'/'var' parameters become properties;// plain parameters ('openedYear') are visible only to initialisers and init blocks.class Store(val code: String, val city: String, openedYear: Int, val aisles: Int = 12) {
// Initialisers and init blocks run top to bottom, interleaved, as one constructor body. val ageInYears: Int = 2026 - openedYear.also { println("1. property initialiser") }
init { println("2. first init block") // println(label) // error: variable 'label' must be initialized. require(code.matches(Regex("[A-Z]{3}-\\d{3}"))) { "bad store code: $code" } }
val label: String = "$code ($city)".also { println("3. second property initialiser") }
init { println("4. second init block") }
// A secondary constructor must delegate to the primary one, which runs first. constructor(csvLine: String) : this( code = csvLine.substringBefore(','), city = csvLine.substringAfter(',').substringBefore(','), openedYear = csvLine.substringAfterLast(',').toInt(), ) { println("5. secondary constructor body") }}
fun main() { val pune = Store("PUN-014", "Pune", openedYear = 2019) // -> 1. property initialiser // -> 2. first init block // -> 3. second property initialiser // -> 4. second init block println("${pune.label}, ${pune.ageInYears} years, ${pune.aisles} aisles") // -> PUN-014 (Pune), 7 years, 12 aisles
val blr = Store("BLR-003,Bengaluru,2021") // -> 1. property initialiser // -> 2. first init block // -> 3. second property initialiser // -> 4. second init block // -> 5. secondary constructor body println(blr.label) // -> BLR-003 (Bengaluru)}The compiler concatenates every property initialiser and init block, in source order, into the body of the primary constructor. Two consequences for Java habits:
- Order is textual. Reading a property declared below an
initblock is a compile error (variable 'label' must be initialized., commented out above). The compiler can’t see indirect reads, though: a method called frominitthat readslabelgets the JVM default,null, even thoughlabelis a non-nullString. The first Gotcha shows the same hole from a subclass. - Secondary constructors run last. They must delegate to the primary constructor (
: this(…)), so all initialisation has already happened by the time their body runs. With default arguments, you rarely need one. Here it exists to parse a CSV line, a job a companion factory usually does better (see the hands-on). Java callers see only the full primary constructor, not one per default argument, unless you add@JvmOverloads(Part 12).
lateinit (Part 2) is the escape hatch for properties that a framework fills in after construction. It doesn’t change any of this ordering.
Inheritance: open and override
// Classes are final unless marked open. So are their members.open class Promotion(val code: String) { open fun discountPaise(pricePaise: Long): Long = 0 fun describe(): String = "$code: ${discountPaise(10_000)} off ₹100" // final: subclasses can't override}
class PercentOff(code: String, private val percent: Int) : Promotion(code) { // 'override' is mandatory, and an override is itself open unless marked final. override fun discountPaise(pricePaise: Long): Long = pricePaise * percent / 100}
// class Flash : PercentOff("FLASH", 50) // error: this type is final, so it cannot be extended.
fun main() { val promo: Promotion = PercentOff("DIWALI10", percent = 10) println(promo.describe()) // -> DIWALI10: 1000 off ₹100}The superclass constructor call is part of the header (: Promotion(code)), and it is the only place a subclass can pass arguments to it. override is a keyword, not an optional annotation, so a typo in a method name is a compile error instead of a silent new method. Abstract classes work exactly as in Java, and are open by definition.
Interfaces: properties, defaults and super<T>
// Interfaces can declare properties (abstract, or with a getter) and default methods.interface Priced { val pricePaise: Long fun priceLabel(): String = "₹${pricePaise / 100}"}
interface Labelled { val name: String val shelfName: String get() = name.uppercase() // a property with a default getter, no field fun priceLabel(): String = "see shelf"}
// Both interfaces supply priceLabel(), so the class must override it,// and super<T> picks which default to call. Same rule as Java's Priced.super.priceLabel().class Item(override val name: String, override val pricePaise: Long) : Priced, Labelled { override fun priceLabel(): String = "${super<Labelled>.priceLabel()} — ${super<Priced>.priceLabel()}"}
// class Broken(override val name: String, override val pricePaise: Long) : Priced, Labelled// error: class 'Broken' must override 'priceLabel' because it inherits multiple interface methods for it.
fun main() { val ghee = Item("Desi Ghee 1L", 64_900) println(ghee.shelfName) // -> DESI GHEE 1L println(ghee.priceLabel()) // -> see shelf — ₹649}An interface property can be abstract (val pricePaise: Long) or have a getter (shelfName), but never a backing field, because interfaces have no state. A class implements an interface property by overriding it, often directly in the constructor: override val name: String. Default methods compile to Java 8+ default methods.
Visibility: four modifiers, two surprises
| Modifier | Kotlin meaning | Closest Java |
|---|---|---|
public (default) | Everywhere | public |
internal | The same module: one Gradle source set compiled together (its test source set can see it too) | No equivalent (JPMS works per package) |
protected | The class and its subclasses. No package access | protected minus the package part |
private | Inside the class; at top level, inside the file | private |
There is no package-private. Packages in Kotlin are namespaces, not access boundaries.
open class PricingRule { // protected = subclasses only. No package access, unlike Java. protected fun marginPercent(): Int = 18
// internal = visible anywhere in the same module (Gradle source set), nowhere else. internal fun auditCode(): String = "RULE-${marginPercent()}"}
class FestivalRule : PricingRule() { fun summary(): String = "festival margin ${marginPercent()}%" // fine: a subclass}
// Same package, not a subclass. In Java, a protected member would be accessible here.class PriceReport { fun audit(rule: PricingRule): String = rule.auditCode() // internal: same module, fine // fun peek(rule: PricingRule) = rule.marginPercent() // error: cannot access 'fun marginPercent(): Int': it is protected in 'com.shelfwise.part03.visibility.PricingRule'.}
// private at the top level = visible in this file only.private fun secretFormula(): Int = 42
fun main() { println(FestivalRule().summary()) // -> festival margin 18% println(PriceReport().audit(PricingRule())) // -> RULE-18 println(secretFormula()) // -> 42}The JVM has no “module-private” access, so internal compiles to public with a mangled name:
public class com.shelfwise.part03.visibility.PricingRule { protected final int marginPercent(); public final java.lang.String auditCode$kotlin_for_java_survivors_language_part03_classes_without_ceremony();}The suffix is the Kotlin module name. Since 2.4.0 it defaults to the Gradle group:project, with non-identifier characters replaced by _. Kotlin code in another module can’t see auditCode. Java code can call it, if it is willing to type that name. protected has the same seam in the other direction: it compiles to JVM protected, so a Java class in the same package can still call marginPercent(). Both modifiers are boundaries the Kotlin compiler enforces on Kotlin code, not JVM security boundaries.
object, companions and object expressions
// object: a class and its single instance in one declaration. Initialised lazily and// thread-safely on first access, by the JVM's class initialisation, like a holder-idiom singleton.object TaxTable { private val gstByCategory = mapOf("staples" to 5, "snacks" to 12, "household" to 18)
fun gstPercent(category: String): Int = gstByCategory[category] ?: 18}
interface IdSource { fun next(): Sku}
class Sku private constructor(val value: String) { override fun toString() = value
// A companion object holds what Java would make static: factories and constants. // Unlike statics, it is a real object, so it can implement an interface. companion object : IdSource { const val PREFIX = "SHW" // const in a companion becomes a static field on Sku private var issued = 0
override fun next(): Sku = Sku("$PREFIX-${++issued}")
@JvmStatic // also generate a static Sku.parse(...) for Java callers fun parse(raw: String): Sku { require(raw.startsWith("$PREFIX-")) { "not a Shelfwise SKU: $raw" } return Sku(raw) } }}
fun issueTwo(source: IdSource): String = "${source.next()}, ${source.next()}"
interface PriceListener { fun onChange(sku: String, newPaise: Long)}
fun main() { println(TaxTable.gstPercent("snacks")) // -> 12
println(Sku.next()) // -> SHW-1 println(issueTwo(Sku)) // -> SHW-2, SHW-3 println(Sku.parse("SHW-77")) // -> SHW-77 // Sku("SHW-9") // error: cannot access 'constructor(value: String): Sku': it is private in 'com.shelfwise.part03.objects.Sku'.
// Object expression: Java's anonymous class. Captured locals can be modified. var changes = 0 val listener = object : PriceListener { override fun onChange(sku: String, newPaise: Long) { changes++ // a Java anonymous class could only read an effectively final local println("$sku -> $newPaise") } } listener.onChange("SHW-1", 15_900) // -> SHW-1 -> 15900 listener.onChange("SHW-2", 8_900) // -> SHW-2 -> 8900 println(changes) // -> 2}Under the hood:
public final class com.shelfwise.part03.objects.TaxTable { public static final com.shelfwise.part03.objects.TaxTable INSTANCE; private static final java.util.Map<java.lang.String, java.lang.Integer> gstByCategory; public final int gstPercent(java.lang.String); static {};}
public final class com.shelfwise.part03.objects.Sku { public static final com.shelfwise.part03.objects.Sku$Companion Companion; public static final java.lang.String PREFIX; // const val: a real static field private static int issued; // companion state lives on Sku public static final com.shelfwise.part03.objects.Sku parse(java.lang.String); // @JvmStatic bridge ...}
public final class com.shelfwise.part03.objects.Sku$Companion implements com.shelfwise.part03.objects.IdSource { public com.shelfwise.part03.objects.Sku next(); // an override, so not final public final com.shelfwise.part03.objects.Sku parse(java.lang.String);}object TaxTable is the static-holder singleton you would write by hand in Java: a private constructor, a static INSTANCE and initialisation in <clinit>, which the JVM runs once, lazily and thread-safely. The companion is an ordinary object reached through the static field Sku.Companion, which is why issueTwo(Sku) works: the class name on its own denotes its companion. Java code calls Sku.Companion.next(). Only @JvmStatic members and const vals appear as real statics on Sku (Part 12 has the full interop list).
The object expression is Java’s anonymous class with one upgrade: it can modify changes, a captured var. The compiler wraps the local in a Ref object to make that possible, which Part 5 shows for lambdas.
Nested vs inner
class Aisle(val number: Int) { private val slots = mutableListOf<String>()
// Nested by default: no reference to an Aisle. This is Java's *static* nested class. class Builder { private var number = 0 fun number(value: Int) = apply { number = value } fun build() = Aisle(number) // fun slotCount() = slots.size // error: outer class 'class Aisle : Any' of non-inner class cannot be used as receiver. }
// 'inner' captures the enclosing Aisle, like a Java inner class without 'static'. inner class Slot(val position: Int) { fun label(): String = "A$number-P$position" // reads the outer instance's 'number' fun fill(product: String) { slots += product // and can touch its private state } }
fun filled(): List<String> = slots}
fun main() { val aisle = Aisle.Builder().number(7).build() // no Aisle instance needed to create a Builder val slot = aisle.Slot(3) // an inner class needs one slot.fill("Basmati Rice 5kg") println(slot.label()) // -> A7-P3 println(aisle.filled()) // -> [Basmati Rice 5kg]}Java chose the wrong default: a nested class without static silently holds a reference to its outer instance, which is how Android apps leaked whole activities and how serialised inner classes drag their parents along. Kotlin reverses it. Aisle$Builder has no outer field. Aisle$Slot has final Aisle this$0, because you asked for it with inner. (apply in the builder returns the receiver after running the block. Part 6 covers it.)
Enums with bodies and entries
enum class StorageZone(val minCelsius: Int, val maxCelsius: Int) { AMBIENT(15, 30), CHILLED(0, 5) { override fun handlingNote() = "keep the cold chain unbroken" }, FROZEN(-25, -18) { override fun handlingNote() = "restock within 20 minutes" };
// An open member with a default; constants with a body override it. open fun handlingNote(): String = "no special handling"
fun accepts(celsius: Int): Boolean = celsius in minCelsius..maxCelsius}
fun main() { // entries: a fixed, cached List. values() still exists, but allocates a new array on every call. println(StorageZone.entries.map { it.name }) // -> [AMBIENT, CHILLED, FROZEN] println(StorageZone.entries.first { it.accepts(-20) }) // -> FROZEN println(StorageZone.CHILLED.handlingNote()) // -> keep the cold chain unbroken println(StorageZone.AMBIENT.handlingNote()) // -> no special handling println(StorageZone.valueOf("CHILLED").ordinal) // -> 1 println(StorageZone.values() === StorageZone.values()) // -> false println(StorageZone.entries === StorageZone.entries) // -> true}Kotlin enums are Java enums: StorageZone extends java.lang.Enum, works in EnumMap, and serialises the same way. The differences are syntax and one addition. The constant list ends with ; when members follow, and members that constants override must be open, since everything is final by default. The addition is entries, Stable since Kotlin 1.9: an immutable List built once and stored in a static $ENTRIES field. values() must return a fresh array every call because arrays are mutable, so it allocates every time. Use entries.
Step-by-Step Hands-On: The Catalog’s First Model
Code: kotlin-for-java-survivors/language/part03-classes-without-ceremony (file catalog/Catalog.kt).
Kabir and Lena finish the pairing session with the first real model for product-catalog-service: categories with tax rates, and a Product that cannot exist in an invalid state.
Step 1 — Categories as an enum with a parser. The GST rate is a property of the category, and parsing belongs to the type, so it goes in the enum’s companion:
// Fragment of catalog/Catalog.kt// Step 1: categories carry their GST rate; parsing lives on the enum's companion.enum class Category(val gstPercent: Int) { STAPLES(5), DAIRY(5), SNACKS(12), HOUSEHOLD(18);
companion object { fun parse(raw: String): Category = entries.firstOrNull { it.name.equals(raw.trim(), ignoreCase = true) } ?: error("unknown category: '$raw'") }}Steps 2 and 3 — A product with guarded state. The constructor is private, so the companion factory in step 4 is the only way in. Each property shows a different tool from this part: a validating setter, a private set, an explicit backing field and a computed getter. The init block comes after the properties, so it validates initialised state:
// Fragment of catalog/Catalog.kt// Step 2: the constructor is private, so every Product goes through a validating factory.class Product private constructor(val sku: String, name: String, val category: Category, pricePaise: Long) {
// Step 3: a validating setter. 'field' is the backing field. var name: String = name.trim() set(value) { require(value.isNotBlank()) { "name must not be blank" } field = value.trim() }
// Readable everywhere, changed only through reprice(). var pricePaise: Long = pricePaise private set
// Explicit backing field: List to the world, MutableList inside the class. val priceHistory: List<Long> field = mutableListOf(pricePaise)
// Computed on every read: no field at all. val shelfPricePaise: Long get() = pricePaise + pricePaise * category.gstPercent / 100
init { require(sku.matches(SKU_FORMAT)) { "bad SKU: $sku" } require(pricePaise > 0) { "price must be positive, was $pricePaise" } }
fun reprice(newPaise: Long) { require(newPaise > 0) { "price must be positive, was $newPaise" } pricePaise = newPaise priceHistory += newPaise }
override fun toString() = "$sku $name [${category.name.lowercase()}] ₹${shelfPricePaise / 100}"The constructor parameters name and pricePaise have no val. They exist only to initialise the properties of the same name, which is how a setter or private set gets attached to a constructor-supplied value.
Step 4 — The factory in the companion.
// Fragment of catalog/Catalog.kt // Step 4: the factory and the format rule live in the companion, like Java statics. companion object { private val SKU_FORMAT = Regex("SHW-\\d{4}")
fun fromCsv(line: String): Product { val parts = line.split(',').map { it.trim() } require(parts.size == 4) { "expected sku,name,category,price but got: $line" } return Product(parts[0], parts[1], Category.parse(parts[2]), parts[3].toLong()) } }}The companion can call the private constructor, just as a static factory can in Java. SKU_FORMAT is initialised when the Product class is, so it is ready before the first init block reads it.
Step 5 — Run it.
// Fragment of catalog/Catalog.ktfun main() { val feed = listOf( "SHW-1001, Toor Dal 1kg, staples, 16500", "SHW-1002, Masala Chips 150g, SNACKS, 3000", "SHW-1003, Floor Cleaner 1L, household, 22000", ) val products = feed.map { Product.fromCsv(it) } for (product in products) println(product) // -> SHW-1001 Toor Dal 1kg [staples] ₹173 // -> SHW-1002 Masala Chips 150g [snacks] ₹33 // -> SHW-1003 Floor Cleaner 1L [household] ₹259
val dal = products.first() dal.reprice(15_900) dal.name = " Toor Dal (Arhar) 1kg " println(dal) // -> SHW-1001 Toor Dal (Arhar) 1kg [staples] ₹166 println(dal.priceHistory) // -> [16500, 15900] // dal.pricePaise = 1 // error: cannot access 'pricePaise': it is private in 'com.shelfwise.part03.catalog.Product'.
try { Product.fromCsv("SHW-12, Paneer 200g, dairy, 9500") } catch (e: IllegalArgumentException) { println(e.message) // -> bad SKU: SHW-12 } try { Product.fromCsv("SHW-1004, Cricket Bat, toys, 99900") } catch (e: IllegalStateException) { println(e.message) // -> unknown category: 'toys' }}The class is forty-five lines, with no getters, no setters and no static. Lena pointed out one thing it doesn’t have: equals. Two Products with the same SKU aren’t equal, and putting them in a Set won’t deduplicate them. That is Part 4’s problem.
Tips, Tricks & Gotchas
Gotcha — a base class that calls an open member from its constructor sees a half-built subclass, and Kotlin’s null safety can’t protect you. Java has the same initialisation order. In Kotlin, it means a property declared as non-null
Stringcan be read asnull:
open class Shelf(val code: String) { init { // Calling an open member from a constructor: the subclass isn't initialised yet. println("registering ${describe()}") }
open fun describe(): String = "shelf $code"}
class ChilledShelf(code: String, val celsius: Int) : Shelf(code) { // A non-null String, which is still null while Shelf's init block runs. val label: String = "chilled at $celsius°C"
override fun describe(): String = "$code: $label"}
fun main() { val shelf = ChilledShelf("D-04", celsius = 4) // -> registering D-04: null println(shelf.describe()) // -> D-04: chilled at 4°C}Shelf’s init runs before ChilledShelf’s initialisers, so label is still at its JVM default. kotlinc doesn’t warn, though IntelliJ’s inspection for calls to non-final functions in constructors does. Never call open members from constructors or init blocks. That is one more reason classes are final by default.
Gotcha —
protectedlost its package access (for Kotlin code). Kotlin tests or helpers in the same package can’t reachprotectedmembers the way Java ones could. Useinternalfor “my module may use this”: the module’s test source set can see it too. Keepprotectedfor subclasses.
Gotcha — Java callers see
Companion. A companion function is called asSku.Companion.next()from Java, and itsvals are behindSku.Companion.getX(). Add@JvmStaticto functions andconst(or@JvmField, Part 12) to constants that Java code uses.
Tip — prefer a companion factory to a secondary constructor. A factory can have a descriptive name (
fromCsv), can return a cached instance or a subtype, and can validate before constructing anything. Secondary constructors can do none of that.
Key Takeaways
| Concept | Remember |
|---|---|
| Properties | A val/var is a getter (and setter); the backing field exists only if needed |
| Accessors | field inside accessors; private set for read-only APIs; is names keep their getter name |
| Explicit backing fields | val x: List<T> + field = mutableListOf(); Stable in 2.4, no flag |
| Construction | Primary constructor in the header; initialisers and init blocks run in source order |
| Inheritance | Final by default; open to extend; override is mandatory |
| Interfaces | Properties and default methods; super<T> resolves conflicts |
| Visibility | No package-private; protected = subclasses only; internal = module, mangled in bytecode |
| Objects | object = singleton; companion object = statics, as a real object; @JvmStatic for Java |
| Nested classes | Static by default; inner holds the outer reference |
| Enums | Java enums underneath; use entries instead of values() |
Story Closing
By the end of the session, Catalog.kt held the first version of the catalog model, and Kabir hadn’t written a single getter. He ran javap once more out of habit, saw the getters he hadn’t written, and closed the terminal.
The next morning he opened a bug report from the pilot store in Pune: a shelf label with the barcode printed where the SKU belonged, and the tills ringing that product up at zero. The cause took an hour to find: a call site passing a SKU and a barcode, both plain Strings, in the wrong order. Nothing in the types had stopped it.
“You’re modelling a grocery chain with Strings and Longs,” Lena said. “Kotlin has better tools for that.”
In Part 4, Kabir rebuilds the catalog model with data classes, sealed hierarchies and value classes, and makes that bug impossible to compile.
This is Part 3 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”