Object-Oriented Programming in Kotlin (OOP)

Object-Oriented Programming in Kotlin (OOP)

Object-oriented programming is one of those ideas that looks simple at first and then quietly becomes the backbone of everything you build. At a high level, it is about organizing code around objects that hold data and behavior together. In Kotlin, that idea feels especially natural because the language was designed to make OOP cleaner, safer, and less noisy than many older languages.

If you have ever opened a large codebase and felt lost in a maze of functions, global variables, and duplicated logic, OOP can bring a sense of structure back into the picture. It gives your code a shape. It helps you think in terms of real-world concepts. A User is a user. An Order is an order. A Cart is a cart. Each one owns its own data and knows how to behave.

Kotlin makes this style of programming elegant. It gives you classes, objects, inheritance, interfaces, encapsulation, polymorphism, data classes, sealed classes, companion objects, and more. But unlike some languages where OOP can feel heavy and ceremonial, Kotlin keeps things concise. You can express a lot with very little code.

In this article, we will explore object-oriented programming in Kotlin from the ground up, with practical examples and a human, developer-to-developer tone. The goal is not just to learn syntax, but to understand how to think in Kotlin when building software.

What object-oriented programming really means

Object-oriented programming is a way of designing software around objects. An object combines:

  • State: the data it stores

  • Behavior: the things it can do

For example, a BankAccount object may store a balance and allow deposits or withdrawals. A Car may store speed, color, and model, and it may have methods like accelerate() or brake().

This approach is useful because it mirrors the way we naturally think about things in the real world. Instead of writing one giant block of logic, we divide a problem into smaller, manageable pieces.

In Kotlin, OOP feels clean because the language is modern, concise, and expressive. You can focus more on the design and less on ceremony.

Why Kotlin is a great language for OOP

Kotlin was built to run on the JVM, but it was also designed to be more readable and less verbose than Java. That matters a lot in object-oriented programming.

Here is why Kotlin stands out:

  • It reduces boilerplate.

  • It has built-in null safety.

  • It supports both classical OOP and modern functional features.

  • It gives you powerful tools like data classes and sealed classes.

  • It makes inheritance and interfaces simple to use.

  • It encourages clear, maintainable code.

In other words, Kotlin gives you the structure of OOP without making your code feel stiff.

Your first Kotlin class

A class is a blueprint for creating objects. Let’s start with a simple example.

class Person {
    var name: String = ""
    var age: Int = 0

    fun introduce() {
        println("My name is $name and I am $age years old.")
    }
}

Now create an object from that class:

fun main() {
    val person = Person()
    person.name = "Hassan"
    person.age = 30
    person.introduce()
}

Output:

My name is Hassan and I am 30 years old.

This is the basic shape of OOP in Kotlin: define a class, create an instance, use its properties and methods.

Primary constructors in Kotlin

Kotlin lets you define constructors in a much cleaner way than many older languages. Instead of writing a separate constructor body all the time, you can place constructor parameters right after the class name.

class Person(val name: String, var age: Int) {
    fun introduce() {
        println("My name is $name and I am $age years old.")
    }
}

Usage:

fun main() {
    val person = Person("Amina", 25)
    person.introduce()
}

This is one of Kotlin’s best features. It makes your classes shorter and easier to read.

val vs var in constructors

When you use val, the property becomes read-only after construction.

class Book(val title: String)

When you use var, the property can be changed later.

class Book(var title: String)

This small detail matters a lot in design. Prefer val when you can. Immutable objects are usually easier to reason about.

Initialization with init

Sometimes you need to run code when an object is created. Kotlin gives you the init block for that.

class User(val username: String, var age: Int) {
    init {
        println("User $username created.")
    }
}

Example:

fun main() {
    val user = User("maria", 20)
}

Output:

User maria created.

You can also use init to validate data.

class Product(val name: String, val price: Double) {
    init {
        require(price >= 0) { "Price cannot be negative" }
    }
}

That little check can save you from bugs later.

Secondary constructors

Kotlin also supports secondary constructors, though in many cases the primary constructor is enough.

class Rectangle(val width: Int, val height: Int) {
    constructor(size: Int) : this(size, size)

    fun area(): Int = width * height
}

