Monday, September 14, 2026

Kotlin Method Chaining Overuse

At my last job, there was a lot of Kotlin code that overused method chaining. Many classes contained blocks of code where a nullable object was operated on and transformed, over and over, in a single fluent expression using a lot of scope functions. The code would look something like this:


fun processOrder(orderId: String?) {
    orderId?.takeIf { it.isNotBlank() }
    ?.toIntOrNull()
    ?.let { httpClient.getPartialOrder(it) }
    ?.toDomainObject()
    ?.let { dbClient.findFullOrder(it) }
    ?.takeIf { it.status == Status.PENDING }
    ?.apply { logger.info("Processing order $id") }
    ?.let { dbClient.updateStatus(it.id, Status.PROCESSING) }
    ?.apply { this.total = this.price + billingService.calculateTax(this) }
    ?.also { order -> emailService.sendConfirmation(order) }
    ?.let { dbClient.finalizeOrder(it) }
    ?: logger.error("Order processing skipped or invalid ID")
}

This is a problem. Yes, the code works, it's arguably kotlinic, and it's brief. A strong Kotlin programmer will understand what is occurring at each line. However, it's unintuitive, especially to someone new to Kotlin. Even a senior Kotlin coder might require context to understand each line, especially if the variables and methods aren't named well.

Without Googling, try to remember which scope function returns its lambda result and which returns the original context object? At what point is the variable referred to as it a String, an Int, a FullOrder, or a database-friendly FullOrderDomain? What is even the purpose of cramming all of this logic into just one expression?

Just because you can doesn't mean you should. Let's look at each problem, line by line.

First is the null/empty check at the beginning of the method. Google's AI Overview suggests a manual null check and early termination here:

fun processOrder(orderId: String?) {
    if (orderId.isNullOrBlank()) {
        logger.error("Order processing skipped: Invalid ID")
        return
    }

I would shorten that block into something more Kotlinic:

require(!orderId.isNullOrBlank()) {
    "Order processing skipped: Invalid ID"
}

This throws an IllegalArgumentException, appropriate if the null or blank ID represents invalid input. We will assume all exceptions are caught and logged somewhere higher up the call stack.

Since we control the signature, we could even reject nullable input by changing String? to a simple String. In that case we can shorten this validation to:

require(orderId.isNotBlank()) {
    "Order processing skipped: Invalid ID"
}

That works well, but is it okay to return early? We might prefer to put our error message at the bottom of the method, so that other null-returning operations can reuse this validation and log message. That was one advantage of cramming everything into one expression ending in our Elvis operator (:?). 

However, I think we can do better. "Invalid ID" isn't a very helpful message for all of the operations in our chain. For example, if the order status isn't PENDING, we probably don't have an invalid ID at all. We probably just don't need to process the order and can instead silently return.

We'll fix these error messages as we go along.

Next, we notice that we transform the String into an Int. Since we never use the String until after it's already converted, we don't need to check whether it's blank. We can simply attempt the conversion:

val orderIdInt = orderId.toIntOrNull()
requireNotNull(orderIdInt) { "Order processing skipped: Invalid ID" }

We could have just as easily used checkNotNull() instead of requireNotNull(), but the latter more accurately communicates that we're validating method input.

Next, we perform a lot of transformations and expensive operations on the resulting objects. That is, we send a network request and a database read. This is a good place to split up the method. It's also a good place to add more specific error messages to make debugging easier. And it's a good time to consider whether all these operations return null upon failure. If they do not, the null-safe calls aren't protecting us from anything. We can remove the null checks.

After some revisions, we could end up with something like this:


fun processOrder(orderId: String) {
    val orderIdInt = orderId.toIntOrNull()
    requireNotNull(orderIdInt) { "Order processing skipped: Invalid ID" }

    // Get the full order from the partial order
    val response = httpClient.getPartialOrder(orderIdInt)
    if (!response.isSuccessful()) {
        throw Exception(response.errorMsg)
    }
    val order = dbClient.findFullOrder(response.toDomainObject())
    if (order.status != Status.PENDING) {
        // The order was already processed. No need to log an error.
        return
    }
    logger.info("Processing order ${order.id}")
    val updatedOrder = dbClient.updateStatus(order.id, Status.PROCESSING)
        ?: throw Exception("Status of order ${order.id} could not be updated")

    // Calculate total, email customer, and update DB
    updatedOrder.total = updatedOrder.price
        + billingService.calculateTax(updatedOrder)
    emailService.sendConfirmation(updatedOrder)
    dbClient.finalizeOrder(updatedOrder)
        ?: throw Exception("Order ${updatedOrder.id} could not be finalized")
}

By introducing local variables, early returns, and vertical space between blocks of functionality, the code becomes linear, scannable, and drastically easier to debug. More importantly, each variable now has an obvious type and purpose. orderIdInt is an Int. response is the response from a network request. There is no need to keep track of what it represents as it changes from type to type.

The error handling is also more meaningful. By replacing our catch-all error log at the end of the method, each operation can communicate its own failure.

Just to show what can be done when there are methods that do return null upon error, let's posit that updateStatus() and finalizeOrder() return modified order objects when successful and null when there is an error. This gives us the opportunity to use the Elvis operator. But even here, I don't think the existence of nullable return values warrants writing the entire method as one long expression.

There are, of course, plenty of times that it can be useful to chain many method calls together in one expression. To me those times are when there are few to no object type conversions and the logic is simple and fast. Consider:


val boysNames = students
    ?.filterNotNull()
    ?.filter { it.gender == MALE }
    ?.map { it.name }
    ?.sorted()
    ?: log.error("Student list was null.")

This is fine as a single expression. There is only one type transition (List<Student> to List<String>) and the logic is all very straightforward. Every operation is cheap and operates on the same collection.

We could certainly return early upon finding that students is null, but doing so would make the code more verbose without making it substantially easier to read.

The problem isn't Kotlin's method chaining. The problem is using method chaining to hide complexity. When a chain starts mixing type conversions, network calls, database I/O, conditional logic, and side effects, the chain stops being a useful abstraction and starts becoming an unreadable mess.

Conciseness isn't the same thing as readability. Sometimes the most Kotlinic code is the code that is easiest to read and maintain.

Wednesday, July 29, 2026

Is LinkedIn Premium Worth It?

The Short Answer: No.

The Long Answer: As a job-seeking software engineer, I don't think LinkedIn Premium is worth the cost ($39.99/month). I reached that conclusion after using the free one-month trial and spending time with its exclusive features.

Opinions on the internet are mixed about whether LinkedIn Premium offers a meaningful advantage in the job market12345. Supporters point to features like seeing which recruiters viewed your profile, messaging those recruiters directly, and receiving application insights that estimate how well your resume matches a position. In my experience, however, none of these features had a meaningful impact on my job search.

First, the ability to see which recruiters viewed my profile didn't provide much of an advantage. Just because a recruiter viewed my profile doesn't mean they'll respond to a message. In fact, I've never received a response after reaching out to a recruiter first. If a recruiter is genuinely interested, they can message me—and as recruiters, they almost certainly already have LinkedIn Premium through their employer.

The feature I used most was "Jobs where you'd be a top applicant." It highlights recently posted jobs that LinkedIn believes closely match your experience. It's a useful feature, although not a perfect one. It frequently recommended full-stack and frontend positions, even though I'm exclusively a backend engineer.

I eventually realized that these recommendations rely heavily on the Skills section of your profile—a section I had largely neglected. Improving that section only slightly improved the recommendations. Even then, LinkedIn's search and filtering tools remain fairly limited. There's no equivalent of a negative regex or exclusion filter, so you'll still spend time filtering out irrelevant positions.

Some info censored for privacy reasons

Another selling point of LinkedIn Premium is access to LinkedIn Learning. Personally, I found most of the courses to be fairly introductory. For the topics I was interested in, classes I've found on LinkedIn were rudimentary and lower in quality than what you can find on YouTube for free.

Finally, paying LinkedIn for the privilege of fully using its own data feels a little backwards. It reminds me of paying a headhunter to help you find a job. Traditionally, job-search platforms have been funded primarily by employers and recruiters rather than by job seekers.

That said, the strongest reason to use LinkedIn isn't Premium—it's the network itself. Nearly every professional software engineer I've worked with has a LinkedIn profile. Over time, your coworkers become your professional network, giving you a large pool of current and former colleagues (along with the occasional recruiter) that you can reach out to during your next job search. None of that requires a Premium subscription.

LinkedIn's job board is also reasonably useful, although I still think it's underdeveloped due to its meager filtering options. Its biggest advantage is the integration with your network. Once you discover that a company you're interested in employs someone you know, you can ask them for that sweet, sweet job referral. Again, that's entirely possible with the free tier.

To be fair, there are situations where Premium may be worth the cost. If you're pursuing a particularly niche role or have been searching unsuccessfully for an extended period, spending $40 for a month or two to gain even a slight advantage may be a reasonable investment. Job searching can be exhausting, and even small improvements to the process can make it feel more manageable—even if they don't ultimately land you the job.

Tuesday, June 16, 2026

Evaluating AI Models for Production

The following article isn’t sponsored by any organization and is solely the opinions and observations of the author.

I recently attended a software seminar on generative AI (GenAI) model evaluation. The event was hosted by TrackIt and AWS. The session promised to explore “how modern AI teams are benchmarking, testing, and optimizing large language models for production environments,” but the most valuable takeaway was a discussion how best to evaluate LLMs (large language models; generative AI models like ChatGPT, Claude Code, etc.) for real-world business use.

I found this topic particularly interesting because there is little guidance available on how organizations should choose an LLM for a specific project or business need. While model capabilities are discussed extensively, the process of evaluating and selecting a model is often overlooked.

This slide was crucial:

LLM Slide
Sorry that I didn't get a better shot

It summarizes the three primary approaches to LLM evaluation:

  1. Algorithmic (deterministic)
  2. LLM-as-Judge
  3. Operational (cost, speed, etc.).

The first and third are relatively straightforward. Operational considerations such as cost, latency, and vendor support are often the first factors engineering teams evaluate when selecting software. Similarly, there are dozens of deterministic LLM-evaluating benchmarks, such as Humanity’s Last Exam, GPQA Diamond, and others. There are numerous websites dedicated to comparing LLMs using these benchmarks.

What was novel to me was the LLM-as-judge approach: using one LLM to evaluate the output of another. In this approach, you define the evaluation criteria and have a separate LLM score the responses generated by the model under test. Although this requires some upfront effort to design effective evaluation criteria, those criteria can be reused across all of your future evaluations. 

This approach is especially valuable when your intended usage has no objective ground truth, or when quality depends on subjective factors. While traditional benchmarks can provide insight into general model performance, they may not accurately predict how well a model performs on a specialized business task. For example, a benchmark score will not tell you which model is best at transforming a chef’s rough notes into polished, consumer-ready recipes for a new cooking website.

Let’s imagine you are building a cooking platform and have a chef who sketches recipe ideas in shorthand. You want an LLM to convert those rough notes into clear, complete, and professionally written recipes. To evaluate candidate models, you would create a collection of representative inputs (the chef's notes and the model instructions) and ideal outputs (finished recipes). Each model under evaluation would generate responses for the same inputs, and the judge model would compare those responses against the expected outputs using criteria you define. The resulting scores would provide a task-specific evaluation tailored to your business needs.

Some tips:

  • Pick one or two judge models. One might think that the comprehensive way would be to use many different models. This adds significant complexity while providing limited additional value. In most cases, using your current production model, or a capable, low-cost model is sufficient.
  • Use a small continuous scoring range, such as 1–5. Quality assessments are inherently subjective and thus should be rated with a range rather than a binary pass/fail. Conversely, a huge range (e.g. 1-100) implies a false level of precision.
  • Don’t rely solely on LLM-as-judge evaluations. Combine subjective evaluations with the algorithmic and operational metrics. Those quick, easy, and deterministic methods add important color to the subjective method discussed here.

The key lesson I took away from the seminar is that selecting an LLM should not be based solely on benchmark rankings. The best model for a business is often the one that performs best on the organization's specific tasks while balancing algorithmic performance, subjective quality, and operational requirements.