Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

Growing Kotlin Adoption in Your Company

Guest post by Urs Peter, Senior Software Engineer and JetBrains-certified Kotlin Trainer. For readers who’d like a more structured way to build Kotlin skills, Urs also leads the Kotlin Upskill Program at Xebia Academy.

This is the third post in The Ultimate Guide to Successfully Adopting Kotlin in a Java-Dominated Environment, a series that follows how Kotlin adoption grows among real teams, from a single developer’s curiosity to company-wide transformation.

All parts in the series:

  1. Getting Started With Kotlin for Java Developers
  2. Evaluating Kotlin in Real Projects
  3. Growing Kotlin Adoption in Your Company

Spread the Word: Win the Hearts and Minds of your Fellow Developers

By now, you have a core team convinced of Kotlin’s benefits. Now comes the critical phase: How do you expand adoption? 

Key in this phase is to win the hearts and minds of skeptical Java developers. Hard and soft factors can make the difference here:

Hard factors: the code

  • Let the code speak

Soft factors: support and connect developers

  • Facilitate easy onboarding
  • Offer self-study material
  • Establish an in-house Kotlin community
  • Be patient…

Let the code speak

Double down on the experience you gained by (re-)writing an application in Kotlin and show the benefits in a tangible, accessible way:

  • Show, don’t tell: present the gained conciseness of Kotlin by comparing Java with Kotlin snippets. 
  • Zoom out to focus on the overarching paradigms: Kotlin is not just different from Java: Kotlin is built on the foundation of safety, conciseness for maximal readability and maintainability, functions as a first-class citizen, and extensibility, which together solve fundamental shortcomings of Java, while still being fully interoperable with the Java ecosystem.

Here are some tangible examples:

1. Null-safety: No more billion-dollar mistakes 

Java

//This is why we have the billion-dollar mistake (not only in Java…)
Booking booking = null; //🤨 This is allowed but causes:
booking.destination; //Runtime error 😱

Optional<Booking> booking = null; //Optionals aren’t safe from null either: 🤨
booking.map(Destination::destination); //Runtime error 😱

Java has added some null-safety features over time, like Optional and Annotations (@NotNull etc.), but it has failed to solve this fundamental problem. Additionally, project Valhalla (Null-Restricted and Nullable types) will not introduce null-safety to Java, but rather provide more options to choose from. 

Kotlin

//Important to realize that null is very restricted in Kotlin:
val booking:Booking? = null //…null can only be assigned to Nullable types ✅ 
val booking:Booking = null //null assigned to a Non-nullable types yields a Compilation error 😃 

booking.destination //unsafely accessing a nullable type directly causes a Compilation error 😃
booking?.destination //only safe access is possible  ✅

The great thing about Kotlin’s null-safety is that it is not only safe, but also offers great usability. A classic example of “have your cake and eat it, too”:

Say we have this Domain:

Kotlin

data class Booking(val destination:Destination? = null)
data class Destination(val hotel:Hotel? = null)
data class Hotel(val name:String, val stars:Int? = null)

Java

public record Booking(Optional<Destination> destination) {

   public Booking() { this(Optional.empty()); }

   public Booking(Destination destination) {
       this(Optional.ofNullable(destination));
   }
}

public record Destination(Optional<Hotel> hotel) {

   public Destination() { this(Optional.empty()); }

   public Destination(Hotel hotel) {
       this(Optional.ofNullable(hotel));
   }
}

public record Hotel(String name, Optional<Integer> stars) {

   public Hotel(String name) {

       this(name, Optional.empty());

   }

   public Hotel(String name, Integer stars) {
       this(name, Optional.ofNullable(stars));
   }
}

Constructing objects

Java

//Because Optional is a wrapper, the number of nested objects grows, which doesn’t help readability
final Optional<Booking> booking = Optional.of(new Booking(
      Optional.of(new Destination(Optional.of(
            new Hotel("Sunset Paradise", 5))))));

Kotlin

//Since nullability is part of the type system, no wrapper is needed: The required type or null can be used. 
val booking:Booking? = Booking(Destination(Hotel("Sunset Paradise", 5)))

Traversing nested objects

Java

//traversing a graph of Optionals requires extensive unwrapping
final var stars = "*".repeat(booking
                          .flatMap(Booking::getDestination)
                          .flatMap(Destination::getHotel)
                          .map(Hotel::getStars).orElse(0)); //-> "*****"

Kotlin

//Easily traverse a graph of nullable types with: ‘?’, use  ?: for the ‘else’ case.
val stars = "*".repeat(booking?.destination?.hotel?.stars ?: 0) //-> "*****"

