The four pillars of Object-Oriented Programming, with idiomatic Kotlin

Object-Oriented Programming is the paradigm that underpins virtually every Android application. Before we explore SOLID principles, Clean Architecture, and the more advanced topics in this guide, it is worth grounding ourselves in the four pillars of OOP. These are not academic curiosities — every architectural decision you make later (why expose an interface, why favour composition, why hide a field) is a direct application of one of these four ideas. We cover each pillar with a precise definition, a worked Kotlin example, and the Kotlin-specific mechanisms that express it.

1. Encapsulation

Encapsulation bundles data (fields) and the behaviour that operates on that data (methods) together inside a single unit — the class — while restricting direct external access to the internal state. The outside world interacts only through a well-defined public interface. This is the mechanism behind information hiding: callers depend on what an object does, never on how it stores its data internally.

In Kotlin, encapsulation is expressed through visibility modifiers. There are four: public (the default — visible everywhere), internal (visible within the same Gradle module), protected (visible to the class and its subclasses), and private (visible only within the declaring class or file).

Encapsulation — hidden state with controlled mutation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BankAccount(initialBalance: Double) {
    // Internal state is private: nobody can assign to it directly
    private var balance: Double = initialBalance

    // Public methods enforce the rules (invariants) of the class
    fun deposit(amount: Double) {
        require(amount > 0) { "Deposit must be positive" }
        balance += amount
    }

    fun withdraw(amount: Double): Boolean {
        if (amount <= 0 || amount > balance) return false
        balance -= amount
        return true
    }

    // Read-only access -- callers can observe but not mutate
    fun currentBalance(): Double = balance
}

val account = BankAccount(100.0)
account.deposit(50.0)        // OK -- goes through the rule check
// account.balance = -9999.0 // COMPILE ERROR -- balance is private
println(account.currentBalance())  // 150.0

Without encapsulation, any caller could set balance to a negative number, bypassing the business rule entirely. By making the field private and forcing all mutation through deposit() and withdraw(), the class guarantees its own consistency — the invariant ‘balance is never negative’ can never be violated from outside.

The backing property pattern

A pervasive Android idiom is the backing property: a private, mutable holder paired with a public, read-only view. This is exactly how a ViewModel exposes state — the ViewModel can mutate _state internally, but the UI receives an immutable StateFlow it cannot tamper with.

Backing property — mutable inside, read-only outside

1
2
3
4
5
6
7
8
9
10
11
class UserViewModel : ViewModel() {
    // Private mutable backing field
    private val _state = MutableStateFlow<UiState>(UiState.Loading)

    // Public read-only exposure -- callers cannot call .value = ...
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun refresh() {
        _state.value = UiState.Loading   // only the ViewModel can mutate
    }
}

Interview Tip:

Kotlin data classes expose their constructor properties publicly by default. For value objects this is fine, but prefer immutable val properties and the generated copy() method over mutable var fields, so the object cannot be changed under you after creation.

Martin makes a pointed observation in Clean Architecture (Ch. 5 — Object-Oriented Programming): Java and Kotlin actually weakened encapsulation compared to what C achieved through header files. In C, callers could see only the function signatures in the header — the private member variables were invisible. In Java and Kotlin the class declaration and its implementation live in the same file, so the compiler technically sees the private fields even though it cannot access them. “OO certainly depends on the idea that programmers are well-behaved enough not to circumvent encapsulated data.” (Martin, ibid.) In practice this is not a crisis, but it is a useful reminder that encapsulation is partly a convention upheld by the type system and partly a discipline upheld by the team.

2. Inheritance

Inheritance allows a subclass to acquire the properties and behaviour of a superclass, establishing an IS-A relationship and enabling code reuse. A Dog IS-A Animal; it inherits the ability to breathe and can override how it makes a sound.

A critical Kotlin design decision: all classes and methods are final by default. To permit subclassing you must explicitly mark the class open, and to permit overriding a method you must mark that method open as well. This is the opposite of Java and is a deliberate nudge toward composition: inheritance is opt-in, not the path of least resistance.

Inheritance — open class hierarchy with overrides

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
open class Animal(val name: String) {
    // open -> subclasses are allowed to override this
    open fun sound(): String = "..."

