The real-life mission
Build a small Smart Home Control Center. A light, fan, and door lock share some ideas, differ in other ways, and must work together through one dashboard. The story is familiar; the design teaches the complete OOP mental model.
This is intentionally simpler than a production home-automation platform. Every class exists to make one OOP idea visible. Teach one layer at a time, run it, and let students predict what the next object will do.
Capabilities
Describe what can switch and what can lock.
Shared identity
Put name, room, and status in SmartDevice.
Real devices
Build a light, fan, and door lock.
One dashboard
Use SmartDevice polymorphism for every status.
Whole house
Compose the devices inside SmartHome.
By the end, students will be able to explain and use:
- Classes, objects, properties, methods, and object state
- Primary constructors, default values,
init, validation, and superclass construction - Encapsulation with private setters and safe public methods
- Abstraction with an abstract superclass and abstract methods
- Inheritance, overriding,
open,override, andsuper - Interfaces, default methods, runtime polymorphism, and composition
Classes are plans; objects are real devices
SmartLight is a class: a reusable plan. bedroomLamp is one object built from it. Two SmartLight objects run the same methods but keep independent rooms, brightness values, and power states.
Class · blueprint
SmartLight
name: String
room: String
brightness: Int
turnOn()
val bedroomLamp
SmartLight
Bedroom
brightness = 70
val readingLamp
SmartLight
Living room
brightness = 40
val ceilingFan
SmartFan
Living room
speed = 2
The sentence that separates two relationships
See the whole design before coding it
Click each card. Green arrows show inheritance, animated blue arrows show interface implementation, and the amber relationship shows composition. The class diagram is a map—not extra decoration—so return to it after every coding step.
The common superclass stores shared identity and requires each real device to explain its own status.
name, roomstatus()description()Step 1 · Describe capabilities with interfaces
A light and a fan can both turn on and off, but a door lock uses lock and unlock. Interfaces express these small promises. They let a controller ask for exactly the behavior it needs without knowing the concrete class.
interface Switchable {
val isOn: Boolean
fun turnOn()
fun turnOff()
// A default method shared by every implementation.
fun toggle() {
if (isOn) turnOff() else turnOn()
}
}
interface Lockable {
val isLocked: Boolean
fun lock()
fun unlock()
fun securityMessage(): String =
if (isLocked) "secure" else "unlocked"
}Why the interface does not store power state
toggle() method is possible because it only uses members in the contract.Step 2 · Put shared identity in an abstract class
Every smart device has a name and room. Every device can describe itself, yet each device reports a different status. SmartDevice stores the shared facts and deliberately leaves status() incomplete.
abstract class SmartDevice(
val name: String,
val room: String
) {
init {
require(name.isNotBlank()) { "Device name cannot be blank" }
require(room.isNotBlank()) { "Room cannot be blank" }
}
// Every concrete device must explain its own state.
abstract fun status(): String
// Subclasses may reuse or extend this implementation.
open fun description(): String = "$name in $room"
}| Member | Design reason | Concept |
|---|---|---|
name / room | Shared data for every device | Inheritance |
init | Reject invalid objects during construction | Invariant |
status() | Every subtype must supply a meaningful answer | Abstraction |
description() | Reusable default that subtypes may extend | Open behavior |
You cannot construct an abstract class
SmartDevice("Thing", "Room") is invalid. A generic device has no concrete status behavior, so Kotlin requires a complete subclass.Step 3 · Build the concrete devices
SmartLight: inheritance plus an interface
The class header calls the superclass constructor and declares the capability it fulfills. The light overrides abstract behavior, protects its state, and uses super.description() instead of repeating the parent logic.
class SmartLight(
name: String,
room: String,
initialBrightness: Int = 50
) : SmartDevice(name, room), Switchable {
override var isOn: Boolean = false
private set
var brightness: Int = initialBrightness
private set
init {
require(initialBrightness in 0..100) {
"Brightness must be from 0 to 100"
}
}
override fun turnOn() { isOn = true }
override fun turnOff() { isOn = false }
fun setBrightness(level: Int) {
require(level in 0..100)
brightness = level
if (level == 0) turnOff() else turnOn()
}
override fun status(): String =
if (isOn) "ON at $brightness%" else "OFF"
override fun description(): String =
"${super.description()} — dimmable light"
}SmartFan and SmartDoorLock: same family, different capabilities
Both objects are SmartDevice subclasses, so both fit the dashboard. Only the light and fan are Switchable. The door implements Lockable, which prevents misleading methods such as door.turnOff().
class SmartFan(
name: String,
room: String,
initialSpeed: Int = 1
) : SmartDevice(name, room), Switchable {
override var isOn: Boolean = false
private set
var speed: Int = initialSpeed
private set
init { require(initialSpeed in 1..3) }
override fun turnOn() { isOn = true }
override fun turnOff() { isOn = false }
fun setSpeed(newSpeed: Int) {
require(newSpeed in 1..3)
speed = newSpeed
}
override fun status() =
if (isOn) "ON at speed $speed" else "OFF"
}
class SmartDoorLock(
name: String,
room: String
) : SmartDevice(name, room), Lockable {
override var isLocked: Boolean = true
private set
override fun lock() { isLocked = true }
override fun unlock() { isLocked = false }
override fun status() =
if (isLocked) "LOCKED" else "UNLOCKED"
}Constructors and super
Creating a subclass object begins by constructing its superclass portion. Choose a device and replay the trace. Shared validation runs before subtype-specific validation, so no device exists with a blank name or room.
- 1Evaluate name, room, and brightness arguments
- 2Call SmartDevice(name, room)
- 3Validate SmartDevice's shared name and room
- 4Return to SmartLight and create its private state
- 5Validate brightness in 0..100
- 6The SmartLight object is ready
Two forms of parent reuse
: SmartDevice(name, room), the subclass invokes its parent constructor. In super.description(), an override invokes its parent implementation and adds detail.Encapsulation keeps every device valid
Callers may read brightness, speed, and isOn, but their setters are private. Changes must pass through methods that validate values and keep related state consistent.
| Unsafe public mutation | Safe method | Rule protected |
|---|---|---|
light.brightness = 900 | light.setBrightness(70) | Brightness remains in 0..100 |
fan.speed = -4 | fan.setSpeed(2) | Speed remains in 1..3 |
light.isOn = true | light.turnOn() | Power changes through the contract |
Encapsulation is not secrecy for its own sake
Step 4 · One call, different behavior
A List<SmartDevice> holds all three concrete types. The loop calls the same status() expression, and runtime dispatch chooses SmartLight, SmartFan, or SmartDoorLock behavior. A second list uses the Switchable interface to control only objects with that capability.
fun printDashboard(devices: List<SmartDevice>) {
devices.forEach { device ->
// The reference type is SmartDevice.
// The runtime object chooses the status() override.
println("${device.description()}: ${device.status()}")
}
}
fun turnEverythingOff(devices: List<Switchable>) {
// SmartLight and SmartFan work through one interface.
devices.forEach { it.turnOff() }
}Oak House · live objects
Choose a device, change it, then run the polymorphic good-night routine.
Control selected object
Desk lamp
declared capability: Switchable
runtime dispatch log
› Home created with three SmartDevice objects
What the controller knows
Switchable → turnOff / turnOn
Switchable → turnOff / turnOn
Lockable → lock / unlock
The controller asks for a capability. The runtime object decides which implementation executes.
Polymorphism removes class-by-class branching
status() or ask a Switchable to turnOff(). The object supplies the correct implementation.Step 5 · Compose devices into a SmartHome
A home is not a special kind of device, so inheritance would model the real world badly. A home contains devices and coordinates them. That relationship is composition.
class SmartHome(private val homeName: String) {
// Composition: a SmartHome HAS devices.
private val devices = mutableListOf<SmartDevice>()
fun add(device: SmartDevice) {
devices += device
}
fun dashboard() {
println("== $homeName ==")
devices.forEach {
println("${it.description()}: ${it.status()}")
}
}
fun goodNight() {
devices.filterIsInstance<Switchable>()
.forEach { it.turnOff() }
devices.filterIsInstance<Lockable>()
.forEach { it.lock() }
}
}| Relationship | Code | Meaning |
|---|---|---|
| Inheritance | SmartLight : SmartDevice | A SmartLight is a SmartDevice |
| Interface | SmartLight : Switchable | A SmartLight can be switched |
| Composition | SmartHome → List<SmartDevice> | A SmartHome has devices |
The complete runnable program
Copy this single file into IntelliJ IDEA or the Kotlin Playground. It has no external dependencies. Use the Run button to compare the expected console output before changing the code.
interface Switchable {
val isOn: Boolean
fun turnOn()
fun turnOff()
fun toggle() = if (isOn) turnOff() else turnOn()
}
interface Lockable {
val isLocked: Boolean
fun lock()
fun unlock()
fun securityMessage() = if (isLocked) "secure" else "unlocked"
}
abstract class SmartDevice(
val name: String,
val room: String
) {
init {
require(name.isNotBlank()) { "Device name cannot be blank" }
require(room.isNotBlank()) { "Room cannot be blank" }
}
abstract fun status(): String
open fun description(): String = "$name in $room"
}
class SmartLight(
name: String,
room: String,
initialBrightness: Int = 50
) : SmartDevice(name, room), Switchable {
override var isOn = false
private set
var brightness = initialBrightness
private set
init {
require(initialBrightness in 0..100) {
"Brightness must be from 0 to 100"
}
}
override fun turnOn() { isOn = true }
override fun turnOff() { isOn = false }
fun setBrightness(level: Int) {
require(level in 0..100) { "Brightness must be from 0 to 100" }
brightness = level
if (level == 0) turnOff() else turnOn()
}
override fun status() = if (isOn) "ON at $brightness%" else "OFF"
override fun description() =
"${super.description()} — dimmable light"
}
class SmartFan(
name: String,
room: String,
initialSpeed: Int = 1
) : SmartDevice(name, room), Switchable {
override var isOn = false
private set
var speed = initialSpeed
private set
init { require(initialSpeed in 1..3) { "Speed must be from 1 to 3" } }
override fun turnOn() { isOn = true }
override fun turnOff() { isOn = false }
fun setSpeed(newSpeed: Int) {
require(newSpeed in 1..3) { "Speed must be from 1 to 3" }
speed = newSpeed
}
override fun status() = if (isOn) "ON at speed $speed" else "OFF"
override fun description() = "${super.description()} — three-speed fan"
}
class SmartDoorLock(
name: String,
room: String
) : SmartDevice(name, room), Lockable {
override var isLocked = true
private set
override fun lock() { isLocked = true }
override fun unlock() { isLocked = false }
override fun status() = if (isLocked) "LOCKED" else "UNLOCKED"
override fun description() = "${super.description()} — smart lock"
}
class SmartHome(private val homeName: String) {
private val devices = mutableListOf<SmartDevice>()
fun add(device: SmartDevice) {
devices += device
}
fun dashboard() {
println("== $homeName ==")
devices.forEach { device ->
println("${device.description()}: ${device.status()}")
}
}
fun goodNight() {
devices.filterIsInstance<Switchable>().forEach { it.turnOff() }
devices.filterIsInstance<Lockable>().forEach { it.lock() }
}
}
fun main() {
val lamp = SmartLight("Desk lamp", "Bedroom", 70)
val fan = SmartFan("Ceiling fan", "Living room", 2)
val door = SmartDoorLock("Front door", "Entrance")
val home = SmartHome("Oak House")
home.add(lamp)
home.add(fan)
home.add(door)
val switchableDevices: List<Switchable> = listOf(lamp, fan)
switchableDevices.forEach { it.turnOn() }
lamp.setBrightness(40)
fan.setSpeed(3)
door.unlock()
home.dashboard()
println("\nGood-night routine...")
home.goodNight()
home.dashboard()
}Practice: extend without rewriting
Ask students to add a television. Existing code should remain unchanged: the new class joins the dashboard through SmartDevice and the good-night routine through Switchable. That is the practical payoff of designing around abstractions.
// Challenge: add a smart television without editing existing classes.
class SmartTelevision(
name: String,
room: String,
private var channel: Int = 1
) : SmartDevice(name, room), Switchable {
// 1. Implement isOn with a private setter.
// 2. Implement turnOn() and turnOff().
// 3. Add changeChannel(newChannel) with validation.
// 4. Override status().
// 5. Override description() and reuse super.description().
}- Add the television to
SmartHomeand confirm it appears automatically in the dashboard. - Add it to a
List<Switchable>and reuse the existing power-control loop. - Create a
BatteryPoweredinterface withbatteryPercentandneedsCharging(). - Make only the door lock implement BatteryPowered; do not modify unrelated devices.
The complete OOP picture
Key takeaways
- A class is a blueprint; every object owns independent state.
- An abstract class provides shared identity, state, validation, and deliberately incomplete behavior.
- Inheritance models an is-a relationship; composition models a has-a relationship.
- Interfaces model capabilities and let unrelated implementations work through a small common contract.
- Private setters and validated methods preserve object invariants.
- Constructors establish valid state; super reuses parent construction and behavior.
- Polymorphism selects the correct override from the runtime object without type-by-type branching.
Smart Home OOP check
1. Why is SmartDevice abstract?
2. Why does turnEverythingOff accept List<Switchable>?
3. Which statement best describes SmartHome and its devices?
4. What makes device.status() polymorphic inside the dashboard loop?
5. Why is brightness publicly readable but privately settable?