Question

Builder Pattern in Kotlin: How to Build Objects Idiomatically

kotlindesign-patterns

Question

I am new to Kotlin and exploring how to convert some existing Java libraries to Kotlin.

Those libraries contain many POJOs with getters, setters, and separate Builder classes. I want to understand the best way to implement the Builder design pattern in Kotlin for a simple object with a few parameters.

Here is my current attempt, converted from Java:

class Car private constructor(builder: Car.Builder) {
    var model: String? = null
    var year: Int = 0

    init {
        this.model = builder.model
        this.year = builder.year
    }

    companion object Builder {
        var model: String? = null
            private set

        var year: Int = 0
            private set

        fun model(model: String): Builder {
            this.model = model
            return this
        }

        fun year(year: Int): Builder {
            this.year = year
            return this
        }

        fun build(): Car {
            val car = Car(this)
            return car
        }
    }
}

How should a Builder be written correctly in Kotlin, and is this pattern still the best approach for simple classes?

Short Answer

By the end of this page, you will understand how the Builder pattern works in Kotlin, why a direct Java-style conversion is often not the best choice, and how Kotlin features such as primary constructors, named arguments, default parameter values, and data class often replace builders for simple objects. You will also see how to write a proper builder in Kotlin when you truly need one.

Concept

Kotlin can implement the Builder pattern, but in many cases you do not need it.

In Java, builders are commonly used because constructors can become hard to read when they have many parameters, especially optional ones. A builder solves this by letting you set values step by step before creating the object.

Kotlin already provides language features that solve much of this problem:

  • Primary constructors let you declare properties directly in the class header.
  • Named arguments make object creation easier to read.
  • Default values make optional parameters simple.
  • data class gives you common object features automatically.
  • copy() lets you create modified versions of immutable objects.

For a simple class like Car, Kotlin code is usually much cleaner without a builder.

For example, instead of this Java-style idea:

val car = Car.Builder()
    .model("Civic")
    .year(2024)
    .build()

Kotlin often prefers:

val car = Car(model = "Civic", year = 2024)

That is shorter, clearer, and idiomatic.

However, builders are still useful when:

  • an object has many optional fields
  • object creation happens in stages
  • validation is needed before construction
  • you want a fluent API
  • you are designing a Java-friendly library API

Your original attempt uses a companion object as the builder. That causes a major problem: a companion object is shared across the class, so its state is global, not per builder instance. That means multiple builds could interfere with each other. A proper builder should usually be a separate class with its own instance state.

Mental Model

Think of a builder like a paper order form.

  • The final object is the finished car order.
  • The builder is the form you fill out before submitting.
  • Each field such as model or year is a box on the form.
  • Calling build() is like submitting the form and receiving the final result.

In Kotlin, though, if the order is simple, you often do not need a separate form at all. You can just hand over the details directly:

Car(model = "Civic", year = 2024)

So the key question is:

  • Is the object simple enough to create directly? Use a constructor.
  • Is construction complex, staged, or validated? A builder may help.
Take Quiz

Syntax and Examples

1. The Kotlin way for simple objects

For simple POJOs, prefer a data class with constructor parameters:

data class Car(
    val model: String,
    val year: Int = 0
)

Usage:

val car1 = Car(model = "Civic", year = 2024)
val car2 = Car(model = "Corolla")

Why this is good:

  • model is required
  • year is optional because it has a default value
  • named arguments make calls readable
  • data class automatically gives you toString(), equals(), hashCode(), and copy()

2. A proper Builder in Kotlin

If you really want the Builder pattern, create a separate nested Builder class:

class Car private constructor(
    val model: String?,
    val year: Int
) {
    class Builder {
        private var model: String? = null
        private var year: Int = 0

        fun model(model: String) = apply {
            this.model = model
        }

        fun year(year: Int) = apply {
            this.year = year
        }

        fun build(): Car {
            return Car(model, year)
        }
    }
}

Usage:

val car = Car.Builder()
    .model("Civic")
    .year(2024)
    .build()

Why apply is useful

apply returns the current object after running a block, which makes fluent chaining easy:

fun model(model: String) = apply {
    this.model = model
}

This means the function updates the builder and then returns the same builder instance.

3. A builder with validation

Often the builder exists to enforce rules:

