25 February 2026
When I first moved from Java to Kotlin on an Android project, the thing that hit me hardest wasnât coroutines or extension functions â it was how much less code I needed for the basics. A Java POJO with 5 fields â name, email, age, role, active status â requires a private field declaration, a constructor assignment, a getter, a setter, an equals(), a hashCode(), and a toString(). Thatâs roughly 80 lines of boilerplate for a class that carries five pieces of data. In Kotlin, the same thing is a one-line data class. What took an entire file in Java takes a single statement. Thatâs not a minor convenience â itâs an entire category of bugs (mismatched equals/hashCode, forgotten fields in toString, copy-paste errors in setters) that simply cannot happen.
But concise syntax isnât just about saving keystrokes. Every line of code you write is a line that can contain a bug. When you go from 80 lines to 5, youâre not making the same logic shorter â youâre eliminating the surface area where mistakes live. Iâve lost count of the number of times Iâve seen Java equals() implementations that check four fields when the class has five, or hashCode() methods that drift out of sync with equals() after a refactor adds a new field. Kotlinâs class system was designed to make these errors structurally impossible. The properties, the constructors, the generated methods â they all work together so the compiler handles the ceremony and you focus on the logic that actually matters.
In Kotlin, the constructor isnât a separate block inside the class body â itâs part of the class header itself. You declare the class name and its constructor parameters in one line. But hereâs where it gets interesting: if you add val or var before a constructor parameter, Kotlin promotes it from a plain parameter to a class property. That single keyword does three things simultaneously â it declares the parameter, creates a backing field, and generates the accessor methods. No separate field declaration, no manual assignment in the constructor body.
class User(
val name: String,
val email: String,
var isActive: Boolean = true
)
Thatâs it. Three properties, a constructor, generated equals-compatible accessors, and a default value for isActive. The equivalent Java would be a field block, a constructor with three assignments, three getters, one setter, and probably a builder class if youâre following best practices. The default value on isActive means callers can construct a User("Alice", "alice@dev.com") without specifying the active flag â it defaults to true. In Java, youâd need a second constructor overload for that behavior.
Constructor parameters without val or var are just parameters â they exist during construction and then disappear. Theyâre useful when you need a value for initialization logic but donât want it stored as a property. I use this pattern when a constructor takes raw config data that gets transformed during initialization.
class DatabaseConfig(
host: String,
port: Int,
val connectionString: String = "jdbc:postgresql://$host:$port/app_db"
)
Here host and port are plain parameters used to compute connectionString, which is the only property that survives. The caller provides the pieces, the class assembles them, and only the result is retained. This keeps the class API clean â consumers see connectionString and nothing else.
Primary constructors handle 90% of cases, but sometimes you need multiple construction paths. Secondary constructors use the constructor keyword inside the class body and must delegate to the primary constructor â either directly or through another secondary constructor. This delegation chain guarantees that the primary constructorâs initialization always runs, which prevents the partially-initialized objects that plague Java codebases with multiple independent constructors.
class ApiException(
val code: Int,
override val message: String,
override val cause: Throwable? = null
) : Exception(message, cause) {
constructor(message: String) : this(
code = -1,
message = message
)
constructor(code: Int, message: String) : this(
code = code,
message = message,
cause = null
)
}
Each secondary constructor delegates to the primary with this(...). This creates a funnel â no matter which constructor a caller uses, the object ends up fully initialized through the same path. In Java, itâs common to see constructors that set some fields and forget others, especially when a class has five or six constructors added over time by different developers. Kotlinâs mandatory delegation makes that structurally impossible.
Iâll be honest though â I rarely write secondary constructors in practice. Default parameters on the primary constructor cover most cases. The main scenario where secondary constructors earn their keep is when extending Java classes that have multiple constructors (like Androidâs View class), or when the construction logic genuinely differs between entry points rather than just defaulting some values.
The init block runs immediately after the primary constructor, and you can have multiple of them â they execute in the order they appear in the class body, interleaved with property initializations. This is where you put validation logic, computed setup, or anything that needs to run during construction but doesnât belong in a property initializer.
class DatabaseConnection(val url: String) {
val host: String
val port: Int
val database: String
init {
require(url.startsWith("jdbc:")) {
"Invalid JDBC URL: must start with 'jdbc:'"
}
require(url.contains("://")) {
"Invalid JDBC URL: missing protocol separator"
}
}
init {
val parts = url.substringAfter("://").split(":", "/")
host = parts.getOrElse(0) { throw IllegalArgumentException("Missing host") }
port = parts.getOrElse(1) { "5432" }.toIntOrNull() ?: 5432
database = parts.getOrElse(2) { "default" }
}
}
The first init block validates the URL format using require â which throws IllegalArgumentException with your message if the condition fails. The second init block parses the URL into components. Splitting them into two blocks makes the intent clear: validate first, then parse. You could put everything in one init block, but separating validation from initialization is a pattern Iâve found makes classes easier to debug when construction fails. The require calls act as contract checks â if someone passes garbage to the constructor, they get a clear error message instead of a NullPointerException three method calls deep.
One important detail: init blocks and property initializers form a single initialization sequence. A property declared between two init blocks will be initialized between them. The compiler processes them top-to-bottom, which means the order in your source file actually matters for initialization. This is different from Java, where field initializers and instance initializer blocks have subtly different ordering rules that almost nobody remembers correctly.
Every val in a Kotlin class compiles to a private final backing field plus a public getter method on the JVM. Every var compiles to a private backing field plus a public getter and setter. This is why Java code calling Kotlin classes uses user.getName() and user.setEmail(...) â those methods are generated by the compiler even though you never wrote them. Understanding this mapping is essential for interop, and itâs also why Kotlin properties arenât just âfields with syntactic sugar.â Theyâre accessor-based abstractions that can contain arbitrary logic.
Custom getters let you compute a value on access instead of storing it. Custom setters let you intercept and validate writes. The field identifier inside a custom accessor refers to the actual backing field â itâs a special keyword that only exists inside get/set blocks.
class Temperature(celsius: Double) {
var celsius: Double = celsius
set(value) {
require(value >= -273.15) {
"Temperature below absolute zero: $value°C"
}
field = value
}
val fahrenheit: Double
get() = celsius * 9.0 / 5.0 + 32.0
val kelvin: Double
get() = celsius + 273.15
}
The celsius property has a custom setter that prevents physically impossible values. Note the field = value assignment â if you wrote celsius = value instead, youâd trigger the setter recursively and blow the stack. The field keyword is how Kotlin avoids that trap. The fahrenheit and kelvin properties have custom getters and no backing field at all â theyâre computed on every access from celsius. The compiler is smart enough to skip generating a backing field when a property only has a custom getter with no field reference.
Hereâs where it gets practical for Android. I use computed properties constantly for UI state derivation. A ViewModel might store raw data but expose computed properties for display:
class OrderSummaryViewModel(
val items: List<OrderItem>,
val taxRate: Double = 0.08
) {
val subtotal: Double
get() = items.sumOf { it.price * it.quantity }
val tax: Double
get() = subtotal * taxRate
val total: Double
get() = subtotal + tax
val formattedTotal: String
get() = "$${String.format("%.2f", total)}"
val isEmpty: Boolean
get() = items.isEmpty()
}
None of these computed properties store anything â they derive everything from items and taxRate. This means theyâre always consistent. You never hit a stale-cache bug where subtotal says one thing and total says another because someone forgot to recalculate after modifying the item list. The tradeoff is that complex computations re-execute on every access, so for expensive calculations youâd want to cache with a backing field and invalidate manually. But for simple derivations like these, the consistency guarantee outweighs the negligible performance cost.
Kotlinâs visibility system has four levels: public (default), private, protected, and internal. The most notable difference from Java is the default â Java defaults to package-private, which leaks implementation details to every class in the same package. Kotlin defaults to public, which sounds more permissive but actually encourages a better practice. When everything is public by default, youâre forced to consciously think about what should be restricted. In Java, package-private is a silent middle ground that developers rely on without intention.
The internal modifier is Kotlinâs genuinely unique contribution. It restricts visibility to the current module â meaning the current Gradle module, IntelliJ module, or Maven project. This is exactly what Android developers need for multi-module architectures. You want a class visible to everything in your :feature:auth module but invisible to :feature:profile? Thatâs internal. Javaâs package-private canât do this because packages arenât module-aware â a class in com.app.auth.internal is accessible to any code that declares the same package, even in a different module.
I use private for implementation details that nothing outside the class should touch â backing data structures, helper functions, cached computations. protected is for base class members that subclasses need but external callers shouldnât see. internal is the sweet spot for module-level APIs: repository implementations, use-case classes, mapper functions that the feature module uses internally but shouldnât leak to the app module. And public is for the actual API surface youâre intentionally exposing. The key insight is that each modifier maps to a real architectural boundary, not just an access control mechanism.
Both abstract classes and interfaces define contracts, but they serve different architectural roles. Interfaces can have default method implementations (since Kotlin 1.0, not a late addition like Java 8), but they cannot hold state â no backing fields, no constructors, no initialized properties that store data. Abstract classes can have constructors, hold state, and define both abstract and concrete members. The choice between them comes down to one question: does the contract need shared state?
interface Authenticator {
fun authenticate(credentials: Credentials): AuthResult
fun logout()
// Default implementation â no state needed
fun isTokenValid(token: String): Boolean {
return token.isNotBlank() && token.length > 10
}
}
abstract class BaseRepository(
protected val apiService: ApiService,
protected val database: AppDatabase
) {
protected var lastSyncTimestamp: Long = 0L
abstract suspend fun sync()
protected fun shouldSync(): Boolean {
return System.currentTimeMillis() - lastSyncTimestamp > SYNC_INTERVAL
}
companion object {
const val SYNC_INTERVAL = 15 * 60 * 1000L // 15 minutes
}
}
Authenticator is an interface because it defines behavior without needing shared state. Any class can implement it regardless of its inheritance hierarchy. BaseRepository is abstract because it needs to hold shared state (apiService, database, lastSyncTimestamp) and provide concrete helper methods that depend on that state. Subclasses like UserRepository and OrderRepository inherit the sync-checking logic and the service references without duplicating them.
The practical rule I follow in Android: use interfaces for defining capabilities (what a class can do), use abstract classes for sharing implementation (how related classes work). A ClickHandler should be an interface. A BaseViewModel with shared error handling and loading state should be an abstract class. Kotlinâs single-inheritance constraint means you only get one abstract base class, so save it for genuine âis-aâ relationships where shared state matters.
Kotlin classes are final by default. You cannot extend a class unless itâs explicitly marked open. This follows the advice from Effective Java â âDesign and document for inheritance or else prohibit it.â In practice, this means fragile base class problems are opt-in rather than the default. Every class you make open is a conscious decision that youâre designing it for extension and accepting the maintenance cost that comes with it.
open class NetworkClient(
protected val baseUrl: String,
protected val timeout: Long = 30_000L
) {
open fun buildHeaders(): Map<String, String> {
return mapOf("Content-Type" to "application/json")
}
open fun handleError(code: Int, body: String): Nothing {
throw NetworkException(code, body)
}
fun get(endpoint: String): Response {
val headers = buildHeaders()
return executeRequest("GET", "$baseUrl$endpoint", headers)
}
private fun executeRequest(
method: String,
url: String,
headers: Map<String, String>
): Response {
// actual HTTP execution
return Response(200, "{}")
}
}
class AuthenticatedClient(
baseUrl: String,
private val tokenProvider: () -> String
) : NetworkClient(baseUrl) {
override fun buildHeaders(): Map<String, String> {
return super.buildHeaders() + ("Authorization" to "Bearer ${tokenProvider()}")
}
override fun handleError(code: Int, body: String): Nothing {
if (code == 401) throw SessionExpiredException("Token expired")
super.handleError(code, body)
}
}
Both the class and the individual methods need open. NetworkClient is open to allow subclassing. buildHeaders() and handleError() are open to allow overriding. get() is not open â subclasses use it but canât change its behavior, which is intentional. executeRequest() is private â itâs an implementation detail that subclasses shouldnât touch at all. This granularity gives you precise control over your extension surface. In Java, every non-final method is overridable by default, which means your entire class is an extension point whether you designed it that way or not.
The override keyword isnât optional decoration â the compiler requires it. This protects you from accidental overrides when a base class adds a new method with the same signature as something you already defined. In Java, @Override is an optional annotation that you should use but the compiler wonât enforce. Kotlin makes it a language requirement, which catches a real category of bugs at compile time rather than runtime.
One tradeoff worth mentioning: Kotlinâs final-by-default design can conflict with mocking frameworks in tests. Libraries like Mockito canât create subclass-based mocks of final classes without extra configuration. This is why mockito-inline or the all-open compiler plugin exist. Itâs a real friction point, but Iâd argue the production safety of final-by-default outweighs the test configuration cost. Youâre protecting every consumer of your class in exchange for one line in your build config.
Kotlinâs class system does something rare â it takes the boilerplate that Java developers accepted for two decades and makes most of it disappear without losing any capability. Primary constructors, property declarations, smart defaults, and final by default arenât just syntactic conveniences. Theyâre design decisions that push you toward safer, more intentional code. Every feature Iâve covered here â from init blocks validating invariants to internal visibility enforcing module boundaries â exists because the language designers studied what goes wrong in large codebases and made the safe choice the easy choice.
Thanks for reading!
val property in a Kotlin class compile to on the JVM?Explanation:
valcompiles to aprivate finalfield with a generated getter.varcompiles to aprivatefield with both getter and setter. Java code accesses them through these getter/setter methods.
final by default?open to be extendedExplanation: Kotlin follows Effective Javaâs advice: âDesign and document for inheritance or else prohibit it.â Making classes final by default prevents fragile base class problems and makes the codeâs extension points explicit.
Create a BankAccount class with: a primary constructor taking owner name and initial balance (default 0.0), a custom setter on balance that prevents negative values, computed properties for isOverdrawn and formattedBalance, an init block that validates the owner name, and a companion object factory method createSavings that sets a minimum balance. Include a secondary constructor that takes only owner name.
class BankAccount(
val owner: String,
initialBalance: Double = 0.0
) {
var balance: Double = initialBalance
set(value) {
if (value < 0.0) {
println("Transaction denied: balance cannot go below zero")
return
}
field = value
}
val isOverdrawn: Boolean
get() = balance <= 0.0
val formattedBalance: String
get() = "$${String.format("%.2f", balance)}"
init {
require(owner.isNotBlank()) {
"Account owner name cannot be blank"
}
require(owner.length >= 2) {
"Account owner name must be at least 2 characters"
}
}
constructor(owner: String) : this(owner, 0.0)
fun deposit(amount: Double) {
require(amount > 0) { "Deposit amount must be positive" }
balance += amount
}
fun withdraw(amount: Double): Boolean {
require(amount > 0) { "Withdrawal amount must be positive" }
val newBalance = balance - amount
if (newBalance < 0.0) return false
balance = newBalance
return true
}
companion object {
private const val MIN_SAVINGS_BALANCE = 100.0
fun createSavings(owner: String, initialDeposit: Double): BankAccount {
require(initialDeposit >= MIN_SAVINGS_BALANCE) {
"Savings account requires minimum $$MIN_SAVINGS_BALANCE"
}
return BankAccount(owner, initialDeposit)
}
}
}
fun main() {
val account = BankAccount("Alice", 500.0)
println("${account.owner}: ${account.formattedBalance}") // Alice: $500.00
account.deposit(250.0)
println("After deposit: ${account.formattedBalance}") // $750.00
account.withdraw(800.0) // returns false â would go negative
println("Is overdrawn: ${account.isOverdrawn}") // false
val savings = BankAccount.createSavings("Bob", 200.0)
println("Savings: ${savings.formattedBalance}") // $200.00
}