Example:

fun main() {
    val square = Rectangle(5)
    println(square.area())
}

This creates a square by reusing the primary constructor.

Still, in Kotlin, secondary constructors should be used carefully. Often a default parameter is a cleaner solution.

class Rectangle(val width: Int, val height: Int = width)

That version is simpler and easier to read.

Encapsulation: protecting your data

Encapsulation means keeping internal data safe and exposing only what should be available. This is one of the core ideas of object-oriented programming.

Imagine a bank account. You should not be able to directly set the balance to anything you want. Instead, the account should provide controlled methods like deposit and withdraw.

class BankAccount(initialBalance: Double) {
    var balance: Double = initialBalance
        private set

    fun deposit(amount: Double) {
        if (amount > 0) {
            balance += amount
        }
    }

    fun withdraw(amount: Double) {
        if (amount > 0 && amount <= balance) {
            balance -= amount
        }
    }
}

Usage:

fun main() {
    val account = BankAccount(1000.0)
    account.deposit(500.0)
    account.withdraw(200.0)

    println(account.balance)
}

The balance property can be read from outside, but it cannot be modified directly. That is encapsulation in action.

This pattern helps prevent invalid states and keeps your classes in control of their own data.

Access modifiers in Kotlin

Kotlin gives you several visibility levels:

  • public

  • private

  • protected

  • internal

By default, members are public.

private

Visible only inside the class or file, depending on context.

class Secret {
    private val code = "1234"

    fun reveal() {
        println(code)
    }
}

protected

Visible inside the class and subclasses.

open class Animal {
    protected fun breathe() {
        println("Breathing")
    }
}

internal

Visible within the same module.

internal class Helper {
    fun doSomething() {
        println("Internal helper")
    }
}

These modifiers help you design cleaner APIs. Good OOP is not about making everything accessible. It is about exposing only what is truly needed.

Methods and behavior

Properties store data, while methods define behavior. A class should usually bundle both together.

class Calculator {
    fun add(a: Int, b: Int): Int {
        return a + b
    }

    fun multiply(a: Int, b: Int): Int {
        return a * b
    }
}

Usage:

fun main() {
    val calculator = Calculator()
    println(calculator.add(3, 4))
    println(calculator.multiply(5, 6))
}

This may look simple, but the real power comes when behavior is attached to meaningful objects.

For example:

class Temperature(var celsius: Double) {
    fun toFahrenheit(): Double = (celsius * 9 / 5) + 32
    fun toKelvin(): Double = celsius + 273.15
}

Now the conversion logic belongs to the concept it represents.

Object initialization and default values

Kotlin lets you assign default values in constructors, which makes classes more flexible.

class Employee(
    val name: String,
    val role: String = "Developer",
    val active: Boolean = true
)

Usage:

fun main() {
    val emp1 = Employee("Youssef")
    val emp2 = Employee("Sara", "Designer", false)

    println(emp1.role)
    println(emp2.role)
}

Default values reduce the need for multiple constructors and make your API easier to use.

Data classes: simple objects with useful features

A data class is one of Kotlin’s most practical features. It is designed to hold data, and Kotlin automatically generates useful functions like:

  • toString()

  • equals()

  • hashCode()

  • copy()

  • componentN() functions for destructuring

Example:

data class User(val name: String, val email: String)

Usage:

fun main() {
    val user1 = User("Ali", "ali@example.com")
    val user2 = User("Ali", "ali@example.com")

    println(user1)
    println(user1 == user2)
}

Output:

User(name=Ali, email=ali@example.com)
true

This is extremely useful for models, DTOs, and state objects.

Copying data classes

One of the nicest features is copy().

data class Profile(val username: String, val age: Int)

fun main() {
    val original = Profile("mona", 24)
    val updated = original.copy(age = 25)

    println(original)
    println(updated)
}

This creates a new object based on the old one, with only the changed fields modified.

That makes your code safer and more predictable.

Destructuring declarations

Because data classes provide componentN() functions, you can destructure them.

data class Point(val x: Int, val y: Int)

fun main() {
    val point = Point(10, 20)
    val (x, y) = point

    println("x = $x, y = $y")
}