class Car private constructor(
    val model: String,
    val year: Int
) {
    class Builder {
        private var model: String? = null
        private var year: Int = 0

        fun model(model: String) = apply {
            this.model = model
        }

        fun year(year: Int) = apply {
            this.year = year
        }

        fun build(): Car {
            require(!model.isNullOrBlank()) { "Model is required" }
            require(year >= 1886) { "Year must be valid" }
            return Car(model!!, year)
        }
    }
}

This is more realistic because it prevents invalid objects from being created.

Step by Step Execution

Consider this builder:

class Car private constructor(
    val model: String,
    val year: Int
) {
    class Builder {
        private var model: String? = null
        private var year: Int = 0

        fun model(model: String) = apply {
            this.model = model
        }

        fun year(year: Int) = apply {
            this.year = year
        }

        fun build(): Car {
            require(!model.isNullOrBlank()) { "Model is required" }
            return Car(model!!, year)
        }
    }
}

val car = Car.Builder()
    .model("Civic")
    .year(2024)
    .build()

What happens step by step

1. Car.Builder()

A new builder object is created.

Internal state:

  • model = null
  • year = 0

2. .model("Civic")

The model() function runs.

fun model(model: String) = apply {
    this.model = model
}

Inside the builder:

  • this.model becomes "Civic"
  • apply returns the same builder instance

Internal state now:

  • model = "Civic"
  • year = 0

3. .year(2024)

The year() function runs.

  • this.year becomes 2024
  • the same builder is returned

Internal state now:

  • model = "Civic"
  • year = 2024

4. .build()

The build() function checks the data.

require(!model.isNullOrBlank()) { "Model is required" }
return Car(model!!, year)
  • If model is missing, an exception is thrown
  • Otherwise, a new Car object is created

Final object:

Car(model = "Civic", year = 2024)

The builder is just a temporary helper. The real object is created only when build() is called.

Real World Use Cases

Builders are useful in several real programming situations.

Configuration objects

A library may need a lot of optional settings:

val config = HttpClientConfig.Builder()
    .baseUrl("https://api.example.com")
    .timeout(5000)
    .retries(3)
    .build()

Validation before construction

If some fields must be checked together, a builder can collect values and validate them at the end.

Example:

  • username is required
  • password must meet rules
  • age must be positive

Step-by-step object assembly

Some objects are naturally built in phases, such as:

  • SQL queries
  • UI dialogs
  • network requests
  • report generation

Java interoperability

If your Kotlin library will be used from Java, a builder may still be a good API design because Java does not have named arguments.

Immutable objects with many options

Builders are often used when the final object should be immutable, but setting all values in one constructor would be inconvenient.

Take Quiz

Real Codebase Usage

In real Kotlin codebases, developers often avoid builders for simple models and use more idiomatic patterns.

Common Kotlin patterns instead of builders

Primary constructor + default values

data class User(
    val name: String,
    val age: Int = 0,
    val active: Boolean = true
)

Named arguments

val user = User(name = "Ava", active = false)

copy() for modification

val original = User(name = "Ava", age = 25)
val updated = original.copy(active = false)

When builders still appear in real projects

Guard clauses and validation

Builders often centralize validation:

fun build(): User {
    require(name.isNotBlank()) { "Name is required" }
    require(age >= 0) { "Age cannot be negative" }
    return User(name, age)
}

Fluent DSL-like setup

Some APIs use a builder-like style because it reads naturally.

Java-facing libraries

If a Kotlin library is intended for Java users, builders can improve usability.

Large configuration objects

For classes with many optional settings, a builder can be easier to maintain than a constructor with many parameters.

A practical guideline

Use these rules in a real codebase:

  • Simple model: use constructor + defaults
  • Immutable model with minor changes: use copy()
  • Complex staged creation: use builder
  • Java interoperability: consider builder
  • Heavy validation before creation: builder can help

Common Mistakes

1. Using a companion object as the builder

This is the main problem in your attempt.

Broken idea:

companion object Builder {
    var model: String? = null
    var year: Int = 0
}

Why it is wrong:

  • a companion object is shared by the class
  • all callers use the same state
  • this can create bugs when multiple objects are built

Use a separate class Builder instead.

2. Translating Java too literally

Beginners often convert Java code line by line instead of using Kotlin features.

Less idiomatic Kotlin:

class Car private constructor(builder: Builder) {
    var model: String? = null
    var year: Int = 0
}

Better Kotlin for simple cases:

data class Car(val model: String, val year: Int = 0)

3. Making everything mutable unnecessarily

