Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Refactor Java Code to Idiomatic Kotlin

In this lab, you'll refactor a working JVM CLI application from verbose, Java-style Kotlin into concise, idiomatic Kotlin — replacing boilerplate patterns with data classes, primary constructors, Kotlin properties, and safe-call operators. You'll apply Kotlin's null safety, collection extensions, and expression-based control flow to simplify real code while keeping all existing tests green. By the end, you'll have the hands-on fluency to recognize and eliminate common Java migration anti-patterns in your own Kotlin codebases.

Lab platform
Lab Info
Level
Intermediate
Last updated
Jul 01, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Explore the codebase

    Kotlin was designed to reduce Java boilerplate, but migrations don't always get there. Production codebases often land in an in-between state: valid Kotlin that still follows Java patterns, with verbose class declarations, manual null guards, and imperative loops all doing work that idiomatic Kotlin can express in far fewer lines. Knowing how to recognize and eliminate those patterns is the skill that turns a working port into genuinely idiomatic Kotlin.

    In this lab, you'll work with a running JVM CLI inventory application written in that Java-style Kotlin. The application processes and reports on a list of inventory items. You'll refactor it step by step, converting verbose class declarations to data classes, replacing explicit getter and setter methods with Kotlin property access, applying null-safe operators in place of manual guards, and swapping imperative loops for collection extension functions.

    To start, you'll run the application to observe its current output and establish a green test baseline you'll carry as a safety net through every step.

    The application directory already contains a runnable CLI application, a JUnit 5 behavioral test suite, and a Gradle Kotlin DSL build.

    info> Feeling stuck? Check out the matching solution/stepN/ folder for the step you're on to see a working implementation. Give it a try on your own first. The solution folder is your safety net, not your starting point.

    The Gradle run task

    The Gradle application plugin adds a run task that compiles the project and launches the application's main function. Running it now gives you a concrete picture of the output the CLI produces. That output is the behavior you must preserve through every refactoring step that follows.

    warning> The lab environment on the right may take up to a minute to finish loading. Continue after the terminal and project files appear. ### The behavioral test suite as a safety net

    The existing JUnit 5 behavioral test suite exercises the application's public interface across a range of inputs. Running it before touching any code confirms that all tests start green. A verified baseline means any failure you see in a later step was introduced by a change in that step, not by a problem that existed before you started. info> This lab experience was developed by the Pluralsight team using an internally developed AI tool. All sections were verified by human experts for accuracy prior to publication. However, content may still contain errors or inaccuracies, and we recommend independent verification.

    To report a problem or provide feedback, click here. Feedback may be used to improve accuracy in accordance with our Privacy Policy.

  2. Challenge

    Step 2: Refactor classes and constructors

    With a green test baseline established, you can refactor with confidence. The model classes in this codebase are the most visible source of Java-style boilerplate: verbose constructors that manually assign parameters to backing fields, explicit get and set methods for every property, and none of the structural equality, copy(), or toString() behavior that Kotlin's data class generates automatically.

    In this step, you'll replace that ceremony with concise data class declarations and Kotlin property access, then confirm the behavioral tests still pass.

    Data classes and primary constructors

    A Kotlin data class condenses a verbose class declaration into a single line. Adding the data keyword and declaring all fields as primary constructor parameters causes the compiler to generate equals(), hashCode(), toString(), and copy() automatically.

    Any class used primarily as a value holder with no custom lifecycle is a strong candidate for this conversion.

    // Java-style class
    class Widget {
        private var label: String = ""
        constructor(label: String) { this.label = label }
        fun getLabel(): String = label
    }
    
    // Idiomatic Kotlin data class
    data class Widget(val label: String)
    ``` ### Kotlin property access
    
    When a class declares its fields in a primary constructor, those fields become Kotlin properties accessed directly by name. There is no need for explicit `getX()` or `setX()` method declarations. 
    
    Call sites that previously used `item.getName()` become `item.name`, and `item.setName(value)` becomes `item.name = value` for mutable properties. ### Verifying structural alignment after the class refactoring
    
    The data class conversion changes how property access works across the codebase. Running the full behavioral suite after both Task 2.1 and Task 2.2 confirms that the model type and every file that references it are aligned correctly.
  3. Challenge

    Step 3: Apply null safety patterns

    The data classes and Kotlin properties are in place. The next layer of Java-style boilerplate is in the application logic: explicit if (x != null) guards and .equals() method calls that Java's type system forced but Kotlin's null-safety types and operators make unnecessary. In data classes, == delegates to the generated equals() implementation, making it the idiomatic replacement for any .equals() call sites.

    In this step, you'll replace null-check guards with safe-call (?.) and Elvis (?:) operators, and swap .equals() calls for Kotlin's == operator, letting the compiler track nullability rather than doing it by hand.

    Safe-call and Elvis operators

    Kotlin's safe-call operator (?.) accesses a property or calls a method only when the receiver is non-null, producing null otherwise. The Elvis operator (?:) provides a fallback value when the left side evaluates to null.

    Together, they replace the common pattern of declaring a mutable variable, branching into two assignment blocks based on a null check, and arriving at the same result.

    // Java-style null guard
    var result: String
    if (thing != null) {
        result = thing.label
    } else {
        result = "default"
    }
    
    // Idiomatic Kotlin
    val result = thing?.label ?: "default"
    ``` ### Structural equality in Kotlin
    
    Kotlin's `==` operator calls the `equals()` implementation of the type on the left. For data classes, that implementation is generated automatically from the primary constructor properties. 
    
    Using `==` is the idiomatic replacement for any `.equals()` call site, and it reads more naturally than a method call. ### Confirming null safety preserves behavior
    
    Safe-call and Elvis operators preserve the same semantics as the explicit null checks they replace. Running the behavioral suite after both tasks confirms the operators produce the results the tests expect.
  4. Challenge

    Step 4: Modernize collections and control flow

    Null safety is now handled idiomatically, and the class and accessor boilerplate is gone. The remaining Java-style patterns are in how the service code processes its data: imperative for loops that accumulate results into mutable lists, and an if/else chain that assigns a value to a var across multiple branches where Kotlin supports expressions.

    In this step, you'll replace the loop-based accumulation patterns with Kotlin collection extension functions, and convert the statement-style conditional assignment to a when expression, producing code that reads linearly from top to bottom.

    Collection extension functions

    Kotlin's standard library provides extension functions on collection types that replace common accumulation patterns. filter selects elements matching a predicate, sumOf computes a numeric sum from each element, maxByOrNull finds the element with the highest value for a key, and groupBy partitions a collection into groups keyed by a property.

    Each of these replaces a corresponding for loop that built a mutable result manually.

    // Java-style accumulation
    val result = mutableListOf<Widget>()
    for (w in widgets) {
        if (w.active) result.add(w)
    }
    
    // Idiomatic Kotlin
    val result = widgets.filter { it.active }
    
    Hint: how to use groupBy when you also need to format the groups

    groupBy returns a Map from key to a list of matching elements. You can chain map on that result to transform each entry into a formatted string, then joinToString to combine them.

    val lines = collection.groupBy { it.property }
        .map { (key, members) -> "$key: ${members.size}" }
        .joinToString(separator)
    
    ### `when` as an expression

    In Kotlin, when can be used as an expression whose result you assign directly to a val. This replaces the common pattern of declaring a var, then assigning it inside each branch of an if/else chain.

    Using when as an expression also gives the compiler a complete view of all branches, which it uses to enforce that the result is always assigned.

    // Java-style conditional assignment to var
    var tier: String
    if (count > 100) {
        tier = "large"
    } else if (count > 10) {
        tier = "medium"
    } else {
        tier = "small"
    }
    
    // Idiomatic Kotlin: when expression assigned to val
    val tier = when {
        count > 100 -> "large"
        count > 10 -> "medium"
        else -> "small"
    }
    ``` ### Confirming the modernized service preserves output
    
    Collection extension functions and `when` expressions change how results are computed, not what those results are. Running the full behavioral suite after both tasks confirms that every computation still produces the expected output.
  5. Challenge

    Step 5: Validate the refactor

    The behavioral tests have passed after every refactoring step. At this point the codebase is structurally complete. The lab's validation checks will confirm that idiomatic Kotlin constructs were applied throughout.

    To close the lab, you'll run the CLI one final time to verify the refactored application produces the same output it did when you started.

    Confirming end-to-end behavior

    Running the CLI against the fully refactored codebase is the observable confirmation that every change you made across the steps composes correctly. The output you see now should match the output you observed in Task 1.1. Well done. You refactored a working JVM CLI inventory application from Java-style Kotlin into idiomatic Kotlin, keeping the behavioral test suite green at every step.

    You applied four categories of Kotlin idiom:

    • Data classes with primary constructors and auto-generated structural equality
    • Kotlin property access in place of explicit getter and setter methods
    • Safe-call and Elvis operators that let the compiler enforce null safety in place of manual guards
    • Collection extension functions and when expressions that replace imperative accumulation patterns with readable, top-to-bottom code

    The final ./gradlew run confirmed that all of those changes composed correctly and the application produces the same output it did at the start. These patterns appear in every Kotlin codebase migrated from Java, and the ability to recognize and refactor them systematically is what turns a working port into idiomatic Kotlin.

About the author

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight