Phase 3 · Object-Oriented KotlinModule 20~65 min read

OOP Capstone: Shape Studio

Build a complete Shape hierarchy with an abstract superclass, interfaces, constructor chains, super calls, encapsulation, and runtime polymorphism.

The capstone mission

We're building a small, production-minded Shape Studio. It has a shared abstract model, specialized shapes, capability interfaces, protected invariants, and functions that work through polymorphism instead of type-checking every object.

This is the lesson where the separate OOP ideas become one system. Teach it in layers: reveal one decision, run the code, ask students to predict the next output, then add the next class.

Build sequence
01

Contracts

Define what can be drawn and scaled.

02

Shared base

Store common state in abstract Shape.

03

Specialize

Implement Circle, Rectangle, and Square.

04

Use polymorphism

Process every shape through one API.

05

Extend safely

Add unrelated Drawable types without changing callers.

Each layer has one responsibility, so students never face the entire program at once.

By the end, students will be able to:

  • Explain the difference between a class, an object, a superclass, and a subtype
  • Choose an abstract class for shared identity and state
  • Choose interfaces for reusable capabilities and loose coupling
  • Trace primary, secondary, and superclass constructor execution
  • Use override, super, and runtime polymorphism confidently
  • Protect object validity instead of exposing unsafe mutation

First: classes are blueprints, objects are values

A class defines which data and behavior a kind of thing has. An object is one concrete instance created from that class. The three objects below share the Circle blueprint, but each owns its own property values and identity.

One class → many objects

Class · blueprint

Circle

radius: Double

color: String

area(): Double

val sun

radius = 2.0

color = sky

val coin

radius = 1.0

color = amber

val orbit

radius = 3.0

color = violet

Changing coin.radius would not change sun.radius; every object owns its state.

The vocabulary students should say aloud

Circle is a class. Circle(2.0) constructs an object. Circle is a Shape, while radius belongs to each Circle object.

The whole architecture at a glance

Click each node in the diagram. Solid arrows represent class inheritance; dashed arrows represent interface implementation. Notice that CanvasLabel and Shape share a capability without sharing a superclass.

Interactive class diagram
Shapeselected node

The shared abstract superclass: common state and behavior live here, while exact measurements are deferred.

name, colorarea(), perimeter()describe()
Square extends Rectangle; Shape implements two contracts; CanvasLabel proves Drawable is broader than Shape.

Step 1 · Start with capabilities

Before choosing classes, ask what the system needs objects to do. Anything drawable must produce a drawing description. Anything scalable must respond to a positive scale factor. Interfaces capture those promises without choosing storage or forcing a class family.

Contracts.kt
// An interface describes a capability, not a family tree.
interface Drawable {
    fun draw(): String

    // Interfaces may provide reusable default behavior.
    fun drawingKind(): String = "vector"
}

interface Scalable {
    fun scale(factor: Double)
}

Why not put everything in Shape?

Because not every drawable item is a geometric shape. A label, icon, chart, or image can implement Drawable. Code that accepts Drawable stays useful as the app grows.

Step 2 · Create the abstract Shape superclass

Every shape has a name and color, can be repainted, and must report an area and perimeter. The shared parts belong in an abstract class; formulas stay abstract because a generic Shape cannot know them. We also centralize validation so every subtype begins in a valid state.

Shape.kt
abstract class Shape(
    val name: String,
    initialColor: String
) : Drawable, Scalable {

    var color: String = initialColor
        private set

    init {
        require(name.isNotBlank()) { "A shape needs a name" }
        require(initialColor.isNotBlank()) { "Color cannot be blank" }
    }

    abstract fun area(): Double
    abstract fun perimeter(): Double

    fun repaint(newColor: String) {
        require(newColor.isNotBlank()) { "Color cannot be blank" }
        color = newColor
    }

    protected fun requireValidScale(factor: Double) {
        require(factor > 0.0) { "Scale factor must be positive" }
    }

    open fun describe(): String = "$name ($color)"
}
MemberWhy it lives in ShapeOOP idea
name / colorState shared by every shapeEncapsulation
area() / perimeter()Every shape must provide its own formulaAbstraction
repaint()One safe implementation for the whole hierarchyInheritance
describe()Useful default that subclasses may refineOpen behavior