    // final by default -> every Animal breathes the same way
    fun breathe() = println("$name is breathing")
}

class Dog(name: String) : Animal(name) {
    override fun sound() = "Woof"
}

class Cat(name: String) : Animal(name) {
    override fun sound() = "Meow"
}

val animals: List<Animal> = listOf(Dog("Rex"), Cat("Mia"))
animals.forEach { animal ->
    animal.breathe()                          // inherited unchanged
    println("${animal.name}: ${animal.sound()}")  // overridden per type
}

Key Insight:

Favour composition over inheritance. Inheritance couples the subclass tightly to the superclass: a change in the superclass can silently break every subclass (the ‘fragile base class’ problem). When the IS-A relationship is not truly natural, prefer interfaces plus delegation. Kotlin’s by keyword makes delegation a first-class language feature.

Composition via delegation — Kotlin’s ‘by’ keyword

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Logger { fun log(msg: String) }

class ConsoleLogger : Logger {
    override fun log(msg: String) = println("[LOG] $msg")
}

// Repository gets Logger behaviour by DELEGATION, not inheritance.
// All Logger calls are forwarded to the injected instance automatically.
class UserRepository(logger: Logger) : Logger by logger {
    fun save(user: User) {
        log("Saving ${user.id}")  // delegated to ConsoleLogger
    }
}

Martin’s observation in Clean Architecture (Ch. 5) is worth noting: inheritance is not entirely new to OO. “Inheritance is simply the redeclaration of a group of variables and functions within a closed scope. But programmers were already able to do this manually in C long before OO languages existed.” (Martin, ibid.) What OO languages added was compiler enforcement — the is-a relationship, override validation, and virtual dispatch — turning what was a manual and fragile technique into a first-class, safe language feature. That enforcement is genuinely valuable; the insight is that the idea itself is not unique to OO.

3. Polymorphism

Polymorphism — from the Greek for ‘many forms’ — lets objects of different concrete types be treated through a common supertype. Code written against an interface or base class works correctly for any implementation, present or future, without modification. This is the single most important enabler of extensible software, and it is the mechanism behind the Open-Closed Principle we cover later.

There are two flavours. Subtype (runtime) polymorphism resolves which implementation to call based on the actual object at runtime. Parametric polymorphism (generics) lets a single piece of code operate over many types in a type-safe way.

Subtype polymorphism — one contract, many shapes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface Shape {
    fun area(): Double
    fun perimeter(): Double
}

class Circle(val radius: Double) : Shape {
    override fun area() = Math.PI * radius * radius
    override fun perimeter() = 2 * Math.PI * radius
}

class Rectangle(val w: Double, val h: Double) : Shape {
    override fun area() = w * h
    override fun perimeter() = 2 * (w + h)
}

// This function never changes, even when you add a new Shape later.
fun describe(shape: Shape) {
    println("area=%.2f perimeter=%.2f".format(shape.area(), shape.perimeter()))
}

listOf(Circle(5.0), Rectangle(4.0, 6.0)).forEach(::describe)

Kotlin’s sealed classes are a powerful, exhaustively-checked form of polymorphism. Because the compiler knows every possible subtype, a when expression over a sealed type requires no else branch — and if you add a new subtype, every when that doesn’t handle it fails to compile. This is the idiomatic way to model UI state.

Sealed classes — exhaustive polymorphism for UI state

1
2
3
4
5
6
7
8
9
10
11
sealed interface UiState {
    data object Loading : UiState
    data class Success(val data: List<User>) : UiState
    data class Error(val message: String) : UiState
}

fun render(state: UiState) = when (state) {  // no 'else' needed
    is UiState.Loading -> showSpinner()
    is UiState.Success -> showList(state.data)   // smart-cast to Success
    is UiState.Error   -> showError(state.message)
}

The architectural power: polymorphism enables dependency inversion

Polymorphic behaviour predates OO languages. In Unix’s C-based I/O system, every device driver exposes five standard functions: open, close, read, write, and seek. The kernel calls these functions through what are effectively function-pointer tables — it does not know whether it is talking to a disk, a terminal, or a network socket; it just calls the same five functions. “It is as though STDIN and STDOUT were Java-style interfaces, with implementations for each device.” (Martin, R.C. — Clean Architecture, Ch. 5 — Object-Oriented Programming) The polymorphic behaviour was there; what it lacked was safety and convenience — one wrong pointer assignment and the program crashed silently.