Unwrap nested object

Java

//extensive unwrapping is also needed for printing a leaf 
booking.getDestination()
       .flatMap(Destination::getHotel)
       .map(Hotel::getName)
       .map(String::toUpperCase)
         .ifPresent(System.out::println);

Kotlin

//In Kotlin we have two elegant options:
//1. we can again traverse the graph with ‘?’
booking?.destination?.hotel.?name?.uppercase()?.also(::println)

//2. We can make use of Kotlin’s smart-cast feature
if(booking?.destination?.hotel != null) {
   //The compiler has checked that all the elements in the object graph are not null, so we can access the elements as if they were non-nullable types
   println(booking.destination.hotel.uppercase())
}

The lack of null-safety support in Java is a key pain-point for developers, leading to defensive programming, different nullability constructs (or none at all), including increased verbosity. Moreover, NullPointerExceptions are responsible for roughly one-third of application crashes (The JetBrains Blog). Kotlin’s compile-time null checking prevents these runtime failures entirely. That’s why, still today, Null-safety is the primary driver for migration to Kotlin.

2. Collections are your friend, not your enemy

The creator of Spring, Rod Johnson, stated in a recent interview that it was not Nullable-types that made him try out Kotlin, but the overly complicated Java Streams API: Creator of Spring: No desire to write Java

The following example depicts the various reasons why the Java Streams API is so horribly complicated and how Kotlin solves all of the issues:

Java

public record Product(String name, int... ratings){}
List<Product> products = List.of(
  new Product("gadget", 9, 8, 7), 
  new Product("goody", 10, 9)
);

Map<String, Integer> maxRatingsPerProduct =

   //🤨 1. Stream introduces indirection
   products.stream()
     //🤨 1. Always to and from Stream conversion
      .collect(
        //🤨 2. Lacks extension methods, so wrappers are required
        Collectors.groupingBy(
           Product::name,
            //🤨 2. Again…
           Collectors.mapping( groupedProducts ->
                //🤨 3. (too) low-level types, arrays, and primitives cause extra complexity
                //🤨 4. No API on Array, always wrap in stream 
               Arrays.stream(groupedProducts.ratings())
                   .max()
                      //🤨 5. Extra verbosity due to Optional
                      .orElse(0.0),
               //🤨 6. No named arguments: what does this do?
                Collectors.reducing(0, Integer::max)
              )
            ));

Kotlin

//😃 rich and uniform Collection API - even on Java collections - due to extension methods
val maxRatingsPerProduct = products.
      .groupBy { it.name }
      .mapValues { (_, groupedProducts) -> //😃 destructuring for semantic precision
            //😃 built-in nullability support, and the same API for 
            //arrays like other Collections
            groupedProducts.flatMap { it.ratings }
                  .maxOrNull() ?: 0 
            }
      }

Due to Kotlin’s uniform Collection framework, converting between different collection types is very straightforward:
Java

int[] numbers = {1, 3, 3, 5, 2};

Set<Integer> unique = Arrays.stream(numbers).boxed().collect(Collectors.toSet());

Map<Integer, Boolean> evenOrOdd = unique.stream()
       .collect(Collectors.toMap(
               n -> n,
               n -> n % 2 == 0));

Kotlin

val numbers = arrayOf(1, 3, 3, 5, 2)

val unique: Set<Int> = numbers.toSet() //😃 simply call to<Collection> to do the conversion

val evenOrOdd: Map<Int, Boolean> = unique.associateWith { it % 2 == 0 }

Net effect:

  • Feature-rich, intuitive Collection API: pipelines read left-to-right like English, not inside nested collector calls.
  • Less boilerplate and complexity: no Collectors.groupingBy, no Stream, no Optional, no Arrays.stream.
  • One mental model: whether you start with a List, Set, Array, or primitive array, you reach for the same collection operators.
  • Performance without pain: the compiler inserts boxing/unboxing only where unavoidable; you write normal code.
  • ​​Null-safety integrated: Collection API fully supports nullability with various helpers often suffixed with orNull(...).
  • Seamless Java-interop with idiomatic wrappers: Due to extension methods, you get the “best of both worlds” – feature-rich Collections on top of Java collections.

Put simply, Kotlin lifts the everyday collection tasks – filtering, mapping, grouping etc. – into first-class, composable functions, so you express what you want, not the ceremony of how to get there.

3. No more checked Exceptions, yet safer code in Kotlin

Java is one of the only languages that still supports checked Exceptions. Though initially implemented as a safety feature, they have not lived up to their promise. Their verbosity, pointless catch blocks that do nothing or rethrow as an Exception as a RuntimeException, and their misalignment with lambdas are a few reasons why they get in the way rather than making your code safer.