Abstract means incomplete—not vague

You cannot write Shape("Mystery", "blue"). The class deliberately leaves measurement formulas unfinished, so Kotlin forces every concrete subtype to supply them.

Step 3 · Add concrete subclasses

Circle: override every shape-specific operation

The Circle constructor delegates shared values to Shape(...). Its overrides provide the real formulas. The super.describe() call reuses the parent implementation, then adds Circle detail.

Circle.kt
class Circle(
    radius: Double,
    color: String = "sky"
) : Shape(name = "Circle", initialColor = color) {

    var radius: Double = radius
        private set

    init {
        require(radius > 0.0) { "Radius must be positive" }
    }

    override fun area(): Double = Math.PI * radius * radius
    override fun perimeter(): Double = 2 * Math.PI * radius

    override fun scale(factor: Double) {
        requireValidScale(factor)
        radius *= factor
    }

    override fun draw(): String = "Circle(radius=$radius, color=$color)"

    // super calls the implementation inherited from Shape.
    override fun describe(): String =
        "${super.describe()} | radius=$radius"
}

Rectangle and Square: reuse with a protected invariant

Rectangle is open because Square extends it. A protected primary constructor lets Square pass a better runtime name, while the public secondary constructor stays pleasant for callers. Width and height have private setters: callers cannot turn a Square into a non-square by mutating one dimension.

RectangleAndSquare.kt
open class Rectangle protected constructor(
    width: Double,
    height: Double,
    color: String,
    shapeName: String
) : Shape(shapeName, color) {

    var width: Double = width
        private set
    var height: Double = height
        private set

    // Friendly public constructor for a normal Rectangle.
    constructor(
        width: Double,
        height: Double,
        color: String = "amber"
    ) : this(width, height, color, "Rectangle")

    init {
        require(width > 0.0 && height > 0.0) {
            "Width and height must be positive"
        }
    }

    override fun area(): Double = width * height
    override fun perimeter(): Double = 2 * (width + height)

    override fun scale(factor: Double) {
        requireValidScale(factor)
        width *= factor
        height *= factor
    }

    open override fun draw(): String =
        "Rectangle(${width}x$height, color=$color)"

    override fun describe(): String =
        "${super.describe()} | $width x $height"
}

class Square(
    side: Double,
    color: String = "mint"
) : Rectangle(side, side, color, "Square") {

    val side: Double
        get() = width

    override fun draw(): String =
        "Square(side=$side, color=$color)"
}

A professional modeling detail

The classic “Square is a Rectangle” model becomes unsafe if callers may set width and height independently. This version exposes only uniform scale(), so width == height remains true for every Square. In a drawing editor with independent resizing, composition or sibling classes may be a better design.

Constructors and the superclass chain

Kotlin must finish the parent portion of an object before initializing the child portion. Select a type and replay its constructor. Square demonstrates the longest chain: Square → Rectangle → Shape.

Animated constructor trace
val objectUnderConstruction = Square(side = 5.0)
  1. 1Evaluate Square constructor arguments
  2. 2Delegate to Rectangle(side, side, color, "Square")
  3. 3Rectangle delegates upward to Shape("Square", color)
  4. 4Run Shape init first, then Rectangle init
  5. 5Return to Square and expose one read-only side
  6. 6The Square object is ready—with width == height preserved

Step 1 of 6: parent construction completes before child initialization continues.

Construction travels upward first; initialization then completes from the superclass back to the requested subtype.

Two similar-looking ideas

In a class header, : Shape(...) invokes the superclass constructor. Inside an overridden method,super.describe() invokes the superclass implementation. Kotlin does not spell the constructor call as super(...), but both operations deliberately reuse the parent.

Step 4 · Use polymorphism