This is especially handy when working with structured data.

Inheritance: building on existing classes

Inheritance lets one class reuse and extend another class. It is useful when there is a clear “is-a” relationship.

For example, a Dog is an Animal.

open class Animal(val name: String) {
    fun eat() {
        println("$name is eating")
    }
}

Now a subclass:

class Dog(name: String) : Animal(name) {
    fun bark() {
        println("$name says woof!")
    }
}

Usage:

fun main() {
    val dog = Dog("Buddy")
    dog.eat()
    dog.bark()
}

open keyword

In Kotlin, classes and methods are final by default. That means they cannot be inherited or overridden unless you explicitly mark them as open.

This design is intentional. It encourages safer code and prevents accidental inheritance.

open class Vehicle {
    open fun move() {
        println("Vehicle is moving")
    }
}

Then override it:

class Car : Vehicle() {
    override fun move() {
        println("Car is driving")
    }
}

Overriding methods

When a subclass needs different behavior, it can override a method from the parent class.

open class Shape {
    open fun draw() {
        println("Drawing a shape")
    }
}

class Circle : Shape() {
    override fun draw() {
        println("Drawing a circle")
    }
}

Usage:

fun main() {
    val shape: Shape = Circle()
    shape.draw()
}

Output:

Drawing a circle

This is polymorphism in action, which we will explore more fully soon.

The super keyword

Sometimes you want to reuse the parent implementation inside an overridden method.

open class Person(val name: String) {
    open fun greet() {
        println("Hello from $name")
    }
}

class Teacher(name: String) : Person(name) {
    override fun greet() {
        super.greet()
        println("I teach Kotlin.")
    }
}

Usage:

fun main() {
    val teacher = Teacher("Lina")
    teacher.greet()
}

This allows you to extend behavior instead of replacing it completely.

Polymorphism: one interface, many forms

Polymorphism means that a single interface or base type can refer to different concrete types.

This is what makes code flexible and reusable.

Imagine a function that works with any kind of Animal.

open class Animal {
    open fun sound() {
        println("Some animal sound")
    }
}

class Cat : Animal() {
    override fun sound() {
        println("Meow")
    }
}

class Dog : Animal() {
    override fun sound() {
        println("Woof")
    }
}

Now one function can handle many types:

fun makeAnimalSound(animal: Animal) {
    animal.sound()
}

Usage:

fun main() {
    makeAnimalSound(Cat())
    makeAnimalSound(Dog())
}

This works because Kotlin decides at runtime which implementation to call.

Polymorphism is one of the main reasons OOP stays useful in real projects.

Abstract classes

An abstract class is a class that cannot be instantiated directly. It is meant to be a base class for other classes.

abstract class Shape {
    abstract fun area(): Double

    fun describe() {
        println("I am a shape")
    }
}

A subclass must implement the abstract method:

class Square(private val side: Double) : Shape() {
    override fun area(): Double = side * side
}

Usage:

fun main() {
    val square = Square(4.0)
    println(square.area())
    square.describe()
}

Abstract classes are useful when you want to define a common structure while forcing subclasses to provide their own implementation for specific behavior.

Interfaces in Kotlin

Interfaces define a contract. They say what a class can do, without necessarily defining how it does it.

interface Printable {
    fun print()
}

A class can implement the interface:

class Report : Printable {
    override fun print() {
        println("Printing report")
    }
}

Usage:

fun main() {
    val report: Printable = Report()
    report.print()
}

Interfaces with default methods

Kotlin interfaces can contain method implementations too.

interface Logger {
    fun log(message: String) {
        println("Log: $message")
    }
}

Then a class can use it directly or override it.

class ConsoleLogger : Logger

Interfaces are often better than inheritance when you want flexible design. A class can implement multiple interfaces, but it can only inherit from one class.

Multiple interfaces

Kotlin allows a class to implement more than one interface.

interface Flyable {
    fun fly()
}

interface Swimmable {
    fun swim()
}

class Duck : Flyable, Swimmable {
    override fun fly() {
        println("Duck is flying")
    }

    override fun swim() {
        println("Duck is swimming")
    }
}

This is a powerful design tool. It lets you model capabilities instead of forcing everything into a rigid class hierarchy.

