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.
Contracts
Define what can be drawn and scaled.
Shared base
Store common state in abstract Shape.
Specialize
Implement Circle, Rectangle, and Square.
Use polymorphism
Process every shape through one API.
Extend safely
Add unrelated Drawable types without changing callers.
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.
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
The vocabulary students should say aloud
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.
The shared abstract superclass: common state and behavior live here, while exact measurements are deferred.
name, colorarea(), perimeter()describe()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.
// 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?
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.
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)"
}| Member | Why it lives in Shape | OOP idea |
|---|---|---|
name / color | State shared by every shape | Encapsulation |
area() / perimeter() | Every shape must provide its own formula | Abstraction |
repaint() | One safe implementation for the whole hierarchy | Inheritance |
describe() | Useful default that subclasses may refine | Open behavior |
Abstract means incomplete—not vague
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.
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.
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
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.
- 1Evaluate Square constructor arguments
- 2Delegate to Rectangle(side, side, color, "Square")
- 3Rectangle delegates upward to Shape("Square", color)
- 4Run Shape init first, then Rectangle init
- 5Return to Square and expose one read-only side
- 6The Square object is ready—with width == height preserved
Step 1 of 6: parent construction completes before child initialization continues.
Two similar-looking ideas
: 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.
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
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.
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()}")
}Why this design is professional
| Decision | Benefit | Failure it prevents |
|---|---|---|
| Abstract Shape | Shares identity, state, and validated behavior | Duplicated logic across shape classes |
| Drawable interface | Rendering depends on a capability, not a family | A renderer coupled only to Shape |
| Scalable interface | Expresses an independent behavior | A deep superclass full of unrelated methods |
| Private setters | Mutations pass through safe operations | Invalid radius or a broken square invariant |
| require in init | Bad objects fail immediately with clear messages | Errors appearing much later in formulas |
| List<Shape> | New shapes work with existing measurement code | Type checks and duplicated branches |
Open for extension
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.
- Add Triangle. Validate the triangle inequality and implement Heron's formula.
- Add a text label. Confirm it works in
renderAllbut not inprintMeasurements. - Add totalArea. Accept
List<Shape>and return the sum of every area. - Prove the invariant. Scale a Square three times and assert
width == height. - Trigger validation. Try a negative radius and explain where the exception originates.
// 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.