Kotlin follows the proven paradigm used by almost all other programming languages –C#, Python, Scala, Rust, and Go – of using exceptions only for unrecoverable situations.

The following examples highlight the obstacles that checked Exceptions introduce into your code, without adding any safety:

Java

public String downloadAndGetLargestFile(List<String> urls) {
   List<String> contents = urls.stream().map(urlStr -> {
               Optional<URL> optional;
               try {
                   optional = Optional.of(new URI(urlStr).toURL());
                   //🤨 Within lambdas checked exceptions are not supported and must always be caught...
               } catch (URISyntaxException | MalformedURLException e) {
                   optional = Optional.empty();
               }
               return optional;
           }).filter(Optional::isPresent)       //Quite a mouthful to get rid of the Optional...
           .map(Optional::get)
           .map(url -> {
               try (InputStream is = url.openStream()) {
                   return new String(is.readAllBytes(), StandardCharsets.UTF_8);
               } catch (IOException e) {
                   //🤨… or re-thrown, which is annoying, I don’t really care about IOE
                   throw new IllegalArgumentException(e);
               }
           }).toList();
   //🤨 An empty List results in a NoSuchElementException, so why is it not checked? The chance that the List is empty is as high as the other two cases above...
   return Collections.max(contents);
}

Kotlin

//😃 safe return type
fun downloadAndGetLargestFile(urls: List<String>): String? =
   urls.mapNotNull {    //😃 convenient utility methods to rid of null
       //😃 try catch is possible, yet runCatching is an elegant way to convert an exception to null
       runCatching { URI(it).toURL() }.getOrNull()
   }.maxOfOrNull{  //😃 safe way to retrieve the max value
       it.openStream().use{ it.reader().readText() }  //😃 convenient extension methods to make java.io streams fluent
   }

4. Functions as first-class citizens

Kotlin treats functions as first-class citizens, which may raise questions if you’re coming from Java: What does that mean, and why does it matter?

The key difference lies in Java’s limited approach – its functional capabilities focus mostly on the call-site via lambdas, while the declaration-side remains tied to verbose and less intuitive functional interfaces. In Kotlin, you can define, pass, return, and compose functions without any boilerplate, making functional programming far more expressive and natural.

Java

public void doWithImage(
      URL url, 
      //🤨 Function interfaces introduce an indirection: because we don’t see the signature of a Function we don’t know what a  BiConsumer does, unless we look it up
      BiConsumer<String, BufferedImage> f) throws IOException {
        f.accept(url.getFile(), ImageIO.read(url));
}

//🤨 Same here
public void debug(Supplier<String> f) {
   if(isDebugEnabled()) {
      logger.debug("Debug: " + f.get());
   }
}

//🤨 calling no-argument lambdas is verbose
debug(() -> "expensive concat".repeat(1000));

Kotlin

fun doWithImage(
   url: URL,
  //😃 Kotlin has a syntax for declaring functions: from the signature, we see what goes in and what goes out 
   f:(String, BufferedImage) -> Unit) = 
      f(url.file, ImageIO.read(url))

  //😃 same here: nothing goes in, a String goes out
fun debug(msg: () -> String) {
   if(isDebugEnabled) {
            logger.debug(msg())
   }
}

//😃 convenient syntax to pass a lambda: {<lambda body>}
debug{"expensive concat".repeat(1000)}

Kotlin provides a clear and concise syntax for declaring functions – just from the signature, you can immediately see what goes in and what comes out, without having to navigate to an external functional interface. 

Java “leaks” its functional programming features through a large set of java.util.function.* interfaces with verbose and complex signatures, which often makes functional programming cumbersome to use. Kotlin, by contrast, treats functions as first-class citizens: these interfaces remain hidden from the developer, yet remain fully interoperable with Java’s approach:

As a result, using functions in Kotlin is significantly more straightforward and intuitive, which lowers the threshold considerably for leveraging this powerful programming concept in your own code. 

5. Headache-free concurrency with coroutines

If you need high throughput, parallel processing within a single request, or streaming, the only option you have in Java is a reactive library, like Reactor and RxJava, available in frameworks like Spring WebFlux, Vert.X, Quarkus etc.

The problem with these libraries is that they are notoriously complicated, forcing you into functional programming. As such, they have a steep learning curve, yet it is very easy to make errors that can have dire consequences when the application is under load. That’s most probably why reactive programming never became mainstream. 

