Skip to content
>_ StartupJobList_

Technology decisions

Java vs Kotlin for backend teams: coexistence, adoption and trade-offs

Keep Java when it already meets the service and team requirements. Pilot Kotlin where its type and modeling tools solve a concrete problem, while preserving Java consumers, behavior and support ownership. Both can coexist on the JVM; the useful decision is where a change helps enough to maintain it.

JVM backend application teams evaluating an existing Java service or a mixed Java/Kotlin module. Java records are standard from JDK16 and virtual threads from JDK21; version-sensitive behavior is qualified. This is not an Android, native-runtime or benchmark comparison.

Java and Kotlin are distinct languages that can share JVM libraries and coexist in a backend codebase. This comparison addresses adoption and coexistence decisions, rather than ranking languages or treating them as interchangeable salary populations.

Compare the practical consequences

Scroll sideways to compare all columns on a smaller screen.

Java and Kotlin by decision criterion
CriterionJavaKotlinWhat changes the decision
Existing serviceRetain known Java behavior and ownership.Pilot Kotlin behind a stable JVM boundary.A conversion must solve an identified team problem.
Null boundariesUse explicit validation and library contracts.Use nullable types and validate Java platform values.Neither repairs invalid external data automatically.
Public library APIJava callers see native Java signatures.Generated accessors, overloads and suspend signatures need review.Compile a real Java consumer fixture.
Domain valuesRecords offer concise data carriers on supported JDKs.Data classes offer generated methods and shallow copy.Specify nested collection ownership in either case.
Waiting concurrencyVirtual threads are standard from JDK21.Coroutines commonly use kotlinx.coroutines and suspend-aware clients.Choose for cancellation and bounded dependencies.
Build workflowCurrent toolchain may already meet the task.Adds a compiler/plugin and compatibility decisions.Measure cold and changed-module builds locally.
AdoptionNo language conversion risk for retained modules.Mixed Java/Kotlin supports a small trial.Keep serialized and public contracts stable.
Pay evidenceJava-tagged salary-stated source posts.Kotlin-tagged salary-stated source posts.Overlapping groups cannot price a language switch.

↑ Back to sections

When should a backend team keep Java?

Keep Java when the current service is understandable, supported and meets its requirements. Adopt Kotlin for an identifiable development problem that a small pilot can test, not because fewer lines imply lower operating cost.

A team maintaining a Java billing service already has business behavior, incident knowledge and a release process. Rewriting it creates a second task: proving that rounding, retries, authorization and message compatibility still behave the same way. Syntax reduction does not remove that proof. Start by naming the problem: repeated null handling at a boundary, awkward domain modeling, or an existing Kotlin team sharing JVM libraries. If the problem is a slow database query, changing the source language is not the first experiment.

Kotlin can coexist with Java. A new module or a narrow internal implementation can test whether the language helps without converting an entire service. Give that experiment an owner, representative tests and a removal path. Staying with Java wins when the proposed gain cannot pay for build maintenance, cross-language review and support. Kotlin wins the pilot only when the team can explain the improvement and operate the resulting code. Neither decision needs a blanket claim about all JVM projects.

Follow-up: What would reverse a decision to adopt Kotlin?

A pilot that complicates the Java-facing API, leaves support dependent on one engineer, or fails the agreed build and regression checks is a reason to stop. Retain the working boundary rather than widen the conversion.

Read the source: Kotlin: backend development · Kotlin: calling Kotlin from Java

↑ Back to sections

Does Kotlin eliminate null failures in a Java application?

Kotlin makes nullability part of its type system, but Java interoperability leaves a boundary to inspect. Unannotated Java references can arrive as platform types, so a Kotlin caller may still fail at runtime.

Consider a Java client library returning a customer label without a trustworthy nullability contract. Kotlin allows a platform value to be treated as nullable or non-nullable. Choosing a non-null declaration does not repair the external data; it can insert a runtime assertion. At an integration boundary, explicitly accept uncertainty, validate it, and translate it into the domain result. Do not scatter non-null assertions through business code and call that a null-safe migration.

