OOP in Kotlin, Not Java-in-Kotlin
There is a particular kind of Kotlin that interviewers see constantly, and it goes like this:
class Ticket {
private var id: String = ""
private var entryTime: Long = 0
fun getId(): String = id
fun setId(id: String) { this.id = id }
fun getEntryTime(): Long = entryTime
fun setEntryTime(t: Long) { this.entryTime = t }
}
It compiles. It works. And it tells the interviewer, in eleven lines, that you have written a lot of Java and have not yet stopped. That's not a crime — but in a forty-five minute interview it costs you something concrete: those eleven lines should have been one, and the ten you saved were minutes you needed for the design.
This chapter is about writing Kotlin that is actually Kotlin. Not for style points. For time — Kotlin's constructs collapse the ceremony that Java spends the coding block on, and the minutes you save go straight into the part of the interview that is scored. And for expressiveness — a sealed hierarchy doesn't just say the same thing as an enum-plus-casts more briefly; it enlists the compiler as a reviewer who catches the case you forgot.
We'll go through the constructs in the order they show up in an LLD interview, each as a bad-code/good-code refactor, and then assemble every one of them into a runnable module at the end.
Encapsulation Without the Ceremony
Encapsulation means the object controls its own state. It has never meant "wrap every field in a getter and a setter" — that idiom is a Java workaround for a missing language feature, and pairing a public getter with a public setter is a field with extra steps, encapsulating nothing.
Kotlin has the feature. A property is a getter and setter, and you narrow their visibility independently.
Bad — Java wearing a Kotlin costume:
class ParkingFloor(private val number: Int) {
private val spots: MutableList<Spot> = mutableListOf()
fun getSpots(): MutableList<Spot> = spots // 💀
fun getNumber(): Int = number
}
That getSpots() is the real bug, and it survives review far more often than it should. It hands callers a live, mutable handle to the floor's internals. Anyone can now call floor.getSpots().clear(). The class has a private field and zero encapsulation — the modifier is doing nothing but reassuring you.
Good:
class ParkingFloor(val number: Int) {
private val _spots = mutableListOf<Spot>()
val spots: List<Spot> get() = _spots.toList()
fun addSpot(spot: Spot) {
require(_spots.none { it.id == spot.id }) { "Duplicate spot ${spot.id}" }
_spots += spot
}
}
Three things happened. val number replaced the field-plus-getter. The mutable collection is private and the public view is a read-only List (and .toList() copies it, so callers can't downcast their way back in). And mutation now goes through a method that enforces an invariant — which is what encapsulation was always for.
The other move you'll use constantly:
class Ticket(val id: TicketId, val entryTime: Instant) {
var exitTime: Instant? = null
private set // readable by all, writable only in here
fun close(at: Instant) {
check(exitTime == null) { "Ticket $id already closed" }
exitTime = at
}
}
private set is the most useful two words in Kotlin for this interview. It gives you a public read and a private write in one line, and it is exactly the shape most entities want.
T> ### Kotlin Edge
T>
T> Say the private set out loud when you type it: "exit time is publicly readable but only the ticket can set it, and close() guards the transition." You have just narrated encapsulation and an invariant in one sentence. That is a scored signal, and it took you four seconds.
Data Classes, and the Trap Inside Them
data class gives you equals, hashCode, toString, copy, and destructuring. In an interview this is close to free money — the twenty lines of Java equals/hashCode boilerplate you'd otherwise skip (and get dinged for skipping) simply aren't there.
But data class encodes a specific claim: this thing is defined by its values, not its identity. Two Money(100, USD) are the same money. That is true for values. It is usually false for entities.
W> ### Trap
W>
W> The mutable data class in a HashMap. This one is genuinely nasty:
W>
W> ~~~~~~~~
W> data class Spot(val id: String, var isOccupied: Boolean)
W>
W> val free = hashSetOf(Spot("A1", false))
W> free.first().isOccupied = true // spot gets taken
W> free.contains(Spot("A1", true)) // false. and it's gone forever.
W> ~~~~~~~~
W>
W> hashCode is derived from all constructor properties, including the var. Mutating it moves the object to a different hash bucket than the one it's filed under — it is now unreachable in its own set. The object is still in the collection and you cannot get it out.
W>
W> Rule: data class for immutable values. Plain class for mutable entities. If you catch yourself writing data class with a var, stop.
So: Spot has identity (spot A1 is that spot, and its occupancy changes) — plain class. Money, TimeRange, Transition have no identity — data class.
While we're here: stop passing String ids around.
fun issueTicket(vehicleId: String, spotId: String, gateId: String): String
Three strings. Nothing stops you swapping two of them at a call site, and the compiler will smile and let you. Kotlin gives you type safety for free at zero runtime cost:
@JvmInline value class TicketId(val value: String)
@JvmInline value class SpotId(val value: String)
value class is erased to the underlying String at runtime — no allocation, no wrapper object — but the compiler now rejects issueTicket(spotId, vehicleId, ...). This is called defeating primitive obsession, it takes ten seconds, and it is a distinctly senior signal. Very few candidates do it.
Enums Before Hierarchies
Here is the mistake, and it is the most common one in the entire interview:
Bad:
abstract class Vehicle(val plate: String) {
abstract fun getSize(): Int
}
class Car(plate: String) : Vehicle(plate) { override fun getSize() = 2 }
class Truck(plate: String) : Vehicle(plate) { override fun getSize() = 3 }
class Motorcycle(plate: String) : Vehicle(plate) { override fun getSize() = 1 }
Three classes, three files, one integer of information. Nothing is polymorphic here — no subtype behaves differently in any way that matters; they each return a different constant. You have built an inheritance hierarchy to store a number.
Ask the diagnostic question from Chapter 1: does this subtype have behavior of its own? If every override is return <constant>, the answer is no, and you wanted data, not types.
Good:
enum class VehicleSize(val slotsRequired: Int) {
MOTORCYCLE(1),
CAR(2),
TRUCK(3);
fun fitsIn(spot: SpotSize): Boolean = slotsRequired <= spot.capacity
}
class Vehicle(val plate: String, val size: VehicleSize)
One class, one enum. The enum carries data and behavior — Kotlin enums take constructor parameters and can hold methods, which is enough for most of the cases where people reach for subclasses. And unlike a hierarchy, an enum is a closed set: when (size) over it is exhaustive, so adding BUS tomorrow produces compile errors at every site that needs updating rather than silent wrong answers.
Enums can even carry per-constant behavior when you really need it:
enum class Denomination(val cents: Int) {
NICKEL(5), DIME(10), QUARTER(25);
companion object {
fun of(cents: Int) = entries.first { it.cents == cents }
}
}
Reach for a class hierarchy when subtypes genuinely differ in behavior — a MotorcycleSpot that can hold two motorcycles has different allocation logic, and that's real polymorphism. Reach for an enum when they differ only in data. The difference is the whole ballgame, and stating it out loud is one of the cheapest ways to signal that you understand what inheritance is for.
Sealed: The Closed Set With Payload
An enum is a closed set where every member is a singleton with the same shape. A sealed interface is a closed set where members can carry different data. That distinction is the entire decision procedure.
Bad — the nullable-everything result:
class ParkResult(
val success: Boolean,
val ticket: Ticket?, // set if success
val errorMessage: String?, // set if !success
val retryAfter: Duration? // set if lot full, sometimes
)
Every caller now writes if (result.success) result.ticket!! — and that !! is a promise the compiler can't check, made by a person who is about to be paged at 3am. Four fields encode a two-case outcome, and half of them are lies at any given moment.
Good:
sealed interface ParkResult {
data class Admitted(val ticket: Ticket, val spot: Spot) : ParkResult
data class LotFull(val retryAfter: Duration) : ParkResult
data class SizeUnavailable(val requested: VehicleSize) : ParkResult
data object GateClosed : ParkResult
}
Each case carries exactly the data that case has, and nothing else. No nullables, no !!. And at the call site:
when (val result = gate.admit(vehicle)) {
is ParkResult.Admitted -> printTicket(result.ticket) // smart-cast, no cast needed
is ParkResult.LotFull -> showWait(result.retryAfter)
is ParkResult.SizeUnavailable -> suggestOtherLot(result.requested)
ParkResult.GateClosed -> flashLight()
}
The when is exhaustive — no else branch, and the compiler requires all four. Add UnderMaintenance tomorrow and every call site fails to compile until it's handled. That is the feature. In Java, a new enum constant slips silently into a default: branch and ships. In Kotlin it cannot.
Use data object for cases with no payload, data class for cases with one. And note the smart cast: inside the is ParkResult.Admitted branch, result simply is an Admitted, with no downcast.
This is also the backbone of the State pattern (Chapter 7, and the turnstile you already ran), of every result/error type in the book, and of every event stream. Of all Kotlin's features, this is the one that most changes what a good LLD answer looks like.
Composition Over Inheritance, and by
"Favor composition over inheritance" is the most-quoted and least-practiced advice in object-oriented design. The reason people ignore it is that composition traditionally costs you forwarding boilerplate: to wrap a 12-method interface you write 12 one-line delegating methods, and at that point inheritance just looks easier.
Kotlin removes the cost.
Bad — inheritance for reuse:
class AuditedSpotRegistry : InMemorySpotRegistry() {
override fun reserve(id: SpotId): Boolean {
println("AUDIT: reserving $id")
return super.reserve(id) // fragile base class, forever
}
}
You are now welded to InMemorySpotRegistry's internals. If the base class changes reserve() to call reserveAll() internally, your audit log silently double-counts or vanishes. This is the fragile base class problem, and it is why inheritance across a module boundary is a liability.
Good — delegation:
interface SpotRegistry {
fun reserve(id: SpotId): Boolean
fun release(id: SpotId)
fun findFree(size: SpotSize): SpotId?
}
class AuditedSpotRegistry(
private val delegate: SpotRegistry,
private val audit: (String) -> Unit,
) : SpotRegistry by delegate {
override fun reserve(id: SpotId): Boolean {
audit("reserve $id")
return delegate.reserve(id)
}
}
: SpotRegistry by delegate generates every forwarding method for you. You override only the one you care about; release and findFree pass straight through. You depend on the interface, not on someone's implementation, so there is no base class to be fragile.
And you just wrote the Decorator pattern in six lines. Say that out loud when you do it — by delegation is the single most impressive Kotlin construct you can deploy in an LLD interview, because it demonstrates a design principle and a language feature simultaneously. We use it in Chapters 16, 18, and 19.
Polymorphism Without the Ceremony
A Strategy interface in Java is a file. In Kotlin it's a line — and if it has a single method, you can implement it with a lambda:
fun interface PricingStrategy {
fun price(duration: Duration, size: VehicleSize): Money
}
// implementations, all of them valid:
val flat = PricingStrategy { _, _ -> Money(500) }
val hourly = PricingStrategy { d, _ -> Money(200 * d.inWholeHours.coerceAtLeast(1)) }
object WeekendPricing : PricingStrategy { // named, when it deserves a name
override fun price(d: Duration, size: VehicleSize) = Money(150 * d.inWholeHours)
}
The fun interface keyword (a SAM interface) is what enables the lambda form. In an interview this means you can define a strategy interface and two implementations in under a minute, then spend the time you saved on the design conversation.
A caution, though, and it's worth voicing: don't let this collapse into "everything is a lambda." A strategy with a name, some state, and a place in the class diagram communicates more to your reader than an anonymous closure buried in a constructor call. Lambdas for the trivial cases, named objects for the ones that carry weight.
object: Singleton Done Right, Used Sparingly
object ParkingLot { // Singleton, thread-safe, lazily initialized. one word.
private val floors = mutableListOf<Floor>()
fun park(v: Vehicle): ParkResult { ... }
}
Kotlin's object is a properly lazy, thread-safe singleton with no double-checked locking, no volatile, no enum trick. The pattern that takes a page in the Java literature takes a keyword here.
W> ### Trap
W>
W> Reaching for object because the problem says "the parking lot." Global mutable state is not testable: two tests that both park a car now interfere, and there is no way to reset. The interviewer who asks "how would you test this?" is asking precisely because you wrote object.
W>
W> Use object for stateless things — a pure Formatter, a DefaultClock, a strategy with no fields. Use a plain class with an injected dependency for anything that holds state. If you need exactly one ParkingLot at runtime, construct exactly one in main(). That's not a design problem; it's a wiring detail.
That last sentence is worth memorizing. "There is one of these in production, so I'll construct one in main() rather than making it a global" is a sophisticated answer to a question most candidates get wrong on instinct.
Extension Functions, and What They Are Not
Extension functions let you add behavior to types you don't own:
fun Duration.billableHours(): Long = inWholeHours.coerceAtLeast(1)
fun List<Spot>.freeOf(size: SpotSize) = firstOrNull { !it.isOccupied && it.size == size }
They're genuinely useful for adapting third-party types and for keeping domain classes small. But there is a sharp edge that interviewers do probe:
W> ### Trap W> W> Extensions are resolved statically. They are not polymorphism. W> W> ~~~~~~~~ W> open class Spot W> class EvSpot : Spot() W> W> fun Spot.describe() = "generic spot" W> fun EvSpot.describe() = "EV spot" W> W> val s: Spot = EvSpot() W> println(s.describe()) // "generic spot" — dispatched on the STATIC type W> ~~~~~~~~ W> W> If you need dynamic dispatch, you need a member function. An extension is sugar over a static utility method; it does not participate in the vtable. Candidates who "override" behavior with extensions have introduced a bug that will not show up until a subtype meets a supertype-typed variable.
Rule of thumb: extensions for your own convenience over types you don't control. Member functions for anything that is genuinely part of the object's behavior — and therefore anything a subtype might change.
The Chapter Project: Locker Rental
Every construct above, in one runnable module. The domain is deliberately tiny — a coin-operated locker rental — so that nothing distracts from the constructs.
{title="ch-02-kotlin-oop/src/main/kotlin/com/cracklld/ch02/Domain.kt"}
package com.cracklld.ch02
import kotlin.time.Duration
// --- value classes: no primitive obsession, no runtime cost -------------------
@JvmInline value class LockerId(val value: String)
@JvmInline value class RenterId(val value: String)
// --- data class: defined by value, immutable ---------------------------------
data class Money(val cents: Int) : Comparable<Money> {
operator fun plus(other: Money) = Money(cents + other.cents)
override fun compareTo(other: Money) = cents.compareTo(other.cents)
override fun toString() = "$%.2f".format(cents / 100.0)
}
// --- enum: differs in DATA, not behavior -------------------------------------
enum class LockerSize(val litres: Int, val baseRateCents: Int) {
SMALL(20, 100),
MEDIUM(50, 180),
LARGE(120, 300);
fun fits(itemLitres: Int) = itemLitres <= litres
}
// --- plain class: has IDENTITY and mutable state. NOT a data class. -----------
class Locker(val id: LockerId, val size: LockerSize) {
var occupant: RenterId? = null
private set // public read, private write
val isFree: Boolean get() = occupant == null
fun assignTo(renter: RenterId) {
check(isFree) { "Locker ${id.value} is already occupied" }
occupant = renter
}
fun vacate() {
checkNotNull(occupant) { "Locker ${id.value} is not occupied" }
occupant = null
}
}
// --- sealed: closed set, each case carries only its own data ------------------
sealed interface RentalResult {
data class Granted(val locker: Locker, val quotedRate: Money) : RentalResult
data class NoneAvailable(val size: LockerSize) : RentalResult
data class TooLarge(val itemLitres: Int) : RentalResult
data object OutOfHours : RentalResult
}
// --- fun interface: Strategy, implementable by lambda -------------------------
fun interface PricingStrategy {
fun quote(size: LockerSize, duration: Duration): Money
}
Note what each declaration is claiming. Money is a data class because two equal amounts are the same amount. Locker is a plain class because locker A-01 is that locker even after its occupant changes — and because making it a data class with a mutable occupant would plant the HashMap bug from earlier in the chapter. LockerSize is an enum because the sizes differ in numbers, not conduct.
{title="ch-02-kotlin-oop/src/main/kotlin/com/cracklld/ch02/Registry.kt"}
package com.cracklld.ch02
import kotlin.time.Duration
interface LockerRegistry {
fun all(): List<Locker>
fun findFree(size: LockerSize): Locker?
fun rent(size: LockerSize, renter: RenterId, itemLitres: Int, duration: Duration): RentalResult
fun release(id: LockerId)
}
class InMemoryLockerRegistry(
lockers: List<Locker>,
private val pricing: PricingStrategy,
private val clock: Clock = SystemClock,
) : LockerRegistry {
private val _lockers = lockers.associateBy { it.id }.toMutableMap()
override fun all(): List<Locker> = _lockers.values.toList() // read-only copy
override fun findFree(size: LockerSize): Locker? =
_lockers.values.firstOrNull { it.isFree && it.size == size }
override fun rent(
size: LockerSize,
renter: RenterId,
itemLitres: Int,
duration: Duration,
): RentalResult {
if (!clock.isOpen()) return RentalResult.OutOfHours
if (!size.fits(itemLitres)) return RentalResult.TooLarge(itemLitres)
val locker = findFree(size) ?: return RentalResult.NoneAvailable(size)
locker.assignTo(renter)
return RentalResult.Granted(locker, pricing.quote(size, duration))
}
override fun release(id: LockerId) {
_lockers[id]?.vacate() ?: error("Unknown locker ${id.value}")
}
}
/**
* Decorator via `by` delegation. Only `rent` and `release` are intercepted;
* `all` and `findFree` are forwarded automatically by the compiler.
*/
class AuditedLockerRegistry(
private val delegate: LockerRegistry,
private val sink: MutableList<String> = mutableListOf(),
) : LockerRegistry by delegate {
val auditLog: List<String> get() = sink.toList()
override fun rent(
size: LockerSize,
renter: RenterId,
itemLitres: Int,
duration: Duration,
): RentalResult {
val result = delegate.rent(size, renter, itemLitres, duration)
sink += when (result) { // exhaustive: no `else`
is RentalResult.Granted -> "GRANT ${result.locker.id.value} -> ${renter.value} @ ${result.quotedRate}"
is RentalResult.NoneAvailable -> "DENY no ${result.size} free"
is RentalResult.TooLarge -> "DENY item ${result.itemLitres}L too large"
RentalResult.OutOfHours -> "DENY outside opening hours"
}
return result
}
override fun release(id: LockerId) {
delegate.release(id)
sink += "FREE ${id.value}"
}
}
AuditedLockerRegistry is the chapter in miniature. Six meaningful lines of by delegation give you a Decorator; the when over RentalResult is exhaustive, so adding a fifth outcome tomorrow breaks the build here and forces you to decide how to log it. Nothing is inherited from a concrete class, so nothing is fragile.
{title="ch-02-kotlin-oop/src/main/kotlin/com/cracklld/ch02/Clock.kt"}
package com.cracklld.ch02
import java.time.LocalTime
/** Injected, so tests can control it. */
interface Clock {
fun now(): LocalTime
fun isOpen(): Boolean = now() >= LocalTime.of(6, 0) && now() < LocalTime.of(22, 0)
}
/** `object` is fine here: STATELESS. Contrast with a singleton registry, which is not. */
object SystemClock : Clock {
override fun now(): LocalTime = LocalTime.now()
}
class FixedClock(private val at: LocalTime) : Clock {
override fun now(): LocalTime = at
}
// --- extension: convenience over a type we don't own -------------------------
fun kotlin.time.Duration.billableHours(): Long = inWholeHours.coerceAtLeast(1)
The Clock split is worth a sentence in an interview: SystemClock is an object because it holds no state, while the registry — which does — is a plain class you construct once in main(). That is the object rule from earlier, applied.
{title="ch-02-kotlin-oop/src/main/kotlin/com/cracklld/ch02/Demo.kt"}
package com.cracklld.ch02
import kotlin.time.Duration.Companion.hours
import java.time.LocalTime
fun main() {
// Strategy as a lambda — `fun interface` makes this legal.
val hourly = PricingStrategy { size, duration ->
Money(size.baseRateCents * duration.billableHours().toInt())
}
val lockers = listOf(
Locker(LockerId("S-01"), LockerSize.SMALL),
Locker(LockerId("M-01"), LockerSize.MEDIUM),
Locker(LockerId("L-01"), LockerSize.LARGE),
)
val registry = AuditedLockerRegistry(
InMemoryLockerRegistry(lockers, hourly, FixedClock(LocalTime.of(10, 0)))
)
val requests = listOf(
Triple(LockerSize.MEDIUM, RenterId("alice"), 40),
Triple(LockerSize.MEDIUM, RenterId("bob"), 30), // none left
Triple(LockerSize.SMALL, RenterId("carol"), 90), // item too big
)
requests.forEach { (size, renter, litres) ->
val outcome = when (val r = registry.rent(size, renter, litres, 3.hours)) {
is RentalResult.Granted -> "${renter.value}: locker ${r.locker.id.value}, ${r.quotedRate}"
is RentalResult.NoneAvailable -> "${renter.value}: no ${r.size} lockers free"
is RentalResult.TooLarge -> "${renter.value}: ${r.itemLitres}L won't fit"
RentalResult.OutOfHours -> "${renter.value}: we're closed"
}
println(outcome)
}
registry.release(LockerId("M-01"))
println("\n--- audit ---")
registry.auditLog.forEach(::println)
}
alice: locker M-01, $5.40
bob: no MEDIUM lockers free
carol: 90L won't fit
--- audit ---
GRANT M-01 -> alice @ $5.40
DENY no MEDIUM free
DENY item 90L too large
FREE M-01
And the tests, including one that pins down the data class trap so it can never regress:
{title="ch-02-kotlin-oop/src/test/kotlin/com/cracklld/ch02/RegistryTest.kt"}
package com.cracklld.ch02
import kotlin.test.*
import kotlin.time.Duration.Companion.hours
import java.time.LocalTime
class RegistryTest {
private val hourly = PricingStrategy { size, d ->
Money(size.baseRateCents * d.billableHours().toInt())
}
private fun registry(open: Boolean = true) = InMemoryLockerRegistry(
lockers = listOf(
Locker(LockerId("S-01"), LockerSize.SMALL),
Locker(LockerId("M-01"), LockerSize.MEDIUM),
),
pricing = hourly,
clock = FixedClock(if (open) LocalTime.NOON else LocalTime.of(3, 0)),
)
@Test
fun `a free locker of the right size is granted and priced`() {
val result = registry().rent(LockerSize.MEDIUM, RenterId("alice"), 40, 2.hours)
val granted = assertIs<RentalResult.Granted>(result)
assertEquals(Money(360), granted.quotedRate)
assertFalse(granted.locker.isFree)
}
@Test
fun `the same locker cannot be rented twice`() {
val r = registry()
r.rent(LockerSize.MEDIUM, RenterId("alice"), 40, 1.hours)
assertIs<RentalResult.NoneAvailable>(r.rent(LockerSize.MEDIUM, RenterId("bob"), 40, 1.hours))
}
@Test
fun `oversized items are refused before a locker is consumed`() {
val r = registry()
assertIs<RentalResult.TooLarge>(r.rent(LockerSize.SMALL, RenterId("carol"), 500, 1.hours))
assertTrue(r.findFree(LockerSize.SMALL) != null, "locker must not be consumed on failure")
}
@Test
fun `nothing is rented outside opening hours`() {
assertIs<RentalResult.OutOfHours>(
registry(open = false).rent(LockerSize.SMALL, RenterId("dan"), 5, 1.hours)
)
}
@Test
fun `the audit decorator records both grants and releases`() {
val audited = AuditedLockerRegistry(registry())
audited.rent(LockerSize.SMALL, RenterId("alice"), 10, 1.hours)
audited.release(LockerId("S-01"))
assertEquals(2, audited.auditLog.size)
assertTrue(audited.auditLog[0].startsWith("GRANT"))
assertTrue(audited.auditLog[1].startsWith("FREE"))
}
@Test
fun `mutating a locker does not lose it from a set - it is not a data class`() {
val locker = Locker(LockerId("S-01"), LockerSize.SMALL)
val set = hashSetOf(locker)
locker.assignTo(RenterId("alice")) // mutate after insertion
assertTrue(set.contains(locker), "identity-based hashing must survive mutation")
}
}
That last test is the chapter's thesis as an executable assertion. Turn Locker into a data class and it fails — the object vanishes from a set that still contains it. Try it.
Add the module and run it:
./gradlew :ch-02-kotlin-oop:run
./gradlew :ch-02-kotlin-oop:test
Interview Framing
The constructs are not the point; narrating why you chose them is. The same code scores differently depending on whether you say the sentence.
| When you type… | Say… |
|---|---|
data class Money |
"Value object — two equal amounts are the same amount, so structural equality is what I want." |
plain class Locker |
"Entity, so identity equality. If I made this a data class with a mutable field, it'd break in a HashSet." |
private set |
"Publicly readable, and only the locker itself can change it — the invariant lives in assignTo." |
@JvmInline value class |
"Typed ids, so I can't swap a RenterId for a LockerId at a call site. Zero runtime cost." |
enum class LockerSize |
"These differ in data, not behavior — subclasses here would be three files and no information." |
sealed interface RentalResult |
"Closed set. The when is exhaustive, so a new outcome breaks the build instead of slipping through a default." |
: Registry by delegate |
"Delegation, not inheritance — that's a Decorator, and I'm not coupled to any concrete implementation." |
fun interface PricingStrategy |
"Strategy. SAM, so implementations can be lambdas; pricing is the thing most likely to change." |
object SystemClock |
"Stateless, so a singleton is safe. The registry holds state, so it's a plain class I construct in main()." |
A> ### Scorecard
A>
A> Strong: entities and values distinguished deliberately; volatile behavior behind a fun interface; exhaustive when over a sealed result; by delegation instead of subclassing a concrete class; typed ids; every choice explained in one sentence as you make it.
A>
A> Weak: getter/setter pairs; data class with vars used as map keys; three-subclass hierarchies that only override a constant; nullable-everything result objects with !! at the call site; object for the stateful root, followed by "…how would I test it? Hmm."
X> ### Exercise
X>
X> 1. Change Locker to a data class and run the tests. Watch the last one fail, and make sure you can explain precisely why the set loses an object it still contains.
X> 2. Add a fifth RentalResult case — UnderMaintenance(val until: LocalTime). Do not search for the call sites. Just add the case and compile: the exhaustive whens will list every place that needs updating. Note how many there are, and that you missed none.
X> 3. Write a RateLimitedLockerRegistry decorator that refuses more than N rentals per renter, using by delegation. You should be overriding exactly one method.
What's Next
You now have the vocabulary. Chapter 3 supplies the grammar: SOLID, not as five slogans to recite, but as five refactors you can perform under a clock — each one starting from code that looks reasonable and ending somewhere the interviewer can see the difference.