A List<Shape> can hold Circle, Rectangle, and Square objects. The loop calls the sameshape.area() expression each time; Kotlin dispatches to the implementation belonging to the runtime object. No if (shape is Circle) chain is needed.

Runtime method dispatch

One declared type

val shapes: List<Shape>

1 · reference

shapes[0]: Shape

The loop only promises a Shape.

2 · runtime object

○Circle

Kotlin inspects the actual object.

3 · override runs

shape.area() → 12.57

π × radius²

console

Circle area=12.57

Choose a runtime object or play the sequence. The declared Shape reference stays the same while the executed override changes.
ShapeServices.kt
private fun Double.pretty(): String = "%.2f".format(this)

fun printMeasurements(shapes: List<Shape>) {
    for (shape in shapes) {
        // Same call; the runtime object chooses the override.
        println(
            "${shape.name.padEnd(9)} " +
            "area=${shape.area().pretty()} " +
            "perimeter=${shape.perimeter().pretty()}"
        )
    }
}

class CanvasLabel(private val text: String) : Drawable {
    override fun draw(): String = "Aa $text"
}

fun renderAll(items: List<Drawable>) {
    items.forEach { println("${it.drawingKind()}: ${it.draw()}") }
}

The interface payoff

printMeasurements accepts Shapes because it needs area and perimeter. renderAll accepts Drawables because it only needs drawing. Giving a function the smallest contract it needs reduces coupling and makes future classes easier to add and test.

The complete Shape Studio program

Here is the entire runnable example in one file. It combines constructors, validation, an abstract superclass, multilevel inheritance, two interfaces, overrides, a super call, polymorphic collections, and an unrelated object that participates through an interface. Students can also download the finished ShapeStudio.kt file and open it directly in IntelliJ IDEA.

ShapeStudio.kt
interface Drawable {
    fun draw(): String
    fun drawingKind(): String = "vector"
}

interface Scalable {
    fun scale(factor: Double)
}

abstract class Shape(
    val name: String,
    initialColor: String
) : Drawable, Scalable {
    var color: String = initialColor
        private set

    init {
        require(name.isNotBlank()) { "A shape needs a name" }
        require(initialColor.isNotBlank()) { "Color cannot be blank" }
    }

    abstract fun area(): Double
    abstract fun perimeter(): Double

    fun repaint(newColor: String) {
        require(newColor.isNotBlank()) { "Color cannot be blank" }
        color = newColor
    }

    protected fun requireValidScale(factor: Double) {
        require(factor > 0.0) { "Scale factor must be positive" }
    }

    open fun describe(): String = "$name ($color)"
}

class Circle(radius: Double, color: String = "sky") :
    Shape(name = "Circle", initialColor = color) {
    var radius: Double = radius
        private set

    init { require(radius > 0.0) { "Radius must be positive" } }

    override fun area() = Math.PI * radius * radius
    override fun perimeter() = 2 * Math.PI * radius

    override fun scale(factor: Double) {
        requireValidScale(factor)
        radius *= factor
    }

    override fun draw() = "Circle(radius=$radius, color=$color)"
    override fun describe() = "${super.describe()} | radius=$radius"
}

open class Rectangle protected constructor(
    width: Double,
    height: Double,
    color: String,
    shapeName: String
) : Shape(shapeName, color) {
    var width: Double = width
        private set
    var height: Double = height
        private set

    constructor(width: Double, height: Double, color: String = "amber") :
        this(width, height, color, "Rectangle")

    init {
        require(width > 0.0 && height > 0.0) {
            "Width and height must be positive"
        }
    }

    override fun area() = width * height
    override fun perimeter() = 2 * (width + height)

    override fun scale(factor: Double) {
        requireValidScale(factor)
        width *= factor
        height *= factor
    }

    open override fun draw() = "Rectangle(${width}x$height, color=$color)"
    override fun describe() = "${super.describe()} | $width x $height"
}

class Square(side: Double, color: String = "mint") :
    Rectangle(side, side, color, "Square") {
    val side: Double get() = width
    override fun draw() = "Square(side=$side, color=$color)"
}