What OO languages gave us was polymorphism that is safe and convenient, without manually managing function-pointer tables. And from safe, convenient polymorphism follows a profound architectural consequence:

“Any source-code dependency, regardless of where it is, can be inverted.” (Martin, R.C. — Clean Architecture, Ch. 5 — Object-Oriented Programming)

Inserting an interface between a caller and a concrete class allows the source-code dependency to point in the opposite direction from the control flow at runtime:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// WITHOUT an interface — source-code dependency follows control flow
class OrderService {
    private val db = MySQLDatabase()   // depends on the concrete class
    fun placeOrder(order: Order) = db.save(order)
}

// WITH an interface — dependency is inverted
// The interface lives in the domain; the implementation lives in the data layer
interface OrderRepository { fun save(order: Order) }          // domain (stable)
class MySQLOrderRepository : OrderRepository { /* ... */ }    // data (detail)

// At runtime:  OrderService → calls → MySQLOrderRepository  (control flows forward)
// In source:   MySQLOrderRepository depends on OrderRepository  (dependency inverted)
class OrderService(private val repo: OrderRepository) {  // no knowledge of MySQL
    fun placeOrder(order: Order) = repo.save(order)
}

This is the mechanism behind Clean Architecture’s dependency rule and the Dependency Inversion Principle. Because any dependency can be inverted, the architect is not forced to align source-code dependencies with the flow of control. It becomes possible to organise the system so that the UI and the database both depend on the business rules — not the reverse. The source code of the business rules never mentions the UI or the database.

Two capabilities follow directly (Martin, ibid.):

  • Independent deployment. When the source code in one component changes, only that component needs to be redeployed. Changing the database implementation does not require rebuilding the business-rules module.
  • Independent development. If modules can be deployed independently, they can be developed independently by separate teams. The domain team and the data team can work in parallel once the interface contract is agreed.

4. Abstraction

Abstraction hides complexity behind a simple, essential contract. Where encapsulation hides data, abstraction hides implementation. The caller depends on the what and is completely insulated from the how. In Kotlin we achieve abstraction through interfaces (pure contracts, no state) and abstract classes (partial contracts that may hold state and provide some implemented methods).

Abstraction — interface as a contract the caller depends on

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// The contract -- says WHAT can be done, not HOW
interface UserRepository {
    suspend fun findById(id: String): User?
    suspend fun save(user: User)
    suspend fun delete(id: String)
}

// The ViewModel depends ONLY on the abstraction.
// It has no idea whether data comes from Room, Retrofit, or memory.
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    fun load(id: String) {
        viewModelScope.launch {
            val user = repo.findById(id)
            _state.value = user?.let { UiState.Success(listOf(it)) }
                ?: UiState.Error("Not found")
        }
    }
}

Because the ViewModel depends on the abstraction, you can swap a real network repository for an in-memory fake in unit tests without touching a line of ViewModel code. This is the practical payoff of abstraction, and it leads directly into the Dependency Inversion Principle.

The four pillars at a glance

Pillar Hides / Provides Core Question Kotlin Mechanism
Encapsulation Hides internal data Who can read or change this state? private / internal / val, backing property
Inheritance Reuses behaviour Can this type reuse another’s behaviour? open class / override / by (delegation)
Polymorphism Provides many forms Can different types share one call site? interface / sealed class / generics
Abstraction Hides implementation What is the contract, hiding the how? interface / abstract class

Key Insight:

Martin’s conclusion in Clean Architecture (Ch. 5) reframes what OO actually is: “OO is the ability to gain absolute control, through the use of polymorphism, over every source-code dependency in the system.” It allows architects to create a plug-in architecture with modules containing high-level policies that are independent of modules containing low-level details. Low-level details are relegated to plug-in modules that can be deployed and developed independently. Encapsulation and inheritance are supporting pillars; polymorphism and the dependency inversion it enables is the core architectural gift of OO. (Martin, R.C. — Clean Architecture: A Craftsman’s Guide to Software Structure and Design, Ch. 5 — Object-Oriented Programming)


↑ Chapter Index


This site uses Just the Docs, a documentation theme for Jekyll.