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:
publicprivateprotectedinternal
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
valovervarKeep 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.