If the final object should not change, use val instead of var.

Prefer:

class Car private constructor(
    val model: String,
    val year: Int
)

4. Forgetting validation in build()

A builder is often useful because it can reject invalid objects.

Broken code:

fun build(): Car {
    return Car(model, year)
}

Safer version:

fun build(): Car {
    require(!model.isNullOrBlank()) { "Model is required" }
    return Car(model!!, year)
}

5. Overusing nullable types

If a field must exist in the final object, do not keep it nullable there unless the domain really allows null.

Prefer:

class Car private constructor(
    val model: String,
    val year: Int
)

instead of:

class Car private constructor(
    val model: String?,
    val year: Int
)

6. Using builders when named arguments are enough

If your class is simple, a builder adds extra code without much value.

val car = Car(model = "Civic", year = 2024)

That is often better than a full builder.

Comparisons

ApproachBest forProsCons
Primary constructorSimple objectsShort, idiomatic, easy to readLess useful for staged creation
Constructor + default valuesObjects with optional fieldsVery concise, no extra builder classCan become long with many parameters
data classImmutable value objectsAuto-generated useful methodsNot always enough for complex creation rules
Builder patternComplex construction or Java-friendly APIsFluent, can validate before creationMore code and more maintenance
copy() on data classUpdating immutable objectsVery readable for small changesOnly works after an object already exists

Builder vs constructor in Kotlin

data class Car(val model: String, val year: Int = 0)

val car = Car(model = "Civic", year = 2024)

vs.

val car = Car.Builder()
    .model("Civic")
    .year(2024)
    .build()

In Kotlin, prefer the constructor when:

  • the class is small
  • there are only a few parameters
  • default values solve optional fields
  • readability is already good with named arguments

Prefer a builder when:

  • construction is complex
  • values need validation together
  • the API should support Java nicely
  • you want a fluent configuration style
Take Quiz

Cheat Sheet

Quick reference

Idiomatic Kotlin for simple objects

data class Car(
    val model: String,
    val year: Int = 0
)

Usage:

val car = Car(model = "Civic", year = 2024)
val car2 = Car(model = "Corolla")

Proper builder in Kotlin

class Car private constructor(
    val model: String,
    val year: Int
) {
    class Builder {
        private var model: String? = null
        private var year: Int = 0

        fun model(model: String) = apply { this.model = model }
        fun year(year: Int) = apply { this.year = year }

        fun build(): Car {
            require(!model.isNullOrBlank()) { "Model is required" }
            return Car(model!!, year)
        }
    }
}

Rules of thumb

  • Prefer constructor + named arguments for simple classes
  • Use default values for optional parameters
  • Use data class for value objects
  • Use a builder for complex or validated construction
  • Do not use a companion object as builder state
  • Prefer val for immutable final objects

Useful Kotlin features that reduce builder usage

  • primary constructor
  • named arguments
  • default parameter values
  • data class
  • copy()
  • apply

Edge case

If your Kotlin code must be called from Java, a builder may still be the most convenient API.

FAQ

Should I use the Builder pattern in Kotlin?

Usually not for simple classes. Kotlin's named arguments and default values often make builders unnecessary.

What is wrong with using a companion object as a builder?

A companion object is shared across all instances of the class, so the builder state becomes global and unsafe.

What should I use instead of a builder for a simple POJO in Kotlin?

Use a primary constructor, often with a data class, named arguments, and default values.

When is a builder still useful in Kotlin?

When construction is complex, requires validation, happens in stages, or when designing APIs for Java callers.

Why is apply commonly used in Kotlin builders?

Because it lets you modify the current builder and return it in one expression, which supports fluent chaining.

Should final properties be var or val?

Use val when the object should be immutable after creation. This is common in Kotlin.

Can a data class replace a builder?

Often yes, especially for simple immutable objects with optional fields and readable named arguments.

Take Quiz

Mini Project

Description

Create a small Kotlin model for building a database connection configuration. This project demonstrates when a builder is useful: you have required values, optional values, and validation before creating the final object.

Goal

Build a DatabaseConfig object using a Kotlin builder with fluent methods and validation in build().

Requirements

  • Create a DatabaseConfig class with host, port, database, and sslEnabled properties.
  • Make host and database required before building.
  • Give port a default value.
  • Use a nested Builder class with fluent setter methods.
  • Validate that port is positive in build().
Take Quiz

Keep learning

Related questions

View more