Note: VirtualThreads are not a replacement for reactive libraries, even though there is some overlap. VirtualThreads offer non-blocking I/O, but do not provide features such as parallel processing or reactive streams. Structured concurrency and Scoped Values will also enable parallel processing once the major frameworks have support for it. For reactive streams, you will always have to rely on a reactive library.

So let’s assume you are a Java developer using Spring Boot and you want to make a parallel call within a single request. This is what you end up with: 

@PostMapping("/users")
@ResponseBody
@Transactional
public Mono<User> storeUser(@RequestBody User user) {
   Mono<URL> avatarMono = avatarService.randomAvatar();
   Mono<Boolean> validEmailMono = emailService.verifyEmail(user.getEmail());
   //🤨 what does ‘zip’ do?
   return Mono.zip(avatarMono, validEmailMono).flatMap(tuple -> 
       if(!tuple.getT2()) //what is getT2()? It’s the validEmail Boolean…
        //🤨 why can I not just throw an exception?
         Mono.error(new InvalidEmailException("Invalid Email"));
       else personDao.save(UserBuilder.from(user)
                                          .withAvatarUrl(tuple.getT1()));
     );  
  }

Even though, from a runtime perspective, this code performs perfectly well, the accidental complexity introduced is enormous: 

  • The code is dominated by Mono/Flux-es, throughout the whole call chain, which enforces you to wrap all domain objects.
  • There are many complex operators everywhere, like zip, flatMap, etc.
  • You cannot use standard programming constructs like throwing exceptions 
  • The business intent of your code suffers significantly: the code focuses on Monos and flatMap, thereby obscuring what is truly happening from a business perspective.

The good news is that there is a powerful remedy in the form of Kotlin Coroutines. Coroutines can be framed as a reactive implementation on the language level. As such, they combine the best of both worlds: 

  • You write sequential code like you did before.
  • The code is executed asynchronously / in parallel at runtime.

The above Java code converted to coroutines looks as follows:

@GetMapping("/users")
@ResponseBody
@Transactional
suspend fun storeUser(@RequestBody user:User):User = coroutineScope {
   val avatarUrl = async { avatarService.randomAvatar() }
   val validEmail = async { emailService.verifyEmail() }
   if(!validEmail.await()) throw InvalidEmailException("Invalid email")
   personRepo.save(user.copy(avatar = avatarUrl.await()))
}

Kotlin’s suspend keyword enables structured, non-blocking execution in a clear and concise manner. Together with async{} and await(), it facilitates parallel processing without the need for deeply nested callbacks or complex constructs such as Mono or CompletableFuture.

That’s why complexity will decrease, and developer joy and maintainability will increase, with the exact same performance characteristics. 

Note: Not all major Java-based web frameworks support Coroutines as well. Spring does an excellent job, as does Micronaut. Quarkus currently offers limited Coroutine support.

6. But hey, Java is evolving too!

Java keeps moving forward with features like records, pattern matching, and upcoming projects such as Amber, Valhalla, and Loom. This steady evolution strengthens the JVM and benefits the entire ecosystem.

But here’s the catch: most of these “new” Java features are things Kotlin developers have enjoyed for years. Null safety, value classes, top-level functions, default arguments, concise collections, and first-class functions are all baked into Kotlin’s design – delivered in a more unified and developer-friendly way. That’s why Kotlin code often feels cleaner, safer, and far more productive.

And there is more: Kotlin also profits from Java’s innovation. JVM-level advances like Virtual Threads and Loom in general, or Valhalla’s performance boosts apply seamlessly to Kotlin too.

In short: Java evolves, but Kotlin was designed from day one to give developers the modern tools they need – making it a safe, modern, and forward-looking choice for building the future.

7. Kotlin’s evolutionary advantage

Older programming languages inevitably carry legacy baggage compared to modern alternatives. Updating a language while supporting massive existing codebases presents unique challenges for language designers. Kotlin enjoys two crucial advantages:

Standing on the shoulders of giants: Rather than reinventing the wheel, Kotlin’s initial design team gathered proven paradigms from major programming languages and unified them into a cohesive whole. This approach maximized evolutionary learning from the broader programming community.

Learning from Java’s shortcomings: Kotlin’s designers could observe Java’s pitfalls and develop solid solutions from the ground up.

For deeper insights into Kotlin’s evolution, Andrey Breslav from Kotlin’s original design team gave an excellent talk at KotlinDevDay Amsterdam: Shoulders of Giants: Languages Kotlin Learned From – Andrey Breslav

Soft factors: Support and connect developers in their Kotlin adoption journey

1. Facilitate easy onboarding