The difference between inheritance and composition

This is one of the most important design lessons in OOP.

Inheritance says: “this object is a type of that object.”

Composition says: “this object has another object inside it.”

In many cases, composition is better.

For example, instead of making a Car inherit from Engine, it may be better for Car to have an Engine.

class Engine {
    fun start() {
        println("Engine started")
    }
}

class Car(private val engine: Engine) {
    fun drive() {
        engine.start()
        println("Car is moving")
    }
}

Composition gives you more flexibility and less tight coupling. A lot of experienced Kotlin developers prefer composition over inheritance unless inheritance truly makes sense.

Nested and inner classes

Kotlin supports classes inside classes.

Nested class

A nested class does not hold a reference to its outer class.

class Outer {
    class Nested {
        fun greet() {
            println("Hello from nested class")
        }
    }
}

Inner class

An inner class can access members of the outer class.

class Outer {
    private val message = "Hello from Outer"

    inner class Inner {
        fun showMessage() {
            println(message)
        }
    }
}

Usage:

fun main() {
    val outer = Outer()
    val inner = outer.Inner()
    inner.showMessage()
}

Use inner classes carefully. They can be useful, but they can also make your design more complex if overused.

Companion objects: class-level behavior

Sometimes you need functionality related to the class itself rather than a particular instance. Kotlin uses companion objects for that.

class MathHelper {
    companion object {
        const val PI = 3.14159

        fun square(x: Int): Int {
            return x * x
        }
    }
}

Usage:

fun main() {
    println(MathHelper.PI)
    println(MathHelper.square(7))
}

This behaves a bit like static members in Java, but Kotlin keeps the model more flexible.

Object declarations: singleton objects

Kotlin has a built-in way to create a singleton.

object AppConfig {
    val appName = "MyApp"
    val version = "1.0"
}

Usage:

fun main() {
    println(AppConfig.appName)
}

A singleton is useful when you want one shared instance across the whole app.

Common uses include:

  • configuration

  • logging

  • utility managers

  • shared state holders

Object expressions: anonymous objects

Sometimes you need a one-off object without creating a named class.

val printer = object {
    fun printMessage() {
        println("Anonymous object")
    }
}

Usage:

fun main() {
    val printer = object {
        fun printMessage() {
            println("Anonymous object")
        }
    }

    printer.printMessage()
}

This is useful for small, local behavior.

Sealed classes: controlled hierarchies

Sealed classes are one of Kotlin’s best tools for modeling fixed sets of possibilities.

Imagine a login result:

sealed class LoginResult {
    data class Success(val userId: String) : LoginResult()
    data class Error(val message: String) : LoginResult()
    object Loading : LoginResult()
}

Now you can handle every possible case clearly.

fun handleResult(result: LoginResult) {
    when (result) {
        is LoginResult.Success -> println("Welcome ${result.userId}")
        is LoginResult.Error -> println("Error: ${result.message}")
        LoginResult.Loading -> println("Loading...")
    }
}

Sealed classes help you avoid hidden states. They make your code more explicit and safer.

This is especially useful in UI state management and business logic.

Enumerations and OOP

Enums represent a fixed set of constants.

enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

You can also give enum entries behavior.

enum class Operation {
    ADD {
        override fun apply(a: Int, b: Int) = a + b
    },
    SUBTRACT {
        override fun apply(a: Int, b: Int) = a - b
    };

    abstract fun apply(a: Int, b: Int): Int
}

Usage:

fun main() {
    println(Operation.ADD.apply(10, 5))
    println(Operation.SUBTRACT.apply(10, 5))
}

Enums are often used when the set of possibilities is small and stable.

Delegation: letting another object do the work

Kotlin has a neat feature called delegation. It lets one object hand off part of its behavior to another object.

Class delegation

interface Speaker {
    fun speak()
}

class EnglishSpeaker : Speaker {
    override fun speak() {
        println("Hello")
    }
}

class Person(speaker: Speaker) : Speaker by speaker

Usage:

fun main() {
    val person = Person(EnglishSpeaker())
    person.speak()
}

Here, Person delegates the speak() call to the Speaker implementation. This can reduce boilerplate and improve design.