The decision is about where the uncertainty becomes explicit. A Java service can use validated constructors and clear nullability conventions too. Kotlin provides compiler help inside the boundary, while the team still owns deserialization, Java libraries and initialization. During a mixed-language pilot, test missing values through real adapter signatures rather than testing only freshly constructed Kotlin objects. This makes the comparison about prevented defects instead of punctuation.

Kotlin / boundary sketch · Worked example
val label: String? = legacyClient.customerLabel()
val accepted = label?.takeIf { it.isNotBlank() }
    ?: return CustomerResult.MissingLabel

The explicit nullable declaration preserves the possibility of a missing Java value. This sketch returns a domain outcome for null or blank labels instead of manufacturing a valid name. The client and result types are application placeholders.

Illustrative, not executed.

Follow-up: Should every Java platform value become nullable?

Choose according to the real contract. Use nullable types at uncertain boundaries and validate where the application requires a value. Verified annotations can improve tooling, but callers must still handle invalid external payloads.

Read the source: Kotlin: calling Java and platform types

↑ Back to sections

What changes when Java callers consume Kotlin code?

The JVM can run both languages, but Java callers see generated methods and signatures rather than Kotlin source conveniences. Inspect the Java-facing API before converting a shared library.

Kotlin properties generally become accessors. Top-level functions become static methods on a generated class. Default arguments do not automatically give Java every convenient source-level calling form; annotations such as JvmOverloads can generate selected overloads. A suspend function also does not become an ordinary blocking Java method. These details matter when a module has callers that cannot change in the same release. On the JVM, a suspend function uses a continuation-passing representation with an additional Continuation parameter. A Java-friendly blocking or future-based adapter is a separate API decision, not an automatic effect of sharing bytecode.

Take a library used by three services. Convert an internal calculation first while keeping its published Java interface stable. Add a tiny Java consumer fixture that compiles against the produced artifact, including exceptions, generics and constructors. Decide deliberately whether Kotlin-specific types belong in the public contract. For a Java-heavy ecosystem, a plain Java-facing facade can contain the language difference. That facade has a maintenance cost, so use it at a real boundary rather than wrapping every function.

Follow-up: Is Java interoperability the same as free rollback?

No. Coexistence makes a narrow pilot possible, but changing public signatures, serialized data or dependencies can still make rollback costly. Keep those contracts stable or version them explicitly.

Read the source: Kotlin: calling Kotlin from Java · Kotlin: compatibility modes · Kotlin specification: continuation-passing style

↑ Back to sections

Are Kotlin data classes safer than Java records?

Both reduce value-model boilerplate. Neither makes referenced mutable objects deeply immutable. Choose the public contract and collection ownership before choosing the declaration syntax.

A Java record gives its components final fields and generated value-oriented methods. A Kotlin data class derives common methods from properties in its primary constructor and adds copy and component operations. A Kotlin data class can also declare mutable properties. Both can refer to a mutable collection that another object changes. A shallow copy preserves nested references; it does not create an independent object graph.

For a line-item snapshot passed between threads, define whether construction copies the list, whether each element is immutable and whether later updates create a new snapshot. Test mutation through the original input and through any exposed accessor. This is more useful than declaring that one language has immutable objects. If existing Java records already express the domain clearly, data classes alone are a weak migration argument. If Kotlin copy operations simplify deliberate state transitions, document their shallow nature in the review.

Java / Kotlin ownership trace · Worked example
Java:   record Batch(List<String> ids) {}
Kotlin: data class Batch(val ids: List<String>)

Input list: [a]
Construct batch using that list.
Another owner appends b to the same mutable list.

Without a defensive ownership strategy, both declarations can expose the later change through their list reference. The trace compares aliasing, not syntax or execution speed.

Illustrative, not executed.

Follow-up: What is a fair test of a snapshot model?

