Spring Boot With Kotlin
11 min read
This post is a continuation of my Kotlin + Spring Boot learning journey. Check out my first post on Kotlin here. These are notes I wrote down when going through this YouTube series (which I'd really recommend for those new to Spring Boot).
Getting up and running with a REST endpoint
- Use
@RestController
to initialise a controller class for handling REST requests. @RequestMapping
defines a path for the endpoint.- Use
@GetMapping
to state it's a GET endpoint.- It can also be used to add extra path params to the parent endpoint path.
@RestController
@RequestMapping("api/hello")
class HelloWorldController {
@GetMapping
fun helloWorld(): String {
return "Hey world"
}
}
Note, we can simplify the helloWorld
function by writing it as an expression.
@RestController
@RequestMapping("api/hello")
class HelloWorldController {
@GetMapping
fun helloWorld(): String = "Hey world"
}
- Run your main application and check it all works!
Spring Boot project structure
What is Gradle?
Gradle is a build automation tool for multi-language software development. It controls the development process in the tasks of compilation and packaging to testing, deployment, and publishing. Supported languages include Java, C/C++, and JavaScript.
- The
gradle/wrapper
directory stores information on the Gradle distribution version that will be downloaded for anyone who uses your application. This means that your user does not need to have Gradle already downloaded on their local machine to use your application.- To use the wrapper, run
gradlew build
.
- To use the wrapper, run
settings.gradle.kts
sets up the repositories where Gradle can find the Spring plugins. This is also where the name of your root project is set, when you first initialised the creation of the Sprint Boot project.build.gradle.kts
lists the plugins and dependencies applied to your project (e.g. Spring and Kotlin). It also details the version of Java compatibility and the repository locations for fetching Gradle dependencies.- Jackson is for serialising and deserialising to JSON.
- Spring Boot Starter Test is a testing library that gives us assertions and mocking capabilities.
- The main application files sit within
src/main/kotlin
andsrc/test/kotlin
.- There will be an Application file in the main directory with a
main
function that runs the application.- The
@SpringBootApplication
annotation tells Spring Boot that this is the main application class, and that it should build the context and config starting at this class.
- The
- There will be an ApplicationTest file automatically created in the test directory, with a test to check that your app doesn't crash on load.
- There will be an Application file in the main directory with a
src/main/resources
contains anapplication.properties
file. This is the main entry file for configuring the whole application. e.g. the port the application should run, what Tomcat version to use, default logging levels and other custom configurations.
Setting up the data layer
- Create a folder (aka package) called
model
insrc/main/kotlin/myapplicationmodule
. This will be where we store the data representations in the application. - Create a new "Kotlin class" file for
Game
to store the data transfer object (DTO - the data to be sent over the network).- Remember to use
val
to denote properties that should only have a getter method, andvar
to denote properties that have a setter method as well. - Kotlin defaults class properties to be public (i.e. it exposes the properties getters and setters publicly).
- Think about whether you need secondary constructors. If you just need primary constructors, you can use the Kotlin shorthand.
class Game(val first_prop: String, val second_prop: String)
- Use a
data class
to auto-create standard implementations ofequals
,hashCode
andtoString
(perfect for DTOs).
- Remember to use
- There's no real need to write a test for checking data class properties.
data class Game(
private val name: String
private val console: String
)
Setting up the data source layer
- Create a new package called
datasource
insrc/main/kotlin/myapplicationmodule
. This will be where we store the various data sources we might want to be able to access from a service layer, and store logic for data retrieval and storage. - Create a new file called
GameDataSource
and create an interface with a function to get all the games.
interface GameDataSource {
fun retrieveGames(): Collection<Game>
}
- Create another file called
MockGameDataSource
with a class that implements the interface above.- Use the annotation
@Repository
to mark the class as a Spring Boot bean. This adds it to the application context and tells Spring to initialise this bean / object at runtime. It also allows it to be injected via dependency injection. - Note:
@Repository
also has other special Spring associations that come with it e.g. during testing etc.
- Use the annotation
@Repository
class MockGameDataSource : GameDataSource {
val games = listOf(Game("Call of Duty", "XBox"))
override fun retrieveGames(): Collection<Game> = games
}
- Create a test file in JUnit called
MockGameDataSourceTest
.
internal class MockGameDataSourceTest {
private val mockDataSource = MockGameDataSource()
@Test
fun `should list a collection of games`() {
// when
val games = mockDataSource.retrieveGames()
// then
assertThat(games).isNotEmpty
}
}
Setting up the service layer
- The service layer is the main entry point into your business logic.
- Create a new package called
service
insrc/main/kotlin/myapplicationmodule
. - Create a new Kotlin file called
GameService
. Use the@Service
annotation to mark the class as a bean.- Note that if you want to add a generic bean that isn't a Service or Repository, you can use the
@Component
annotation.
- Note that if you want to add a generic bean that isn't a Service or Repository, you can use the
@Service
class GameService(private val dataSource: GameDataSource) {
fun getGames(): Collection<Game> = dataSource.retrieveGames()
}
- Create a test file called
GameServiceTest
. - Add new library to Gradle dependencies:
testImplementation("io.mockk:mockk:someversionhere")
. Ensure you load the Gradle changes and wait for it to be downloaded (check it appears in your "External Libraries" folder). This allows us to usemockk
for mocking in tests.
internal class GameServiceTest {
private val dataSource: GameDataSource = mockk()
private val gameService = GameService(dataSource)
@Test
fun `should call datasource to retrieve games`() {
// given
every { dataSource.retrieveGames() } returns emptyList()
// when
val games = gameService.getGames()
// then
verify(exactly = 1) { dataSource.retrieveGames() }
}
}
Alternatively, if we don't really care about what the retrieveGames()
dataSource
method returns, we can use the relaxed = true
feature in mockk
.
internal class GameServiceTest {
private val dataSource: GameDataSource = mockk(relaxed = true)
private val gameService = GameService(dataSource)
@Test
fun `should call datasource to retrieve games`() {
// when
gameService.getGames()
// then
verify(exactly = 1) { dataSource.retrieveGames() }
}
}
Setting up the web layer
- The web (or API) layer contains the logic for your controllers and REST mappings.
- Create a new package called
controller
insrc/main/kotlin/myapplicationmodule
. - Create a new Kotlin file called
GameController
. Use the@RestController
annotation and set the API path with the@RequestMapping
annotation.- We can set the
GameService
parameter to be aprivate val
since it is only called in thisGameController
.
- We can set the
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {
@GetMapping
fun getGames(): Collection<Game> = service.getGames()
}
- Create a test file called
GameControllerTest
. Instead of initialising individual classes and testing using an instance, we'll write an integration test for this using the@SpringBootTest
annotation.- Note that this annotation initialises the entire application context, so does take longer to run than a regular unit test. It only initialises our beans, so you'll need to add the
@AutoConfigureMockMvc
annotation as well to ensureMockMvc
beans are also initialised. MockMvc
comes with Spring and allows us to call our endpoints without actually sending a HTTP request.- To tell Spring Boot to give us a bean of this
mockMvc
object, use the@Autowired
annotation. This is Spring Boot's way of injecting the dependency once it's initialised. This is also why we need Kotlin'slateinit var
, so that it knows some kind of framework method is needed to initialise the object, before it can be injected later on.
- Note that this annotation initialises the entire application context, so does take longer to run than a regular unit test. It only initialises our beans, so you'll need to add the
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `should return all games`() {
// when-then
mockMvc.get("/api/games")
.andDo { print() }
.andExpect {
status { isOk() }
content { contentType(MediaType.APPLICATION_JSON) }
jsonPath("$[0].name") { value("Call of Duty") }
}
}
}
- Note
.andDo { print() }
just adds debugging output. The Jackson mapper package is what automatically returns the result asapplication/json
(it does the collection mapping for you).
Adding a GET by path param endpoint
- Add a new
@GetMapping
with a path parameter ofname
. Also add an exception handler to catch theNoSuchElementException
(which is raised when the repository cannot find a game).
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {
@ExceptionHandler(NoSuchElementException::class)
fun notFound(e: NoSuchElementException): ResponseEntity<String> =
ResponseEntity(e.message, HttpStatus.NOT_FOUND)
@GetMapping("/{name}")
fun getGame(@PathVariable name: String): Game = service.getGame()
}
- Amend the repository file and the service file.
interface GameDataSource {
fun retrieveGame(): Game
}
@Repository
class MockGameDataSource : GameDataSource {
val games = listOf(Game("Call of Duty", "XBox"))
override fun retrieveGame(name: String): Game =
games.firstOrNull() { it.name == name}
?: throw NoSuchElementException("Could not find game with name $name")
}
@Service
class GameService(private val dataSource: GameDataSource) {
fun getGame(name: String): Game = dataSource.retrieveGame(name)
}
- Note: We can use the
@Nested
annotation to add scope to our tests. e.g.
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Nested
@DisplayName("getGames()")
@TestInstance(Lifecycle.PER_CLASS)
inner class GetGames {
@Test
fun `should return all games`() {
// when-then
mockMvc.get("/api/games")
.andDo { print() }
.andExpect {
status { isOk() }
content { contentType(MediaType.APPLICATION_JSON) }
jsonPath("$[0].name") { value("Call of Duty") }
}
}
}
@Nested
@DisplayName("getGame()")
@TestInstance(Lifecycle.PER_CLASS)
inner class GetGame {
@Test
fun `should return the right game`() {
// test...
}
}
}
Adding a POST endpoint
- Add a test in
GameControllerTest
file.- Note: instead of using
@Autowired
lateinit
variables, we can move them to the constructor (and amend them to beval
). ObjectMapper
comes from the Jackson package and is used for (de)serialising JSON.
- Note: instead of using
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest @Autowired constructor(
val mockMvc: MockMvc,
val objectMapper: ObjectMapper
) {
// ... other tests
@Nested
@DisplayName("POST /api/games")
@TestInstance(Lifecycle.PER_CLASS)
inner class PostNewGame {
@Test
fun `should add new game`() {
// given
val newGame = Game("God of War", "PS5")
// when
val postResponse = mockMvc.post(baseUrl) {
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(newGame)
}
// then
postResponse
.andDo { print() }
.andExpect {
status { isCreated() }
content { contentType(MediaType.APPLICATION_JSON) }
jsonPath("$.name") { value("God of War") }
jsonPath("$.console") { value("PS5") }
}
}
}
}
- Amend the
GameController
. Use the@RequestBody
annotation to get thegame
from the payload.
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {
@ExceptionHandler(NoSuchElementException::class)
fun notFound(e: NoSuchElementException): ResponseEntity<String> =
ResponseEntity(e.message, HttpStatus.NOT_FOUND)
// ... other endpoints
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun addGame(@RequestBody game: Game): Game = service.addGame(game)
}
- Add the
addGame
method to theGameService
andcreateGame
function in theGameDataSource
interface.
@Service
class GameService(private val dataSource: GameDataSource) {
// other methods
fun addGame(game: Game): Game { dataSource.createGame(game) }
}
interface GameDataSource {
fun createGame(game: Game): Game
}
- In our mock, we need to amend the
listof
to becomemutableListOf
to allow us to add the new game to the list.
@Repository
class MockGameDataSource : GameDataSource {
val games = mutableListOf(Game("Call of Duty", "XBox"))
override fun createGame(game: Game): Game =
games.add(game)
return game
}
Adding a PATCH endpoint
- Start by amending the test file again. Note the amendment in content json assertion (a slightly cleaner version rather than asserting each individual value separately).
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest @Autowired constructor(
val mockMvc: MockMvc,
val objectMapper: ObjectMapper
) {
// ... other tests
@Nested
@DisplayName("PATCH /api/games")
@TestInstance(Lifecycle.PER_CLASS)
inner class PatchExistingGame {
@Test
fun `should update existing game`() {
// given
val updatedGame = Game("God of War", "XBox")
// when
val patchResponse = mockMvc.patch(baseUrl) {
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(updatedGame)
}
// then
patchResponse
.andDo { print() }
.andExpect {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
json(objectMapper.writeValueAsString(updatedGame))
}
}
mockMvc.get("$baseUrl/${updatedGame.name}")
.andExpect {
content {
json(objectMapper.writeValueAsString(updatedGame))
}
}
}
}
}
- Amend the
GameController
.
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {
@ExceptionHandler(NoSuchElementException::class)
fun notFound(e: NoSuchElementException): ResponseEntity<String> =
ResponseEntity(e.message, HttpStatus.NOT_FOUND)
// ... other endpoints
@PatchMapping
fun updateGame(@RequestBody game: Game): Game = service.updateGame(game)
}
- Add the
updateGame
method to theGameService
andGameDataSource
interface.
@Service
class GameService(private val dataSource: GameDataSource) {
// other methods
fun updateGame(game: Game): Game { dataSource.updateGame(game) }
}
interface GameDataSource {
fun updateGame(game: Game): Game
}
- Finally, amend the
MockGameDataSource
for theupdateGame
method.
@Repository
class MockGameDataSource : GameDataSource {
val games = mutableListOf(Game("Call of Duty", "XBox"))
override fun updateGame(game: Game): Game =
val currentGame = games.firstOrNull { it.name == game.name }
?: throw NoSuchElementException ("Count not find a game with name ${game.name}")
games.remove(currentGame)
games.add(game)
return game
}