Kotlin best practises¶
Foreword¶
This guide was created for internal development teams at Unity who build backend services in Kotlin. Its purpose is twofold: to onboard developers who are new to Kotlin by covering the most important language-specific practices and tools, and to serve as a living record of the architecture and coding style decisions made across our services.
Each service maintains its own architecture decision log, but this document captures the non-service-specific decisions, style guidelines, and general advice that anyone working on our Kotlin codebases should be aware of.
Although written primarily from the perspective of teams working in the finance domain, the practices here are broadly applicable to any backend Kotlin development. We have made it publicly available in the hope that other teams — inside and outside Unity — may find it useful.
What is Kotlin and why we use it¶
Wikipedia — Kotlin · kotlinlang.org
In short Kotlin is "yet another functional JVM language". The main benefits compared to developing with Java are similar to other functional languages.
- Less boilerplate code
- More concise syntax
- Removing(/hiding) internal inconsistencies that Java has accumulated
- Maintain Java interoperability
There are however some things Kotlin brings to the table that are not shared by all similar languages.
- Strict typing + type inference
- Kotlin is a strictly typed language
- Type inference greatly reduces the boilerplate normally associated with that
- Null safety
- Variables have to be explicitly declared as nullable
- Safe-call handling for nullable values enforced by IDE
Learning Kotlin¶
This document doesn't go through all the basics. kotlinlang.org/docs has all the up to date resources to get you started. The team library also includes Kotlin in Action which is a slightly heavier read but the first few chapters do a really good job of introducing the major differences between Kotlin and Java.
Best practises¶
Principles¶
Keep it Readable¶
"Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. …[Therefore,] making it easy to read makes it easier to write."
— Martin C. Fowler, Clean Code
- The easier the code is to understand the easier it is to see the actual logic and if necessary fix it
- Use your judgement
- Readability for a particular task is generally more valuable than following convention, but convention makes things more readable. Try to maintain a balance of only breaking convention when there is a clear readability improvement
- Concise does not always equate readable
- Your function chain with multiple elvis operations that can be extended by passing it a lambda might be really cool and clever, but would it be easier to understand if you just wrote it out in plain old Java style?
- New developers will most likely know Java better than Kotlin, so consider this when writing code
- If you're doing something Kotlin specific that isn't covered here, consider how easy it is to understand and if necessary add a comment into your code or a section to this document
- Use your judgement
Keep it Safe¶
- Take full advantage of the added safety features: immutability and null safety
Coding conventions¶
- We follow the Kotlin official conventions unless specifically overruled by this document
- Several points from the above reference are also listed in this document to highlight them
- It is important to follow the general coding conventions for most things as this makes it easier for new developers to understand our code, but there are times when your particular problem can be solved in a non-standard way that is actually more readable in that particular case
Safe variable declarations¶
- Always use a non-nullable type whenever possible
- Always declare variables as
valunless you have a specific reason to usevar - Also use immutable collections instead of mutable ones
- If you need to declare a variable before assigning it a value, instead of making it nullable declare it as
lateinit- If you find yourself doing this, check if you should instead refactor to a lambda initialiser:
- e.g.
val x = { when (y) { … } }
- e.g.
- If you find yourself doing this, check if you should instead refactor to a lambda initialiser:
- Use safe calls and the elvis operator with nullable variables
Do not use !!
Never use !! to force non-null. It converts a null-safety compile-time guarantee into a runtime NullPointerException and defeats the purpose of Kotlin's type system. Use safe calls, the elvis operator, or requireNotNull instead.
Use when¶
Control flow — when expression
In Java you mainly used the switch statement together with enums. In Kotlin switch has been replaced by when, which can and should be used for a far wider array of flow management.
- If you have a code block that has more than one
if-statement you should almost always refactor it into awhen - If you have an
ifstructure with only boolean checks, sometimes it can be more readable to keep theifstructures — use common sense
Classes and functions¶
Declaring multiple classes in one file¶
- Consider defining strongly linked classes in the same file
- Data classes that are only used as return values and the class using them
- Collections of data classes and enums used throughout a package
- Such a collection file should be named so that it is easily recognisable and linkable to the package
- e.g.
PackageNameDomainObjects.kt
- Take the file size into account — even though some classes have very strong interconnections you might still want to declare them in their own files to avoid a massive monolith file
Constructors¶
- Try to only define a primary constructor and, if necessary, an
initblock - Instead of overloading with secondary constructors, provide default arguments
- Functions — default arguments
- Defining builders is supported but not recommended — you should be able to achieve the same effect with default arguments and it is best to stick to one way of doing things
- If you need to create your Kotlin object with default arguments from a Java class you will likely need to use the
@JvmOverloadsannotation
- On the calling side you should use named arguments whenever you are not passing all the possible arguments
Parameter and state validation¶
- If you need to validate parameter values you should do so explicitly using a
requireblock requireworks similarly to anifstatement that throws anIllegalArgumentExceptionif the result is false- kotlin.require
- Can be used in the constructor,
initblocks, or any function where you need to check parameter values
- Correspondingly, state validation should use a similar
checkblock- kotlin.check
- Throws
IllegalStateException
Properties and data classes¶
- Declare class variables as public and use property access instead of setters and getters
- Variables declared as
varcan get both get and set externally;valis read-only - You can still define an explicit setter or getter for a variable if you need to, for example, do some computation on the value and still use the property access syntax on the calling side
- Data classes should be used extensively and declared with the keyword
data(but see the JPA entities section for an important exception) - Declaring a class as a data class reduces a significant amount of boilerplate
- All parameters from the primary constructor will be declared as properties
equals(),hashCode(),copy(),toString()(andcomponentN()for destructuring) functions will be generated- e.g.
data class User(val name: String, var age: Int)is a complete data class implementation
- Data classes come with a built-in
copyfunction that can be used to create a copy with changed values
Since many data classes are only ever created by one class in the project it in those cases makes sense to declare data classes in the same file as the creating class.
JPA entities — data classes vs. regular classes¶
Use regular classes, not data classes, for JPA entities
When working with JPA (Spring Boot's default ORM), data classes interact poorly with Hibernate in ways that can cause silent performance problems and correctness bugs. The guidance below explains why and what to do instead.
This decision is based on the following findings (originally documented 2023-10-11; versions and behaviour may evolve):
- Stack Overflow: Should I use Kotlin data class as JPA entity?
- Spring guide for Kotlin + Spring Boot — persistence with JPA
- Kotlin issue tracker: KT-28525
Why data classes are problematic with JPA¶
Lazy loading does not work with data classes by default. Data classes are final by design, which prevents Hibernate from creating proxy subclasses for lazy loading. Every foreign-key relationship is therefore eagerly fetched, even if annotated with FetchType.LAZY. The all-open compiler plugin can remove the final constraint, but this does not resolve the equals()/hashCode()/toString() problems described below — so data classes remain unsuitable for JPA entities in either case. In a larger data model where entities reference each other (Payment → Customer → Address → …), unchecked eager loading can cascade into fetching large portions of the database.
toString() prints the entire object graph. The generated toString() on a data class includes every constructor property. Since associated entities are always eagerly fetched (see above), any log statement that includes the entity will print the full graph of loaded objects — which can be unexpectedly large and noisy. With regular classes, the default toString() returns a memory reference, so you have to explicitly decide what to expose, which is a safer default.
equals() and hashCode() still need to be overridden. This applies to both regular and data classes. The default data class implementations use all constructor properties, which is incorrect for JPA entities — an entity's identity should be based on its database id, not on all its fields. Using the generated implementations leads to broken behaviour with HashSet and detached/re-attached entities.
Recommended pattern for JPA entities¶
Use regular classes with a shared BaseClass that provides id-based equals() and hashCode(). Two compiler plugins should be enabled for any Kotlin + JPA project:
kotlin-jpa(no-arg plugin) — generates a no-arg constructor (required by the JPA spec) for all classes annotated with@Entity,@MappedSuperclass, and@Embeddable. Without this, Hibernate throwsInstantiationExceptionat startup.kotlin-allopen(all-open plugin) — removes thefinalconstraint from JPA-annotated classes and their properties, enabling Hibernate's proxy-based lazy loading. Spring Boot'skotlin-springplugin does the same for Spring-managed beans but does not cover JPA annotations, so both are needed.
Foreign-key targets must also be declared as open classes with open properties (or rely on the all-open plugin) so Hibernate can subclass them for proxying.
@MappedSuperclass
abstract class BaseClass {
@Id
@GeneratedValue
open var id: UUID? = null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false
other as BaseClass
return id != null && id == other.id
}
override fun hashCode(): Int = Hibernate.getClass(this).hashCode()
}
@Entity
class Payment : BaseClass() {
val externalId: String? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false)
val customer: Customer? = null
}
@Entity
open class Customer : BaseClass() {
open var customerName: String? = null
}
Key points in the example above:
Paymentitself does not need to beopen— only entities that are the target of a lazy association need to beopen(here,Customer).BaseClassprovides id-basedequals()/hashCode()usingHibernate.getClass()to correctly handle proxied instances.BaseClasscan be extended with common audit fields (e.g.createdAt,updatedAt).
Verifying lazy loading in tests¶
To confirm lazy loading is working, assert that a related entity is not initialized immediately after the parent is fetched, and only becomes initialized when accessed inside a transaction:
@Test
fun `lazy loading works - does not load customer`() {
val payment = paymentRepository.findById(examplePaymentId).get()
assertFalse(Hibernate.isInitialized(payment.customer))
assertEquals(payment.externalId, "some_id")
assertFalse(Hibernate.isInitialized(payment.customer))
}
When data classes are still acceptable¶
Data classes remain a good choice for DTOs, value objects, API request/response models, and any class that is not a JPA entity. Even for simple JPA entities with no foreign-key relationships where you don't expect the model to grow, data classes can work — but keep in mind that converting from data classes to regular classes later is a larger refactoring effort than starting with regular classes from the beginning.
Scope functions¶
- Kotlin provides five scope functions —
let,run,with,apply, andalso— that all exist to execute a block of code on an object:
Companion objects¶
Object declarations — companion objects
- Replaces static variables and functions
- The place to define a factory and any serialisation functions related to a data class
- If you need a static helper function that is only ever used when dealing with one particular class, that should also be declared in the companion object
Return values¶
- Avoid returning null (especially on public functions)
- Null should never be used to denote an error state
- Instead throw an exception
- Or consider using a sealed class (see below)
- When successful execution sometimes yields no results:
- If the return object is a collection, simply return an empty collection
- On a case-by-case basis you need to decide when it makes more sense to return a nullable or simply throw an exception or define a sealed class
- If not returning anything is a common occurrence where no action is required, nullable might make more sense
- If returning nothing is exceptionally rare, or you usually need to do some handling when it happens (log, stop execution, trigger a different function), then throwing an exception is probably a better idea
- Null should never be used to denote an error state
- Protect your mutables from other objects making changes to them accidentally
- When returning a mutable collection you should upcast it to a non-mutable version
- e.g.
return mutableList as List
- e.g.
- Similarly you should attempt to protect your other class variables by only returning immutable ones or copies of the data instead of the original
- When returning a mutable collection you should upcast it to a non-mutable version
- Use
PairandTripleas return values sparingly- Sometimes you will find yourself wanting to return a few values and
Pair/Tripleare the simplest solution - However a better, if slightly more verbose, tactic is to declare a return object data class that contains those values — this makes the handling more explicit and easier to read
- As a quick rule of thumb: internal functions can use
PairandTriplesince the creation and handling will always be in the same class, but public functions should instead return data classes - You will notice that many Kotlin libraries do in fact use
Pair&Tripleas public return values, but you will also notice that to use them you will pretty much always have to go to the source code to figure out what is actually returned and in what order
- Sometimes you will find yourself wanting to return a few values and
Returning sealed classes¶
Sealed classes · Sealed classes vs. exceptions (blog post)
The above blog post describes a clean way of using sealed classes to return success & error states. Consider using it over simply throwing an exception.
Chaining collection functions¶
- Unlike Java's Stream API you do not have to explicitly declare that you want to process a collection as a stream and collect the result afterwards — this means less boilerplate for simple operations like mapping and filtering
- Kotlin's eager collection functions process the entire collection at each step, which can be slower than Java streams for chained operations
- For this reason Kotlin introduces sequences, which work exactly like Java streams (lazy, item-by-item)
Use sequences for chained collection operations
Whenever you are chaining multiple functions on a collection with more than a trivial number of items, declare it as a sequence first.
- Effective Kotlin: use sequence for bigger collections with more than one processing step
- Kotlin sequences are not parallel — for massive data sets you can be better off using Java parallel streams
Nested lambdas¶
- When nesting lambdas take particular care to retain readability and avoid using the
itdefault parameter name, as it can become hard to see which lambda levelitrefers to
Top-level functions¶
- Top-level functions replace utility classes
- They are not declared inside a class; instead they can theoretically be declared in any file inside a package
- Best practise is to have a
[Package/Domain/Etc]Utils.ktfile in the package and declare all top-level functions in there - If your utility function is only used by one class, or only needed when dealing with a particular class, it should probably not be a top-level function — instead it should be declared inside that class (see also Companion objects above)
Closeables¶
- Some resources (streams, clients etc.) implement the
Closeableinterface and after using them you need to explicitly callclose()so the JVM can garbage-collect them - Kotlin provides a
useblock that ensures the resource is properly closed afterwards regardless of error conditions
Java libraries¶
- Extensions can be used to modify external library behaviour either to provide a more Kotlin-like experience or to add a layer of domain-specific logic
- Keep in mind that Kotlin cannot tell whether a Java method can return null (unless it has been correctly annotated) — instead it returns a Platform Type
Treat Java return values as nullable
Best practice is to assume that any return value from Java code is nullable, so that the IDE forces you to use safe calls:
// Explicitly declare as nullable even if the Java method looks non-null
val fromJava: String? = JavaObject.method()
Libraries may change — even if you know a Java library can never return null today, if it isn't annotated you cannot trust that it won't change without you noticing.
Style¶
Tools and libraries¶
Logging¶
- We use a lazy logging library called kotlin-logging (previously hosted under
MicroUtils, now maintained atoshai) - You can create the logger variable outside of your actual class as a top-level declaration
- The usage guide suggests setting up an IntelliJ live template to speed up logger variable creation
- You can also have your class extend
KLogging, but that leads to having multiple ways of declaring the logger depending on what your companion object looks like — so while cleaner looking it is not recommended
Tips¶
Strings¶
- Familiarise yourself with Kotlin String templates
- Triple-quoted strings, i.e.
"""some string""", are treated as literals and retain all formatting characters without requiring escape characters
Operator overloading¶
- In Kotlin, operators such as
+,-, and*are linked to corresponding functions — by providing those functions in your classes you can create powerfully concise handling syntax in a DSL
Do not abuse operator overloading
+ already means something in regular code — using it for something else is misleading. Only use operator overloading inside your own specific DSL.
Equality¶
- The
==operator in Kotlin is overloaded and calls.equals(). If you need to check for reference equality the operator is===