Building Spring apps with Kotlin

Updated: 20 October 2023

This post outlines the general methodology and operation of Spring applications with Kotlin. For the sake of example we will be building a project management application backend

References

About Spring

The Spring framework comes with lots of infrastructure for working with some common application needs like dependency injection, transaction management, web apps, data access, and messaging

Spring boot provides an opinionated setup for building Spring applications using the framework

Initializing an Application

To initialize an application we can use the Spring initializr and select the project configuration that we would like. For this case adding selecting Maven, Kotlin, and Java 17 along with the Spring Web Dependency

Running the Application

The initialized application will have the Spring dev tools preinstalled. You can run the application in hot reloading mode using them as follows:

Terminal window
1
mvn spring-boot:run

While you can probably make all this stuff work using the command line but since the Kotlin experience seems kinda wack in VSCode I’ll be using IntelliJ for this

So, with that noted - just hit the play button

Defining a Controller

To define a controller we can simply create a class with the @RequestController to register the controller and @RequestMapping to register the endpoint within this controller

Then, we can define a method in that controller, the name of the function is not super important but the annotation at the top tells how it will be routed

The code for the controller can be seen below

HelloController.kt

1
import org.springframework.web.bind.annotation.GetMapping
2
import org.springframework.web.bind.annotation.RequestMapping
3
import org.springframework.web.bind.annotation.RestController
4
5
@RestController
6
@RequestMapping("api/hello")
7
class HelloController {
8
@GetMapping
9
fun getHello() = "Hello World"
10
}

We can make the endpoint return data instead of just a string by defining a data class to return:

1
data class Data(val name: String, val age: Int)
2
3
// in controller
4
5
@GetMapping("")
6
fun getHello() = Data("Bob Smith", 45)

Project Structure Overview

Some of the important parts of the generated project are the following:

  • mvnw or gradlew files which are wrappers for executing commands for the respective build tool
  • pom.xml or build.gradle files for configuring application dependencies
  • The main function ni the main/kotlin directory, this will have the same name as your project, and is the application entrypoint
  • The files in the test directory are where we will include our unit tests

For reference, the content of my main file are as follows:

1
package nabeelvalley.springkotlin
2
3
import org.springframework.boot.autoconfigure.SpringBootApplication
4
import org.springframework.boot.runApplication
5
6
@SpringBootApplication
7
class SpringAppWithKotlinApplication
8
9
fun main(args: Array<String>) {
10
runApplication<SpringAppWithKotlinApplication>(*args)
11
}

Models

The data layer for our application will consist of different models. We can define these inside of our application code, for example we can create a class called Project which we can define as a data class as such:

models/Project.kt

1
package nabeelvalley.springkotlin.models
2
3
data class Project(
4
val name: String,
5
val description: String
6
);

Data Sources

Data sources are retrieval and storage (aka database). This will allow us to provide us some method of getting data and exchanging implementations of our storage layer

To create a data source we can create a class for a specific data source. When doing this, we usually want to create a base interface that different data sources should implement. Spring calls these Repositories, so we’ll name them using that convention as well

The interface can be defined as so:

data/IProjectRepository.kt

1
package nabeelvalley.springkotlin.data
2
3
import nabeelvalley.springkotlin.models.Project
4
5
interface IProjectRepository {
6
fun getProjects(): Collection<Project>
7
}

Thereafter, we can create a simple in-memory implementation of the above interface using the following:

1
package nabeelvalley.springkotlin.data
2
3
import nabeelvalley.springkotlin.models.Project
4
import org.springframework.stereotype.Repository
5
6
@Repository
7
class InMemoryProjectRepository : IProjectRepository {
8
private val data = mutableListOf<Project>()
9
10
override fun getProjects(): Collection<Project> = data
11
}

It is also important to note that the repository implementation has the @Repository attribute

Testing

Testing will be done using JUnit. Creating test classes can be done by adding the relevant file into the test/kotlin directory

We can add a test for the repository we created as follows:

test/.../data/InMemoryProjectRepositoryTests.kt