Construct it from a mutable list, change the original list, and inspect the snapshot. Then inspect nested elements separately. The expected outcome must follow the ownership contract, not the name of the class.

Read the source: Java SE 25: record classes · Kotlin: data classes

↑ Back to sections

Should the service use virtual threads or coroutines?

Choose a concurrency approach for the libraries, cancellation rules and resource limits in the service. Java virtual threads and Kotlin coroutines are different abstractions; neither makes unlimited downstream work safe.

Virtual threads became a standard Java feature in JDK21. They let compatible blocking operations release a carrier while waiting, which can help thread-per-request designs scale their waiting work. They do not increase database connection capacity or make CPU-bound work faster. Version details matter: older advice about synchronized monitor pinning does not describe current JDK24+ behavior. Native or foreign calls still need attention when diagnosing pinning.

Kotlin suspend functions work with coroutine machinery, commonly kotlinx.coroutines. A coroutine scope can define parent-child lifetime and cancellation, but a suspending signature does not prove that an implementation never blocks. Blocking libraries, dispatchers, deadlines and resource cleanup need explicit treatment. A Java team with suitable blocking clients may find virtual threads a smaller change. A Kotlin application with suspend-aware libraries may benefit from structured coroutine lifetimes. Compare one request path and its cancellation behavior, not a synthetic count of concurrent tasks. Kotlin/JVM can also call Java virtual-thread APIs when the target JDK supports them; virtual threads are not exclusive to Java source. The choice here is the application concurrency contract and its supporting libraries.

Work through a cancelled request: a caller deadline expires while two downstream reads are active. In a Java executor design, cancellation with interruption requested must reach the running task; the blocking API determines whether it responds by throwing, returning early or continuing. The task must preserve interruption and release resources. In a Kotlin coroutine scope, cancelling the Job propagates to children, but they observe it at cancellable suspension points or explicit checks. A blocking adapter does not become cancellable merely because its caller is suspend. In either design, finally can release owned resources, but cancellation does not roll back an external write that already completed. Test both the observed caller outcome and whether downstream work actually stopped.

Cancellation experiment worksheet · Worked example
Start two instrumented downstream reads.
Expire the caller deadline while both are waiting.
Observe: caller result, child exit, connection release, later side effects.
Repeat with a cooperative wait and a non-cooperative adapter.

The two runs distinguish a cancelled caller from stopped downstream work. A passing caller timeout alone cannot establish resource cleanup or prevent a completed external effect. The worksheet makes no benchmark or execution claim.

Illustrative, not executed.

Follow-up: Can a mixed application use both?

Yes, but define adapters and ownership. Test what happens when the caller times out, which work continues, and which resources are released. Adding both approaches without a clear need creates more cancellation paths to maintain.

Read the source: Java SE 25: virtual threads · Kotlin: coroutines guide · Kotlin: cancellation and timeouts · Java SE25: Thread interruption contracts

↑ Back to sections

What should a pilot measure besides runtime behavior?

Measure representative build and review workflows as well as service behavior. A shared JVM target does not mean adding a compiler, plugin and language costs nothing.

Record a clean build, a small implementation edit, a public API edit and a test-only edit. Keep machine, caches and task selection comparable, and inspect build reports before attributing a slow run to Kotlin or Java. Kotlin incremental compilation and classpath snapshots aim to limit recompilation, but the dependency graph and changed API still matter. One warm local run is not evidence about a cold CI worker.

Run the support workflow too. Ask someone other than the author to interpret a stack trace, locate a failing null boundary, change a test and review a Java consumer. Observe whether generated signatures, coroutine traces or annotation processing create confusion in this specific project. No universal build-speed ratio is claimed here. Put the measured engineering cost beside any readability benefit; otherwise the choice rewards author convenience while moving work to reviewers and on-call maintainers.

Follow-up: What is a useful pilot success criterion?

Agree before starting: a named developer task becomes clearer, representative regressions remain green, Java consumers keep their contract, and more than one maintainer can diagnose failures. Set project-specific numerical budgets only from a baseline.

