Skip to content

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

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 val unless you have a specific reason to use var
  • 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 initialise with a single expression (often a when):
      • e.g. val x = when (y) { … }
      • Do not add an extra outer { … } around the whole right-hand side: val x = { when (y) { … } } is a zero-argument lambda (function type), not an assignment of the when expression’s value
  • 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.

Don't model missing data with placeholder values

Empty strings, 0, and -1 are not "missing data" — they are real, valid values that just happen to look conventional. When a field is genuinely optional, model it as a nullable type (String?, Int?) or as an explicit enum state (UNKNOWN, NOT_PROVIDED). The compiler can then enforce that callers handle absence; a sentinel value can't.

Prefer T? over java.util.Optional<T>

Optional<T> exists in Java to compensate for the absence of nullable types in the type system. Kotlin doesn't need it — T? already expresses "may be absent" and lets the compiler enforce safe handling at every call site. When a Java API hands back an Optional<T>, convert at the boundary with optional.getOrNull().

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 a when
  • If you have an if structure with only boolean checks, sometimes it can be more readable to keep the if structures — 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 init block
  • 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 @JvmOverloads annotation
  • 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 require block
  • require works similarly to an if statement that throws an IllegalArgumentException if the result is false
    • kotlin.require
    • Can be used in the constructor, init blocks, or any function where you need to check parameter values
  • Correspondingly, state validation should use a similar check block

Properties and data classes

Properties

  • Declare class variables as public and use property access instead of setters and getters
  • Variables declared as var can get both get and set externally; val is 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

  • 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() (and componentN() 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 copy function 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):

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.

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 throws InstantiationException at startup.
  • kotlin-allopen (all-open plugin) — removes the final constraint from JPA-annotated classes and their properties, enabling Hibernate's proxy-based lazy loading. Spring Boot's kotlin-spring plugin 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:

  • Payment itself does not need to be open — only entities that are the target of a lazy association need to be open (here, Customer).
  • BaseClass provides id-based equals()/hashCode() using Hibernate.getClass() to correctly handle proxied instances.
  • BaseClass can 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.

Spring Data JPA repositories

For our services we extend CrudRepository<T, ID>. We deliberately don't extend JpaRepository: the latter pulls in pagination, sorting, and JPA-specific flush/batch helpers that we usually don't need, and keeping every repository on the same base interface has been more useful for cross-service consistency than the extra surface area would be.

Return T?, not Optional<T>

Spring Data's Java APIs commonly return Optional<T> for "find one" queries because Java has no concept of nullable types. Kotlin does, so a repository in a Kotlin codebase should return T? directly:

// ✅ Idiomatic Kotlin
fun findByExternalPaymentId(externalPaymentId: String): Payment?

// ❌ Boilerplate at every call site
fun findByExternalPaymentId(externalPaymentId: String): Optional<Payment>

Returning T? collapses the call-site pattern from repo.findBy(...).orElseThrow { … } (or worse, .get()) to a single elvis:

val payment = paymentRepository.findByExternalPaymentId(id)
    ?: throw ResponseStatusException(NOT_FOUND, "payment not found")

For "find by id" specifically, Spring Data ships a Kotlin extension that already returns the nullable shape:

import org.springframework.data.repository.findByIdOrNull

val payment = paymentRepository.findByIdOrNull(paymentId)
    ?: throw ResponseStatusException(NOT_FOUND, "payment not found")

Non-nullable return types throw at runtime

A repository method whose return type is non-nullable — fun getByName(name: String): Company — does not mean "this row always exists". When no row matches, Spring Data throws EmptyResultDataAccessException at runtime, and the compiler can't warn you. Default to T? and let the caller decide what to do on absence.

Bulk JPQL update and delete

For @Modifying queries, annotate the method with @Transactional and set both clearAutomatically/flushAutomatically flags so the persistence context isn't left holding stale managed entities:

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update Payment p set p.status = :status where p.id = :id")
fun updateStatus(@Param("id") id: UUID, @Param("status") status: PaymentStatus): Int

flushAutomatically = true writes pending changes from the same transaction to the database before the bulk SQL runs, so the update sees current data. clearAutomatically = true evicts now-stale managed entities afterwards, so subsequent reads in the same transaction don't return cached rows that the bulk query just changed.

@Transactional on a repository method is fine

