Install
$ agentstack add skill-navikt-copilot-java-to-kotlin ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Java to Kotlin Migration
Systematic conversion of Java codebases to idiomatic Kotlin, with framework-aware transformations for Nav services. Covers the full journey from faithful translation through nullability audit, collection migration, and idiomatic transforms — plus framework-specific patterns for Spring→Ktor, JPA→Kotliquery, JUnit→Kotest, and Lombok elimination.
When to Use
- Migrating existing Java services to Kotlin
- Converting Java files in mixed Java/Kotlin codebases
- Onboarding teams from Java to Kotlin patterns
- Planning a phased migration strategy for a Nav service
4-Step Conversion Methodology
Step 1: Faithful Translation
Direct Java → Kotlin conversion preserving exact behavior. Use IntelliJ's built-in converter as a starting point, then fix compilation errors. Keep all existing tests passing. No idiomatic changes yet — correctness first.
// Java — before
public class UserService {
private final UserRepository repository;
private final Logger log = LoggerFactory.getLogger(UserService.class);
public UserService(UserRepository repository) {
this.repository = repository;
}
public User findById(Long id) {
User user = repository.findById(id);
if (user == null) {
throw new NotFoundException("User not found: " + id);
}
log.info("Found user: {}", user.getName());
return user;
}
public List findActive() {
return repository.findAll().stream()
.filter(u -> u.getStatus().equals("active"))
.collect(Collectors.toList());
}
}
// Kotlin — Step 1: faithful translation (no idiomatic changes)
class UserService(private val repository: UserRepository) {
private val log = LoggerFactory.getLogger(UserService::class.java)
fun findById(id: Long): User {
val user = repository.findById(id)
if (user == null) {
throw NotFoundException("User not found: $id")
}
log.info("Found user: {}", user.name)
return user
}
fun findActive(): List {
return repository.findAll().stream()
.filter { u -> u.status == "active" }
.collect(Collectors.toList())
}
}
Step 2: Nullability Audit
Review every ! (platform type assertion) and make nullability explicit. Map Java @Nullable / @NotNull to Kotlin ? / non-null types. Decide per case: requireNotNull() vs safe calls vs default values. Focus on API boundaries — internal code gets strict non-null types.
// Before — platform types and implicit nullability
fun findById(id: Long): User {
val user = repository.findById(id) // returns User! (platform type)
if (user == null) {
throw NotFoundException("User not found: $id")
}
return user
}
// After — explicit nullability
fun findById(id: Long): User {
return repository.findById(id) // returns User? (explicit nullable)
?: throw NotFoundException("User not found: $id")
}
Step 3: Collection and Type Migration
| Java | Kotlin | |------|--------| | List (mutable) | List (immutable) or MutableList | | Optional | T? (nullable) | | Stream pipeline | Kotlin stdlib collection operations | | Map | Map / MutableMap | | enum with methods | Kotlin enum or sealed class |
// Before — Java streams and Optional
fun findActive(): List {
return repository.findAll().stream()
.filter { u -> u.status == "active" }
.collect(Collectors.toList())
}
fun getDisplayName(id: Long): String {
val user: Optional = repository.findOptional(id)
return user.map { it.name }.orElse("Unknown")
}
// After — Kotlin collection operations
fun findActive(): List =
repository.findAll().filter { it.status == "active" }
fun getDisplayName(id: Long): String =
repository.findById(id)?.name ?: "Unknown"
Step 4: Idiomatic Transforms
Apply Kotlin idioms: data classes, extension functions, sealed classes, when expressions, and scope functions where they improve readability.
// Before — Java-style POJO translated directly
class UserDto(
private var id: Long,
private var name: String,
private var email: String,
private var status: String
) {
fun getId() = id
fun getName() = name
fun getEmail() = email
fun getStatus() = status
// equals, hashCode, toString, copy...
}
// After — Kotlin data class
data class UserDto(
val id: Long,
val name: String,
val email: String,
val status: String,
)
// Before — utility class with static methods
class StringUtils {
companion object {
fun maskFnr(fnr: String): String =
if (fnr.length == 11) fnr.take(6) + "*****" else fnr
}
}
val masked = StringUtils.maskFnr(ident)
// After — extension function
fun String.maskFnr(): String =
if (length == 11) take(6) + "*****" else this
val masked = ident.maskFnr()
// Before — complex conditional chain
fun categorize(age: Int, status: String): String {
if (status == "disabled") return "inactive"
if (age "inactive"
age "minor"
age "working-age"
else -> "senior"
}
// Before — builder pattern
val config = Config.builder()
.setHost("localhost")
.setPort(8080)
.setDebug(true)
.build()
// After — named arguments with defaults
val config = Config(
host = "localhost",
port = 8080,
debug = true,
)
Framework-Aware Conversions
Spring Boot → Ktor
| Spring | Ktor | |--------|------| | @RestController | routing { } with get/post/put/delete | | @Service | Plain class with constructor injection | | @Repository (JPA) | using(sessionOf(dataSource)) pattern | | @Autowired | Constructor parameters (no annotations) | | @Configuration | Sealed class config pattern | | application.yml | System.getenv() or konfig library |
// Spring Boot controller
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity createUser(@RequestBody CreateUserRequest request) {
UserDto created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
// Ktor route handler
fun Route.userRoutes(userService: UserService) {
route("/api/users") {
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val user = userService.findById(id)
call.respond(HttpStatusCode.OK, user)
}
post {
val request = call.receive()
val created = userService.create(request)
call.respond(HttpStatusCode.Created, created)
}
}
}
Hibernate/JPA → Kotliquery
// JPA entity + repository
@Entity
@Table(name = "vedtak")
public class VedtakEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String ident;
private String status;
private LocalDate fom;
}
public interface VedtakRepository extends CrudRepository {
List findByIdent(String ident);
}
// Kotliquery — data class + repository
data class Vedtak(
val id: Long,
val ident: String,
val status: String,
val fom: LocalDate,
)
class VedtakRepository(private val dataSource: DataSource) {
fun findByIdent(ident: String): List =
using(sessionOf(dataSource)) { session ->
session.run(
queryOf("SELECT * FROM vedtak WHERE ident = ?", ident)
.map { row ->
Vedtak(
id = row.long("id"),
ident = row.string("ident"),
status = row.string("status"),
fom = row.localDate("fom"),
)
}.asList
)
}
fun save(vedtak: Vedtak): Long =
using(sessionOf(dataSource)) { session ->
session.run(
queryOf(
"INSERT INTO vedtak (ident, status, fom) VALUES (?, ?, ?)",
vedtak.ident, vedtak.status, vedtak.fom
).asUpdateAndReturnGeneratedKey
) ?: throw IllegalStateException("Failed to insert vedtak")
}
}
JUnit → Kotest
// JUnit 5 test
public class UserServiceTest {
@Mock private UserRepository repository;
@InjectMocks private UserService service;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldFindUserById() {
User user = new User(1L, "Kari");
when(repository.findById(1L)).thenReturn(user);
User result = service.findById(1L);
assertEquals("Kari", result.getName());
verify(repository).findById(1L);
}
@Test
void shouldThrowWhenNotFound() {
when(repository.findById(99L)).thenReturn(null);
assertThrows(NotFoundException.class, () -> service.findById(99L));
}
}
// Kotest matchers + MockK
class UserServiceTest {
private val repository = mockk()
private val service = UserService(repository)
@Test
fun `should find user by id`() {
val user = User(1L, "Kari")
every { repository.findById(1L) } returns user
val result = service.findById(1L)
result.name shouldBe "Kari"
verify { repository.findById(1L) }
}
@Test
fun `should throw when not found`() {
every { repository.findById(99L) } returns null
shouldThrow {
service.findById(99L)
}
}
}
Lombok → Kotlin Native
| Lombok | Kotlin | |--------|--------| | @Data | data class | | @Builder | Named arguments + default values | | @Getter / @Setter | val / var properties | | @Slf4j | KotlinLogging.logger {} | | @AllArgsConstructor | Primary constructor (Kotlin default) | | @NoArgsConstructor | Not needed, or add default values | | @RequiredArgsConstructor | Primary constructor with val params |
// Lombok
@Data
@Builder
@Slf4j
public class Søknad {
private final String ident;
private final LocalDate innsendtDato;
@Builder.Default
private String status = "mottatt";
public void behandle() {
log.info("Behandler søknad for {}", ident);
this.status = "behandlet";
}
}
// Kotlin native
private val logger = KotlinLogging.logger {}
data class Søknad(
val ident: String,
val innsendtDato: LocalDate,
var status: String = "mottatt",
) {
fun behandle() {
logger.info { "Behandler søknad for $ident" }
status = "behandlet"
}
}
Jackson → kotlinx.serialization (optional)
| Jackson | kotlinx.serialization | |---------|----------------------| | @JsonProperty("name") | @SerialName("name") | | ObjectMapper() | Json { ignoreUnknownKeys = true } | | @JsonIgnore | @Transient |
> Note: Many Nav services keep Jackson with the jackson-module-kotlin — only migrate to kotlinx.serialization if the team explicitly wants to.
Git History Preservation
Two-phase rename strategy to preserve git log --follow:
# Phase 1: rename file (pure rename, no content change)
git mv src/main/java/no/nav/MyService.java src/main/kotlin/no/nav/MyService.kt
git commit -m "rename: MyService.java → MyService.kt"
# Phase 2: convert content (in separate commit)
# ... apply Kotlin conversion ...
git commit -m "refactor: convert MyService to idiomatic Kotlin"
This ensures git log --follow src/main/kotlin/no/nav/MyService.kt shows the full history including the Java era.
Batch Conversion Workflow
Convert bottom-up: dependencies before dependents. Keep mixed Java/Kotlin builds working throughout. Run the full test suite after each file conversion.
Suggested order:
- Models/DTOs — data classes, straightforward wins
- Utilities — extension functions, small scope
- Repositories — Kotliquery migration
- Services — business logic, may have complex nullability
- Controllers/Routes — framework migration (Spring→Ktor)
- Configuration — sealed class patterns
- Tests — Kotest migration (do last, keeps validation working)
Within each layer, convert leaf packages first (no internal dependencies), then work inward.
Common Pitfalls
| Pitfall | Solution | |---------|----------| | Kotlin keywords as identifiers (when, is, in, object) | Backtick-escape ` when or rename | | SAM conversion — Java functional interfaces auto-convert, Kotlin interfaces don't | Use fun interface for Kotlin SAM types | | Platform types (T!) from Java without null annotations | Decide null strategy explicitly — never leave ! | | Java static → Kotlin | companion object or top-level functions | | @JvmStatic / @JvmField | Needed if Java code still calls Kotlin companion object members | | Checked exceptions | Kotlin doesn't have them — add @Throws if called from Java | | Property access syntax | Java getX() becomes x` in Kotlin callers |
Related
| Resource | Use For | |----------|---------| | kotlin-ktor instruction | Target patterns for Ktor development | | kotlin-spring instruction | Spring Boot Kotlin patterns (if staying on Spring) | | kotlin-app-config skill | Sealed class configuration pattern | | spring-boot-scaffold skill | Scaffolding new Spring Boot services | | flyway-migration skill | Database migration patterns |
Boundaries
✅ Always
- Preserve git history (two-phase rename)
- Run tests after each file conversion
- Convert bottom-up (dependencies before dependents)
- Fix nullability explicitly — never leave platform types
- Keep mixed Java/Kotlin builds compiling throughout
⚠️ Ask First
- Framework migration (Spring → Ktor)
- Changing test framework (JUnit → Kotest)
- Build system changes (Maven → Gradle)
- Switching serialization library (Jackson → kotlinx)
🚫 Never
- Convert multiple files without testing in between
- Suppress Kotlin warnings with
@Suppress - Use
!!without verifying the value cannot be null - Change behavior during conversion — correctness first
- Delete Java files before Kotlin replacements compile and pass tests
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: navikt
- Source: navikt/copilot
- License: MIT
- Homepage: https://ki-utvikling.nav.no/nav-pilot
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.