1
package nabeelvalley.springkotlin.data
2
3
import org.junit.jupiter.api.Assertions
4
import org.junit.jupiter.api.Test
5
6
internal class InMemoryProjectRepositoryTests {
7
8
private val repository = InMemoryProjectRepository()
9
10
@Test
11
fun `should list collection of projects`() {
12
val result = repository.getProjects()
13
14
Assertions.assertTrue(result.isNotEmpty())
15
}
16
}

We can run the above test using IntelliJ, the result will be that the test is failing since we have not added any items into our repository, we can add som sample items and run the test again, it should now be passing

The changes to the repository data definition can be seen below:

1
private val data = mutableListOf(
2
Project("Project 1", "Description of the first project")
3
)

Services

Services are defined using a class with the @Service annotation and generally imply that we will be using some kind of data service or other data.

@Service and @Repository are extensions on the @Component implementation which is some kind of injectable class that can be managed by Spring. These are all instances of an @Bean

We can define a class for the service which uses a project repository as follows:

services/ProjectService.kt

1
package nabeelvalley.springkotlin.services
2
3
import nabeelvalley.springkotlin.data.IProjectRepository
4
import org.springframework.stereotype.Service
5
6
@Service
7
class ProjectService(private val repository: IProjectRepository) {
8
9
fun getAll() = repository.getProjects()
10
}

Controllers

Controllers are used for mapping services to HTTP endpoints. We can define a controller like so:

Get All

1
package nabeelvalley.springkotlin.controller
2
3
import nabeelvalley.springkotlin.services.ProjectService
4
import org.springframework.web.bind.annotation.GetMapping
5
import org.springframework.web.bind.annotation.RequestMapping
6
import org.springframework.web.bind.annotation.RestController
7
8
@RestController
9
@RequestMapping("/api/project")
10
class ProjectController(private val service: ProjectService) {
11
@GetMapping
12
fun getAll() = service.getAll()
13
}

In the above we are using @RestController and @RequestMapping to define the controller and @GetMapping to annotate the method for the defined handler

We can further defined tests for this controller using @SpringBootTest which will initialize the entire application context for the purpose of a test. We can also restrict this a bit using Test Slices, but the example below just uses the entire Spring application context

In the below test

The test file can be seen below in which we verify that the HTTP response matches what we would like as well as ensuring that the data we received is aligned to the data we expect by way of converting the expected data to the JSON response

1
package nabeelvalley.springkotlin.controller
2
3
import com.fasterxml.jackson.databind.ObjectMapper
4
import nabeelvalley.springkotlin.models.Project
5
import org.junit.jupiter.api.Test
6
import org.springframework.beans.factory.annotation.Autowired
7
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
8
import org.springframework.boot.test.context.SpringBootTest
9
import org.springframework.http.MediaType
10
import org.springframework.test.web.servlet.MockMvc
11
import org.springframework.test.web.servlet.get
12
13
@SpringBootTest
14
@AutoConfigureMockMvc
15
class ProjectControllerTests @Autowired constructor(
16
private val mockMvc: MockMvc,
17
private val objectMapper: ObjectMapper
18
) {
19
@Test
20
fun `should return a json response`() {
21
val expectedData = listOf(
22
Project("project-1", "Project 1", "Description of the first project")
23
)
24
val expectedJson = objectMapper.writeValueAsString(expectedData)
25
26
mockMvc.get("/api/project")
27
.andDo {
28
print()
29
}
30
.andExpect {
31
status {
32
isOk()
33
}
34
content {
35
contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
36
json(expectedJson)
37
}
38
}
39
}
40
}

Additionally, we will use MockMvc and @AutoConfigureMockMvc to mock HTTP requests without all the HTTP request overhead. Using @Autowired constructor with this uses the Spring boot constructor dependency injection for the relevant members in the constructor

Get One

Using the methodology above we can implement controllers for getting one project by doing the following:

  1. Update the project entity to have an id field:
1
data class Project(
2
// add this field
3
val id: String,
4
);
  1. Update the repository to contain a method for getting a project by ID:
1
interface IProjectRepository {
2
3
// add this method
4
fun getProject(id: String): Project?
5
}
  1. Update the repository implementation to use th id field and to get one by id:
1
@Repository
2
class InMemoryProjectRepository : IProjectRepository {
3
private val data = mutableListOf(
4
// add id to project
5
Project("project-1", "Project 1", "Description of the first project")
6
)
7
8
// we add this method
9
override fun getProject(id: String): Project? =
10
data.find {
11
it.id == id
12
}
13
}

In the above snippet, it is important to note that we are returning a Nullable project, this is because we may not find a value with the given id

  1. Update the service to allow getting a value by id:
1
@Service
2
class ProjectService(private val repository: IProjectRepository) {
3
// add this method
4
fun getOne(id: String) = repository.getProject(id);
5
}
  1. Add a new endpoint on the controller that uses the id as a PathParam which also contains the ID in the @GetMapping definition. In the controller, we will return a ResponseEntity with a NotFound status for a result that is not found, or an OK status for a result that is found
1
@RestController
2
@RequestMapping("/api/project")
3
class ProjectController(private val service: ProjectService) {
4
// add this method
5
@GetMapping("/{id}")
6
fun getOne(@PathVariable(required = true) id: String): ResponseEntity<Project> =
7
when (val result = service.getOne(id)) {
8
null -> ResponseEntity.notFound().build()
9
else -> ResponseEntity.ok(result)
10
}
11
}

Query Params

We can handle query params in our application by using the RequestParam annotation:

1
@GetMapping
2
fun getAll(@RequestParam name: String?) = service.getAll(name)

Note that in the above example name is of type String?, if we were to make this a required type of String then the application would return a bad request if the consumer does not provide a value for the query parameter

Furthermore we can update our other members of our implementation to use this search parameter as follows:

In the service:

1
@Service
2
class ProjectService(private val repository: IProjectRepository) {
3
4
fun getAll(query: String?) = repository.searchProjects(query)

In the repository interface:

1
interface IProjectRepository {
2
// add this method
3
fun searchProjects(query: String?): Collection<Project>

And in the reposotry implemetation:

1
@Repository
2
class InMemoryProjectRepository : IProjectRepository {
3
// add this method
4
override fun searchProjects(query: String?) = data.filter {
5
it.name.contains(query ?: "")
6
}

Note that the searchProjects method here requires an optional string which we default in the contains function, the reason we do not default the value in the function parameter list is that it is not allowed when overriding a method to define a default in the method param. However under other circumstances we can default a parameter by defining it like so: fun doStuff(text: String? = "")

Exceptions

If for some reason we are unable to use pattern matching and more functional methods for handling excpetions, Spring Boot allows us to define an ExceptionHandler for our controller, for example, if we were to have some codepath throw an error like NoSuchElement

1
@RestController
2
@RequestMapping("/api/project")
3
class ProjectController(private val service: ProjectService) {
4
// add this method
5
@ExceptionHandler(NoSuchElementException::class)
6
fun handleException(e: Exception) = ResponseEntity.internalServerError().body("Error handling: ${e.message}")
7
}

Or if we were to handle a more general exception being thrown:

1
@RestController
2
@RequestMapping("/api/project")
3
class ProjectController(private val service: ProjectService) {
4
// add this method
5
@ExceptionHandler(Exception::class)
6
fun handleException(e: Exception) = ResponseEntity.internalServerError().body("Error getting projects")
7
}

POST and PUT endpoints

We can define endpoints that use POST or PUT using the relevant mapping annotation. In order to receive data in the body we would also use the @RequestBody annotation. Furthermore we can use the @ResponseStatus to annotate that status that will be returned to the user when the response is sent:

1
@RestController
2
@RequestMapping("/api/project")
3
class ProjectController(private val service: ProjectService) {
4
// add this method
5
@PostMapping
6
@ResponseStatus(HttpStatus.CREATED)
7
fun createOne(@RequestBody project: Project) = service.createOne(project)
8
}

Note that you would still need to implement any service or repository level tasks as needed

Other HTTP Methods

As needed, we can also implement other HTTP methods like PATCH and DELETE similar to the POST and GET methods above respectively