Service code in our codebase prefers TransactionHandler.runInTransaction { … } over @Transactional because of Spring's self-call AOP-proxy pitfall: a method annotated @Transactional doesn't start a transaction when called from another method on the same bean. Spring Data repositories don't have that problem — every call already crosses a proxy boundary, so @Transactional works as advertised. It also lets the method be called safely from non-transactional contexts (tests, scheduled jobs) without a TransactionRequiredException.

@Enumerated(EnumType.STRING), never ORDINAL
@Enumerated(EnumType.STRING)
val status: PaymentStatus

EnumType.STRING stores the enum constant name in the database. EnumType.ORDINAL stores its zero-based index — and reordering or inserting an enum constant silently changes the meaning of every existing row. The convenience is never worth the data-corruption risk.

If you inherit a schema that already stores numeric codes, use a custom AttributeConverter so the mapping is explicit. Two rules for safe converters:

  • The Kotlin parameter on convertToDatabaseColumn should match the entity field's nullability.
  • convertToEntityAttribute should throw on unknown values, not silently return null. A corrupt database value is a programming error, not a recoverable state.
@Column(nullable = false) mirrors the migration

@Column(nullable = false) only signals nullability to Hibernate. The actual NOT NULL constraint in the database is defined by the Flyway migration script under src/main/resources/db/migration/. Three things must agree:

Layer Where it lives What it expresses
Kotlin type val name: String vs val description: String? Compile-time null safety
@Column(nullable = ...) The entity field Hibernate's view of the schema
NOT NULL in migration db/migration/V*__*.sql The actual database constraint

Mismatches between these layers are silent: an INSERT from outside the application can still insert NULL if the migration didn't add the constraint, and Hibernate may then return that NULL through a non-nullable Kotlin property — surfacing as a Kotlin null-check failure deep in service code.

Pagination — when you need it

We don't use Spring Data pagination today, but if you reach for it:

  • Choose your return type based on what the caller actually needs:
    • Page<T> fires a SELECT count(*) on top of the data query — use it when the UI shows "page 3 of 47" or the API exposes totalElements.
    • Slice<T> fetches pageSize + 1 rows and reports hasNext() instead — use it for infinite scroll or "load more" UIs where the total is irrelevant.
    • List<T> with Pageable returns just the content with no metadata — use it when you only need a window.
  • For queries with JOIN, DISTINCT, or GROUP BY, supply an explicit countQuery. Auto-derived count queries get these wrong.
  • Native SQL queries always require an explicit countQuery.

JOIN FETCH on a collection + Page<T> = OOM

Hibernate cannot apply LIMIT/OFFSET at the SQL level when a collection association is eagerly fetched in the same query. It loads every matching row into memory and paginates in Java. On large datasets this exhausts heap. Hibernate logs HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. If you hit this, fetch ids in the paged query first, then load the associations in a second query.

Sort.by(\"someField\") is a string — typos throw at runtime

Sort.by("computedField") compiles even when no such property exists on the entity, then throws PropertyReferenceException the first time the query runs. If you reach for sorting beyond a one-off, prefer JpaSort.of(Direction.ASC, Entity_.name) against the Hibernate-generated static metamodel — the compiler catches a renamed or removed field. Sort.sort(Entity::class.java).by(Entity::name) (Sort.TypedSort) is an alternative that uses property references; it relies on the entity class being open, which our all-open plugin already arranges.

PageImpl JSON shape isn't a stable API contract

The default JSON shape of PageImpl includes Spring Data internals and has changed between versions. Opt into the documented stable shape:

On Spring Boot 3.3+/4.x, set the property:

spring:
  data:
    web:
      pageable:
        serialization-mode: via-dto

On Spring Boot 3.2, the property doesn't exist yet — use the annotation on your @Configuration class instead:

@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)

Or map the page to your own explicit DTO before returning it from the controller (works on any version).

Type-safe dynamic queries — when you need them

We don't use dynamic filtering today either. If a future endpoint needs filters that vary at runtime, the standard JPA option is JpaSpecificationExecutor<T> driven by the JPA static metamodel (Company_-style classes generated by Hibernate's annotation processor). String-based field references (root.get<String>("status")) compile but break at runtime on rename — the metamodel makes them compile-time errors. QueryDSL is an alternative; it solves the same problem with a different API and adds a build dependency. Pick one approach per service.

Projections — when entities get heavy

Our default for shaping responses is to fetch the entity and map it with an extension function (Payment.toSummaryView()). That works well for the cases we have today, but it has a real cost when the entity is large and the response only needs a few fields: the SQL still selects every column.

