Phase 3 · Object-Oriented KotlinModule 21~70 min read

OOP Capstone: Smart Home

Build a real-life smart home with abstract classes, capability interfaces, constructor chains, encapsulation, polymorphism, and composition.

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.

Build sequence
01

Capabilities

Describe what can switch and what can lock.

02

Shared identity

Put name, room, and status in SmartDevice.

03

Real devices

Build a light, fan, and door lock.

04

One dashboard

Use SmartDevice polymorphism for every status.

05

Whole house

Compose the devices inside SmartHome.

Five small layers turn separate classes into one understandable object-oriented system.

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, and super
  • 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.

Blueprint and instances

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

Objects made from the same class share structure and behavior, not stored values.

The sentence that separates two relationships

A SmartLight is a SmartDevice, so inheritance fits. A SmartHome has SmartDevice objects, so composition fits.

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.

Interactive class diagram
SmartDeviceselected relationship

The common superclass stores shared identity and requires each real device to explain its own status.

name, roomstatus()description()
Classes describe identity; interfaces describe capabilities; composition assembles the working home.

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.

Capabilities.kt
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

An interface says what an object can do. Each implementing class owns the backing state and decides how the operation works. The shared 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.

SmartDevice.kt
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"
}
MemberDesign reasonConcept
name / roomShared data for every deviceInheritance
initReject invalid objects during constructionInvariant
status()Every subtype must supply a meaningful answerAbstraction
description()Reusable default that subtypes may extendOpen 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.

SmartLight.kt
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().

SmartFanAndDoorLock.kt
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.

Animated constructor trace
val device = SmartLight("Desk lamp", "Bedroom", 70)
  1. 1Evaluate name, room, and brightness arguments
  2. 2Call SmartDevice(name, room)
  3. 3Validate SmartDevice's shared name and room
  4. 4Return to SmartLight and create its private state
  5. 5Validate brightness in 0..100
  6. 6The SmartLight object is ready
Arguments travel to SmartDevice first; initialization returns to the requested concrete class afterward.

Two forms of parent reuse

In : 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 mutationSafe methodRule protected
light.brightness = 900light.setBrightness(70)Brightness remains in 0..100
fan.speed = -4fan.setSpeed(2)Speed remains in 1..3
light.isOn = truelight.turnOn()Power changes through the contract

Encapsulation is not secrecy for its own sake

The goal is a trustworthy object. Other code can use the device easily, but cannot place it in an impossible state.

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.

Polymorphism.kt
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() }
}
Interactive runtime dispatch

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.

Operate each object, then run Good night to send the right message through each capability interface.

Polymorphism removes class-by-class branching

We do not ask, “Is this a light? Is it a fan?” We ask a SmartDevice for 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.

SmartHome.kt
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() }
    }
}
RelationshipCodeMeaning
InheritanceSmartLight : SmartDeviceA SmartLight is a SmartDevice
InterfaceSmartLight : SwitchableA SmartLight can be switched
CompositionSmartHome → List<SmartDevice>A SmartHome has devices
Use the relationship that makes the sentence true.

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.

SmartHomeCenter.kt
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()
}
The first dashboard shows active devices; the second proves one routine safely controls different runtime types.

Download SmartHomeCenter.kt

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.

SmartTelevisionChallenge.kt
// 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().
}
  1. Add the television to SmartHome and confirm it appears automatically in the dashboard.
  2. Add it to a List<Switchable> and reuse the existing power-control loop.
  3. Create a BatteryPowered interface with batteryPercent and needsCharging().
  4. 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?