Extensions and OOP

Kotlin supports extension functions, which are not technically OOP methods, but they work beautifully alongside classes.

fun String.countWords(): Int {
    return this.trim().split(Regex("\\s+")).size
}

Usage:

fun main() {
    val sentence = "Kotlin makes OOP feel clean"
    println(sentence.countWords())
}

Extensions let you add useful behavior without modifying the original class. That said, they do not actually change the class itself. They are resolved statically.

Real-world example: a shopping cart

Let’s build a simple example that brings several OOP ideas together.

data class Product(val name: String, val price: Double)

class Cart {
    private val items = mutableListOf<Product>()

    fun addItem(product: Product) {
        items.add(product)
    }

    fun removeItem(product: Product) {
        items.remove(product)
    }

    fun totalPrice(): Double {
        return items.sumOf { it.price }
    }

    fun listItems() {
        items.forEach {
            println("${it.name} - ${it.price}")
        }
    }
}

Usage:

fun main() {
    val cart = Cart()

    cart.addItem(Product("Laptop", 1200.0))
    cart.addItem(Product("Mouse", 25.0))
    cart.addItem(Product("Keyboard", 75.0))

    cart.listItems()
    println("Total: ${cart.totalPrice()}")
}

This example uses:

  • a data class for product data

  • encapsulation in the cart

  • methods for behavior

  • internal state hidden from the outside

That is the kind of design that scales better than a pile of loose functions.

Real-world example: user roles with sealed classes

Suppose your application has different user roles.

sealed class UserRole {
    object Admin : UserRole()
    object Editor : UserRole()
    object Viewer : UserRole()
}

You can describe permissions like this:

fun canEdit(role: UserRole): Boolean {
    return when (role) {
        UserRole.Admin -> true
        UserRole.Editor -> true
        UserRole.Viewer -> false
    }
}

Usage:

fun main() {
    println(canEdit(UserRole.Admin))
    println(canEdit(UserRole.Viewer))
}

This is a clean way to represent a closed set of possibilities.

Real-world example: shape hierarchy

Here is a more classical OOP example.

abstract class Shape {
    abstract fun area(): Double
    open fun describe() {
        println("I am a shape")
    }
}

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

    override fun describe() {
        println("I am a circle")
    }
}

class Rectangle(private val width: Double, private val height: Double) : Shape() {
    override fun area(): Double = width * height
}

Usage:

fun main() {
    val shapes: List<Shape> = listOf(
        Circle(3.0),
        Rectangle(4.0, 5.0)
    )

    for (shape in shapes) {
        shape.describe()
        println("Area = ${shape.area()}")
    }
}

This demonstrates abstraction, inheritance, and polymorphism all working together.

Equality in Kotlin OOP

Equality matters a lot when you work with objects.

Referential equality

Checks whether two references point to the same object.

val a = Any()
val b = Any()
println(a === b)

Structural equality

Checks whether two objects are equal in content.

data class User(val name: String)

fun main() {
    val u1 = User("Sara")
    val u2 = User("Sara")

    println(u1 == u2)
}

In Kotlin, == means structural equality and === means referential equality.

That distinction is important, especially in data models and collections.

Null safety and OOP

Null-related bugs are common in object-oriented code. Kotlin reduces those bugs through null safety.

class Profile(val nickname: String?)

You can safely handle it with the safe call operator:

fun main() {
    val profile = Profile(null)
    println(profile.nickname?.length)
}

Or provide a fallback:

val length = profile.nickname?.length ?: 0

Kotlin’s null safety is not just a language feature. It changes how you design classes and APIs.

Common OOP mistakes in Kotlin

Even with a great language, it is easy to make poor design choices. Some common mistakes include:

Making everything mutable

Too many var properties can make your objects unpredictable.

Overusing inheritance

Inheritance can become messy when class hierarchies get deep. Composition is often a better choice.

Putting too much logic in one class

A “god class” does everything and becomes hard to maintain.

Exposing internal state directly

If every property is public and writable, your object loses control over itself.

Using abstract classes when an interface would do

Sometimes you do not need shared state. An interface is simpler.

These are design problems, not syntax problems. But they matter a lot in real projects.