Read the source: Kotlin: Gradle compilation and caches · Kotlin: calling Kotlin from Java

↑ Back to sections

How can a team adopt Kotlin without a rewrite?

Pilot one bounded implementation behind an unchanged interface. Preserve tests, data contracts and release ownership, then expand only after the team can maintain the mixed codebase.

First list the seam: its inputs, outputs, exceptions, transaction ownership and Java callers. A pure calculation with existing regression fixtures is often easier to assess than a request handler that also changes framework configuration. Keep serialized field names and database behavior fixed during the language change. Compare old and new behavior on deliberately awkward inputs, including nulls from Java, empty collections and exception paths.

Use three decision gates. Before merging, a Java consumer fixture must compile and behavior tests must retain the agreed outputs and failures. Before expanding the pilot, a second maintainer must diagnose a representative failure and review the generated public API. Before converting another module, compare the pilot against its original task and build baseline. If any gate fails, keep the boundary small or remove the change. Record supported compiler and JVM targets with the build; a source-language rollback must not depend on undoing a data migration. This is a staged adoption checklist, not a requirement that every module eventually changes language.

Migration decision worksheet · Worked example
Seam: invoice-line validation
Keep: Java interface, exception contract, serialized output
Exercise: missing tax code, empty invoice, duplicate line, negative input
Observe: Java consumer build, cold build, changed-module build, support trace
Stop if: contract changes unexpectedly or only the author can maintain it

This worksheet separates the proposed source-language change from external behavior. Its stop conditions can reject the pilot even if the Kotlin implementation is shorter. It is an editorial example, not a measured project result.

Illustrative, not executed.

Follow-up: What should not be a pilot?

A critical service rewrite with undocumented behavior and a single Kotlin maintainer. First build tests and ownership clarity; those improvements help even if the service stays in Java.

Read the source: Kotlin: backend development · Kotlin: compatibility modes

↑ Back to sections

How should salary evidence affect the language decision?

Use advertised salary cohorts to inspect relevant posts, not to calculate the cost or productivity of switching languages. Java and Kotlin tags can overlap and mix roles, places, dates and levels.

A post mentioning both technologies can contribute to both groups. Differences in advertised starting amounts may reflect role composition, geography, seniority, currency or source coverage. They do not establish that replacing Java with Kotlin changes compensation by the difference between two medians. The salary panel uses the existing source and sample rules and may withhold a summary when support is insufficient.

For hiring, read matching source posts and turn the actual work into interview criteria. A JVM role may need Java library debugging, Kotlin null-boundary reasoning or both; it does not automatically need every feature in both guides. This site has no dedicated JVM hiring-role packet in the current collection. Use the technical guides for preparation and the general hiring-budget worksheet for your own assumptions, without treating the Python-specific role packet as a JVM assessment.

Follow-up: Which evidence would justify a broader Kotlin requirement?

A real codebase and responsibility that require it, plus a team able to assess and support that work. A comparison article or aggregate pay figure is not that evidence.

Read the source: Kotlin: backend development

↑ Back to sections

Salary context

Advertised salaries for these skills

These are Hacker News job posts that mention these technologies, not employee earnings or a measure of what a skill adds to pay. The amounts summarize annualized advertised minimums in approximate USD. Groups can overlap and differ in roles, locations and dates.

Java posts

59 posts · 59 usable advertised salary amounts

2024-06-05 to 2026-10-02

$140,000

Median advertised minimum, approximate USD/year

Middle 50%: $129,000–$160,000

Explore Java salaries → Browse matching job posts →
Currency and calculation basis

Approximate USD using ECB daily reference rates as of 2026-10-09. Original currencies and offer ranges remain on the source listings. These figures cover posts tagged Java, rather than every job in the broader technology family.

Kotlin posts

No unambiguous salary cohort is available for this topic.

↑ Back to sections

Keep exploring