From Java to Kotlin – Part XI: The Builder Pattern vs Type-safe Builders / DSL
Considering a move to Kotlin? Coming from a Java background? In this short series of blog posts, I’ll take a look at familiar, straightforward Java concepts and demonstrate how you can approach them in Kotlin. While many of these points have already been discussed in earlier posts by colleagues, my focus is simple: how you used to do it in Java, and how you do it in Kotlin.
Case 11: You want to construct an object.
With many parameters. Some optional. Readable. Preferably without a constructor that looks like a password.
The problem
You have a class with multiple fields.
Some required. Some optional.
And you want:
-
readability
-
immutability
-
no telescoping constructors
In Java, this usually means one thing.
The Builder pattern.
The Java way
The classic version:
public class User {
private final String name;
private final String email;
private final int age;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.age = builder.age;
}
public static class Builder {
private String name;
private String email;
private int age = -1;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public User build() {
return new User(this);
}
}
}
Usage:
User user = new User.Builder()
.name("John")
.email("john@doe.com")
.age(42)
.build();
It works.
But it’s a lot of code for: “Create an object.”
So we reach for Lombok:
@Builder
public class User {
private String name;
private String email;
private int age;
}
Better.
But still:
User user = User.builder()
.name("John")
.email("john@doe.com")
.age(42)
.build();
We are solving a language limitation with a pattern. And then solving the pattern with an annotation processor.
The Kotlin way
In Kotlin, the primary constructor already gives you most of this:
data class User(
val name: String,
val email: String,
val active: Boolean = true
)
Usage:
val user = User(
name = "John",
email = "john@doe.com"
)
That’s it.
-
Named arguments
-
Default values
-
Immutability
-
No builder required
And it stays readable — even with many parameters.
But what if the object becomes complex?
This is where Kotlin does something Java simply cannot do:
Type-safe builders, often used to create a small DSL.
A quick note: what is a DSL?
DSL stands for Domain-Specific Language.
That sounds heavier than it is.
It simply means: A small, readable language focused on one specific task.
Not a new programming language — just a more expressive way to use Kotlin.
For example:
html {
body {
h1("Hello")
}
}
or:
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib")
}
You are not calling constructors.
You are describing a structure.
That is the key difference.
A builder chains method calls. A DSL models a domain.
A Kotlin type-safe builder
val user = user {
name = "John"
email = "john@doe.com"
age = 42
}
With:
fun user(block: UserBuilder.() -> Unit): User =
UserBuilder().apply(block).build()
This gives you:
-
a scoped configuration block
-
compile-time safety
-
no partially built objects leaking out
And the call site reads like configuration instead of construction.
Why this matters
The Java builder pattern is:
-
ceremony for optional parameters
-
ceremony for readability
-
ceremony for immutability
Kotlin removes the need for that ceremony.
And when you do need structure for complex hierarchies, a DSL gives you something the classic builder never could:
Structure in the call site.
Not just chaining.
Takeaway
In Java:
You build because constructors don’t scale.
In Kotlin:
You use the constructor — and only build when you’re actually building something.
A builder constructs objects.
A DSL describes them.
Coming from Lombok?
Your Kotlin replacement is usually:
-
a
data class -
default values
-
named arguments
Not another builder.
And your code gets smaller. And clearer. By default.
When not to use a DSL
A DSL is not a default replacement for a constructor or a data class.
If you only have:
-
a flat object
-
a handful of parameters
-
no nested structure
then a regular constructor with named arguments is clearer and simpler.
val user = User(
name = "John",
email = "john@doe.com"
)
No builder. No DSL. No extra types.
Just the model.
A DSL starts to make sense when:
-
you are building a hierarchy
-
the structure matters
-
the block improves readability
-
you would otherwise end up with deeply nested constructors or long method chains
-
you want easy validation within the builder
Validating while building
A nice bonus of a Kotlin DSL builder is that you can validate your object state before it’s constructed. For example, you can enforce rules in the builder itself:
class UserBuilder {
var name: String = ""
var email: String = ""
var age: Int = -1
var role: String? = null
fun build(): User {
require(name.isNotBlank()) { "Name must not be blank" }
require(email.contains("@")) { "Email must be valid" }
require(age >= 0) { "Age must be non-negative" }
if (role != null) require(age >= 18) { "Users with a role must be at least 18" }
return User(name, email, age)
}
}
Usage:
val user = user {
name = "John"
email = "john@doe.com"
age = 42
role = "admin"
}
This demonstrates how type-safe builders can enforce both simple and dependent validations automatically at build time, keeping your objects consistent without extra boilerplate — something that would be more cumbersome with Java builders.
Note: This can be done in Java as well, it’s an advantage of the builder-pattern versus using the constructor.
Typical good fits are:
-
HTML / UI trees
-
routing definitions
-
test data builders
-
complex configuration objects
Using a DSL for a simple data holder is not expressive.
It’s noise.
And unlike in Java, in Kotlin you don’t need a pattern to compensate for the language.
So don’t.