OOP best practices in Kotlin

A good Kotlin OOP design often follows these ideas:

  • Prefer val over var

  • Keep classes small and focused

  • Use composition when possible

  • Use interfaces for contracts

  • Use sealed classes for closed sets of states

  • Use data classes for pure data

  • Hide internal state with encapsulation

  • Keep constructors simple

  • Validate input early

  • Make illegal states impossible when you can

These habits make your code easier to read, test, and extend.

How OOP fits into modern Kotlin development

Kotlin is not a “pure OOP” language in the old-school sense. It blends object-oriented and functional ideas. That is part of its strength.

In real Kotlin projects, you will often see a mix of styles:

  • data classes for state

  • functions for transformations

  • classes for behavior

  • sealed classes for state machines

  • interfaces for contracts

This combination gives you the best of both worlds. You are not forced into one rigid style. You can choose the right tool for the job.

For example, a ViewModel in Android may expose sealed UI states, use data classes for state, and rely on injected services for behavior. A backend service may use interfaces for repositories and classes for business logic. A small utility module may lean more functional. Kotlin supports all of that well.

A mental model for designing Kotlin classes

When you create a class in Kotlin, ask yourself a few questions:

What is this object responsible for?

What data should it own?

What behavior belongs to it?

What should stay private?

Should this be a class, an interface, a data class, or a sealed class?

Should I use inheritance, or would composition be simpler?

These questions help you move from “writing code” to “designing software.”

That shift matters. Good OOP is not about adding classes everywhere. It is about using classes meaningfully.

A small example of thoughtful design

Let us design an Order class.

data class OrderItem(val name: String, val price: Double, val quantity: Int)

class Order(private val items: List<OrderItem>) {
    fun subtotal(): Double {
        return items.sumOf { it.price * it.quantity }
    }

    fun itemCount(): Int {
        return items.sumOf { it.quantity }
    }
}

Usage:

fun main() {
    val order = Order(
        listOf(
            OrderItem("Book", 15.0, 2),
            OrderItem("Pen", 2.5, 4)
        )
    )

    println("Items: ${order.itemCount()}")
    println("Subtotal: ${order.subtotal()}")
}

This is simple, readable, and focused. The class is responsible for order calculations, not everything in the world.

OOP and testing

Well-designed object-oriented code is easier to test.

For example, if your class depends on an interface, you can replace that dependency in tests.

interface PaymentProcessor {
    fun pay(amount: Double): Boolean
}

class CheckoutService(private val processor: PaymentProcessor) {
    fun checkout(total: Double): Boolean {
        return processor.pay(total)
    }
}

In tests, you can provide a fake processor.

class FakePaymentProcessor : PaymentProcessor {
    override fun pay(amount: Double): Boolean = true
}

This kind of design makes your code more maintainable and less fragile.

OOP in Android, backend, and desktop apps

Kotlin is used in many places, and OOP shows up everywhere.

In Android, classes often represent screens, repositories, models, and managers.

In backend applications, you may see services, controllers, entities, and use case classes.

In desktop apps, OOP helps you organize UI components, data models, event handlers, and application state.

No matter the platform, the same principles apply:
give each class a clear job, keep responsibilities small, and protect internal state.

Final thoughts

Object-oriented programming in Kotlin is powerful because it is practical. It helps you organize code in a way that feels natural and maintainable, while Kotlin itself keeps the syntax light and expressive.

You do not need to turn every problem into a complicated class hierarchy. In fact, the best Kotlin code often looks calm and intentional. Small data classes. Focused behavior classes. Interfaces where flexibility matters. Sealed classes where the possible states are known. Composition where reuse makes more sense than inheritance.

That is the real beauty of Kotlin OOP. It does not force you into old habits. It gives you the tools to design better software with less friction.

If you are learning Kotlin, take time to practice these concepts one by one. Start with classes and objects. Then move to constructors, encapsulation, inheritance, polymorphism, interfaces, and sealed classes. After that, try building a small project like a library system, a cart, a student manager, or a task tracker. The ideas will become much clearer once you use them in something real.

Kotlin makes object-oriented programming feel less like a rulebook and more like a craft. And once that clicks, building software starts to feel a lot more satisfying.