If profiling ever shows that a hot read path is bottlenecked on column count or row size, Spring Data JPA offers three projection styles. We don't use any of them today, so this section is forward-looking; the trade-offs are worth knowing before reaching for them.

Interface-based projection

A Kotlin interface whose getters match entity field names. Spring Data generates a proxy at runtime and maps results onto it. When all getters map directly to entity fields, this is a closed projection and Spring Data can reduce the SQL to only the needed columns:

interface PaymentSummaryView {
    val id: UUID
    val status: PaymentStatus
    val amount: Microdollars
}

// Derived query — Spring Data emits SELECT id, status, amount only.
fun findByOrganizationGenesisId(orgId: GenesisId): List<PaymentSummaryView>

Column reduction depends on how the query is written:

  • Derived query methods — Spring Data fetches only the projected columns.
  • @Query("select p from Payment p where …") — fetches all columns and wraps the entity. No column reduction.
  • @Query with explicit field list and aliases (select p.id as id, p.status as status …) — fetches only the listed fields.
Class/DTO projection

A data class constructed directly from JPQL via select new …:

data class PaymentSummaryDto(val id: UUID, val status: PaymentStatus, val amount: Microdollars)

@Query("""
    select new com.unity.example.PaymentSummaryDto(p.id, p.status, p.amount)
    from Payment p where p.organizationGenesisId = :orgId
""")
fun findSummariesForOrg(@Param("orgId") orgId: GenesisId): List<PaymentSummaryDto>

Trade-offs:

  • The fully-qualified class name in the JPQL string is not compile-checked. With the default eager bootstrap mode, an invalid class name or constructor signature fails at application startup.
  • Constructor arguments are positional. A same-type swap (e.g. two String fields in the wrong order) passes every validation and silently misassigns. Cover the mapping with a test that asserts each field.
Dynamic projection

One repository method, shape chosen per call by passing a Class<T>:

interface PaymentRepository : CrudRepository<Payment, UUID> {
    fun <T> findByOrganizationGenesisId(orgId: GenesisId, type: Class<T>): List<T>
}

Useful when several callers need different shapes from the same filter. Hide the Java idiom behind a reified extension if the call sites are noisy:

inline fun <reified T> PaymentRepository.findByOrg(orgId: GenesisId): List<T> =
    findByOrganizationGenesisId(orgId, T::class.java)
When projections vs. extension-function mappers

Reach for a projection when the SQL itself is the cost — heavy entity, hot read path, only a few fields needed in the response. Stay with extension-function mappers when the cost is mapper boilerplate, not the query: handwritten extensions stay debuggable and keep the response model decoupled from the entity.

DTO mapping

We map between entities and DTOs with handwritten Kotlin extension functions, co-located with the DTO. This is the de-facto pattern across our services (Payment.toSummaryView(), RelatedPurchaseOrderLine.toDto(), etc.):

fun Payment.toSummaryView() = PaymentSummaryView(
    id = requireNotNull(id),
    status = status,
    amount = amount,
)

fun CreatePaymentRequest.toEntity() = Payment(
    organizationGenesisId = organizationGenesisId,
    amount = amount,
)

When a mapper depends on Spring-injected collaborators, group the mappings inside a @Component class instead of as top-level functions.

We deliberately don't use code-generation mapping libraries:

  • MapStruct is the dominant Java option, but it generates Java sources via kapt (which is in maintenance mode). Generated output has historically had weaker null-safety on Kotlin data classes than handwritten code, and IDE support for Kotlin sources is patchier than for Java.
  • Konvert is KSP-based and generates idiomatic Kotlin extension functions, but it adds a build dependency and another concept for new contributors. If a service ever hits real boilerplate scale, raise it in review — it's a reasonable next step, not the default.

Scope functions

  • Kotlin provides five scope functions — let, run, with, apply, and also — 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
  • 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
    • Similarly you should attempt to protect your other class variables by only returning immutable ones or copies of the data instead of the original
  • Use Pair and Triple as return values sparingly
    • Sometimes you will find yourself wanting to return a few values and Pair/Triple are 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 Pair and Triple since 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 & Triple as 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
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.

Nested lambdas

  • When nesting lambdas take particular care to retain readability and avoid using the it default parameter name, as it can become hard to see which lambda level it refers 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.kt file 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 Closeable interface and after using them you need to explicitly call close() so the JVM can garbage-collect them
  • Kotlin provides a use block 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 at oshai)
  • 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 ===