class CanvasLabel(private val text: String) : Drawable {
    override fun draw() = "Aa $text"
}

private fun Double.pretty() = "%.2f".format(this)

fun printMeasurements(shapes: List<Shape>) {
    shapes.forEach { shape ->
        println(
            "${shape.name.padEnd(9)} " +
            "area=${shape.area().pretty()} " +
            "perimeter=${shape.perimeter().pretty()}"
        )
    }
}

fun renderAll(items: List<Drawable>) {
    items.forEach { println("${it.drawingKind()}: ${it.draw()}") }
}

fun main() {
    val circle: Shape = Circle(radius = 2.0)
    val rectangle: Shape = Rectangle(width = 3.0, height = 4.0)
    val square: Shape = Square(side = 5.0)
    val shapes: List<Shape> = listOf(circle, rectangle, square)

    println("MEASUREMENTS")
    printMeasurements(shapes)

    println("\nDRAWABLE GALLERY")
    val gallery: List<Drawable> = listOf(
        circle, rectangle, square, CanvasLabel("OOP Gallery")
    )
    renderAll(gallery)

    square.scale(0.5)
    println("\nScaled square area=${square.area().pretty()}")
}
Run it, then ask students which concrete method produced each line before revealing the answer.

Why this design is professional

DecisionBenefitFailure it prevents
Abstract ShapeShares identity, state, and validated behaviorDuplicated logic across shape classes
Drawable interfaceRendering depends on a capability, not a familyA renderer coupled only to Shape
Scalable interfaceExpresses an independent behaviorA deep superclass full of unrelated methods
Private settersMutations pass through safe operationsInvalid radius or a broken square invariant
require in initBad objects fail immediately with clear messagesErrors appearing much later in formulas
List<Shape>New shapes work with existing measurement codeType checks and duplicated branches

Open for extension

Adding Triangle : Shape requires no edits to printMeasurements. Adding a non-shape Image : Drawable requires no edits to renderAll. Existing code stays closed to modification while the model remains open to useful new types.

Practice extensions

Build these in order. Each challenge changes one design dimension, which makes them ideal pauses during a lecture or a follow-along assignment.

  1. Add Triangle. Validate the triangle inequality and implement Heron's formula.
  2. Add a text label. Confirm it works in renderAll but not in printMeasurements.
  3. Add totalArea. Accept List<Shape> and return the sum of every area.
  4. Prove the invariant. Scale a Square three times and assert width == height.
  5. Trigger validation. Try a negative radius and explain where the exception originates.
TriangleChallenge.kt
// Challenge: add a Triangle without editing existing classes.
class Triangle(
    private var a: Double,
    private var b: Double,
    private var c: Double,
    color: String = "violet"
) : Shape("Triangle", color) {
    // 1. Validate the triangle inequality: a + b > c, etc.
    // 2. perimeter() = a + b + c
    // 3. area() uses Heron's formula
    // 4. scale() changes all three sides
    // 5. draw() returns a useful description
}

Recap & knowledge check

Key takeaways

  • A class is a blueprint; each object is a separate runtime instance with its own state.
  • An abstract superclass models one shared family and may combine state, concrete behavior, and abstract requirements.
  • Interfaces model capabilities. Unrelated classes can satisfy the same small contract, and one class can satisfy several.
  • Superclass construction completes before child initialization; : Parent(...) and super.method() reuse the parent in different contexts.
  • Polymorphism means a base reference can call behavior selected by the object's runtime type.
  • Encapsulation is about preserving valid state—not merely marking everything private.

Shape Studio review

1. Why is Shape abstract?

2. Why does renderAll accept List<Drawable> instead of List<Shape>?

3. What runs for shape.area() when shape refers to a Circle object?

4. What does super.describe() do inside Circle?

5. Why are Rectangle width and height setters private?

6. Which order constructs Square?

You've now designed and reasoned about a complete OOP system—not just isolated syntax. Next, reinforce the same ideas with OOP Capstone: Smart Home, a familiar real-life model that adds composition and capability-based device control.