The goal of the expressive Java vs. Kotlin code snippets is to whet the appetite for Kotlin. Code alone, however, is not enough to win the hearts and minds of Java developers. To accelerate adoption and ensure a smooth start, provide:

  • Sample project: A ready-to-run project with both Java and Kotlin code side-by-side, giving teams a practical reference during migration.
  • Built-in quality checks: Preconfigured with tools like SonarQube, ktlint, and detekt to promote clean, consistent, and maintainable code from day one. This will enable you to apply consistent lint rules, testing frameworks, libraries, and CI pipelines to reduce friction across teams.
  • Coaching and support: Experienced Kotlin engineers available to coach new teams, answer questions, and provide hands-on advice during the early stages of development.
    • This one is particularly important: with only a few hours of guidance from an experienced developer from another team who has gone through these stages before, much damage and technical debt can be prevented. 

A bit of support and coaching is the most powerful way to nurture lasting enthusiasm for Kotlin.

2. Offer (self-)study material

Especially when coming from Java, learning the Kotlin basics can be done on your own. Providing some resources upfront eases the path to getting productive with Kotlin and lowers the threshold. 

Note: Although self-study is valuable for grasping the basics, it also has some drawbacks. One of these drawbacks is an optionality: being caught up in the day’s frenzy, it’s tempting to skip self-study. Moreover, you will lack feedback from a practitioner who knows the subtle nuances of correctly applied idiomatic Kotlin. There is a big chance that after a self-study course, you will write Java-ish Kotlin, which gives you some benefits but does not unlock the full potential of the language. 

Unless you don’t have good coaching, a classical course can be very beneficial. There is a need to attend; you can exchange with peers of the same level and have your questions answered by an experienced professional, which will get you up to speed much faster with less non-idiomatic Kotlin during the initial transition. 

3. Establish an in-house Kotlin community

One of the fastest ways to level up Kotlin expertise across your company is to create and foremost nurture an in-house community.

  • Launch an internal Kotlin community
    • First look for a core-team of at least 3–6 developers who are willing to invest in a Kotlin community. Also ensure they get time and credits from their managers for their task.
    • Once the team is formed, organize a company-wide kick-off with well-known speakers from the Kotlin community. This will ignite the Kotlin flame and give it momentum.
    • Schedule regular meet-ups (monthly or bi-weekly) so momentum never drops.
    • Create a shared chat channel/wiki where questions, code snippets, and event notes live.
  • Invite (external) speakers
    • Bring in engineers who’ve shipped Kotlin in production to share candid war stories.
    • Alternate between deep-dive technical talks (coroutines, KMP, functional programming) and higher-level case studies (migration strategies, tooling tips).
  • Present lessons learned from other in-house projects
    • Ask project leads to present their Kotlin learnings – what worked, what didn’t, and measurable outcomes.
    • These insights can be captured in a “Kotlin playbook” that new teams can pick up.
  • Give your own developers the stage
    • Run lightning-talk sessions where anyone can demo a neat trick, library, or failure they overcame in 5–10 minutes.
    • Celebrate contributors publicly – shout-outs in all-hands or internal newsletters boost engagement and knowledge-sharing.
  • Keep feedback loops tight
    • After each session, collect quick polls on clarity and usefulness, then tweak future agendas accordingly.
    • Rotate organizational duties so the community doesn’t hinge on a single champion and stays resilient long-term.

Note: Many of the suggestions above sound straightforward. However, the effort needed to keep a community vibrant and alive should not be underestimated. 

4. Be patient…

Cultural change takes time. The danger of being enthusiastic about a tool that makes a difference for you is pushing things, which can be counterproductive. An effective approach is to nurture the adoption process with all the activities discussed above, with show, don’t tell as prior guidance.

Up next in the series

We shift focus from convincing developers to persuading decision-makers. The next post shows how to build a compelling business case for Kotlin adoption, grounded in real data and measurable outcomes. You’ll learn how to translate developer wins into management arguments, link productivity gains to cost savings, and demonstrate why Kotlin is more than a technical upgrade  it’s a strategic move for teams and companies alike.

Urs Peter

Urs is a seasoned software engineer, solution architect, conference speaker, and trainer with over 20 years of experience in building resilient, scalable, and mission-critical systems, mostly involving Kotlin and Scala.

Besides his job as a consultant, he is also a passionate trainer and author of a great variety of courses ranging from language courses for Kotlin and Scala to architectural trainings such as Microservices and Event-Driven Architectures.

As a people person by nature, he loves to share knowledge and inspire and get inspired by peers on meetups and conferences. Urs is a JetBrains certified Kotlin trainer.

image description