Spring Boot 3 With Kotlin DSL. REST API Without Annotations

In this step-by-step guide, we will learn how to expose a REST API using Spring Boot 3, Kotlin, bean definition, and router DSL.
The image is a featured image for a blog post titled "Spring Boot 3 With Kotlin DSL. REST API Without Annotations" and consists of a Spring Boot logo and a blurred photo of a desk with monitor.

1. Introduction

Hello ! ๐Ÿ™‚ Continuing the DSL topic, I would like to show you how to expose a REST API using Spring Boot 3, Kotlin, bean definition, and router DSL.

In my previous article, I showed you how the router DSL works and how to expose endpoints manually without needing any web-related annotations, like @RequestMapping, @RestController, @RequestBody, etc…

But is it possible to eliminate the annotations when defining beans? Yes, and in this article, I will prove to you that it’s possible to expose a REST API without any annotations, like @Component or any specialization.

Video Tutorial

If you prefer video content, then check out my video:

If you find this content useful, please leave a subscription  ๐Ÿ˜‰

2. Project Setup

Firstly, let’s navigate to https://start.spring.io/ and generate a new project:

The image is a screenshot from the start.spring.io page, which shows the setting necessary in order to create a Spring Boot 3 project with Kotlin DSL.

As we can see, in order to expose a REST API with Spring Boot 3 and Kotlin DSL, we don’t have to add any additional dependencies. The only one is the Spring Web, which lets us build web applications.

Following, please import the project to your favorite IDE (like IntelliJ).

3. Add Models

Nextly, let’s create the model package and add 3 data classes to our application:

  • User, which will transfer the data between the app and the data source,
  • UserDTO, which will be serialized to JSON responses,
  • and ErrorResponse, shipping information about backend errors.
// User.kt
data class User(
  val id: Long? = null,
  val email: String,
  val name: String,
  val age: Int
)

// UserDTO.kt
data class UserDTO(
  val email: String,
  val name: String,
  val age: Int
)

// ErrorResponse.kt
data class ErrorResponse(
  val message: String
)

4. Implement a Repository

As the next step, let’s create a repository package.

Make a real progress thanks to practical examples, exercises, and quizzes.

Image presents a Kotlin Course box mockup for "Kotlin Handbook. Learn Through Practice"

4.1 Add UserRepository Interface

Following, let’s introduce a UserRepository:

interface UserRepository {
  fun create(user: User): User
  fun findAll(): List<User>
  fun findById(id: Long): User?
  fun updateById(id: Long, user: User): User?
  fun deleteById(id: Long)
}

And although oftentimes I skip this part for the simplicity of my tutorials, you will later see that working with interfaces, rather than implementations gives us much more flexibility. And in real-life scenarios, we should consider using them.

4.2 Add Implementation

So nextly, let’s add the UserCrudRepository to the codebase:

class UserCrudRepository(
  private val dataSource: MutableMap<Long, User>
) : UserRepository {

  override fun create(user: User): User {
    val lastId = this.dataSource.keys.max()
    val incrementedId = lastId + 1
    val updatedUser = user.copy(id = incrementedId)

    this.dataSource[incrementedId] = updatedUser

    return updatedUser
  }

  override fun findAll(): List<User> =
    this.dataSource.values
      .toList()

  override fun findById(id: Long): User? =
    this.dataSource[id]

  override fun updateById(id: Long, user: User): User? =
    this.dataSource[id]
      ?.let { foundUser -> 
        val updatedUser = user.copy(id = foundUser.id)
        this.dataSource[id] = updatedUser
        updatedUser
      }

  override fun deleteById(id: Long) {
    this.dataSource.remove(id)
  }
}

As we can see, this class is just a dummy in-memory database, which will be operating on the dataSource map provided as a constructor argument.

4.3 Implement DataSource

With that being done, let’s create a new object called DataSource (can go to the config package):

object DataSource {

  val devDataSource: MutableMap<Long, User> = mutableMapOf(
    1L to User(1L, "email-1@gmail.com", "Name 1", 22),
    2L to User(2L, "email-2@gmail.com", "Name 2", 43),
    3L to User(3L, "email-3@gmail.com", "Name 3", 26),
    4L to User(4L, "email-4@gmail.com", "Name 4", 50)
  )

  val prodDataSource: MutableMap<Long, User> = mutableMapOf(
    1L to User(1L, "prod-email-1@gmail.com", "Name 1", 22),
    2L to User(2L, "prod-email-2@gmail.com", "Name 2", 43),
    3L to User(3L, "prod-email-3@gmail.com", "Name 3", 26),
    4L to User(4L, "prod-email-4@gmail.com", "Name 4", 50)
  )

}

Well, in our application we will be dealing with dummy data.

However, in real life instead of two maps implementations, we would rather have a few implementations of UserRepository, for example:

  • one, in-memory, which could be used for local development and testing,
  • the second, “real”, one, responsible for database connection.

Either way, the purpose of this tutorial is to learn a bit more about Kotlin with bean definition DSL, and this setup will be useful in our considerations.

5. Configure Routing

In order to expose REST endpoints with Kotlin route DSL, we need to add two things:

  • UserHandler– which normally would be a UserController when working with annotations,
  • custom RouteFunctionDSL– responsible for URL mappings.

5.1 Add UserHandler

Firstly, let’s add a handler package and implement the UserHandler:

class UserHandler(
  private val userRepository: UserRepository
) {

  fun createUser(
    request: ServerRequest
  ): ServerResponse {
    val userRequest = request.body(UserDTO::class.java)

    val createdUserResponse = userRepository.create(
      user = userRequest.toModel()
    )
      .toDTO()

    return ServerResponse.ok()
      .body(createdUserResponse)
  }

  fun findAllUsers(
    request: ServerRequest
  ): ServerResponse {
    val usersResponses = userRepository.findAll()
      .map(User::toDTO)

    return ServerResponse.ok()
      .body(usersResponses)
  }

  fun findUserById(
    request: ServerRequest
  ): ServerResponse {
    val id = request.pathVariable("id")
      .toLongOrNull()
      ?: return badRequestResponse("Invalid id")

    val userResponse = userRepository.findById(id)
      ?.toDTO()

    return userResponse
      ?.let { response ->
        ServerResponse.ok()
          .body(response)
      }
      ?: notFoundResponse(id)
  }

  fun updateUserById(
    request: ServerRequest
  ): ServerResponse {
    val id = request.pathVariable("id")
      .toLongOrNull()
      ?: return badRequestResponse("Invalid id")

    val userRequest = request.body(UserDTO::class.java)

    val updatedUser = userRepository.updateById(
      id = id,
      user = userRequest.toModel()
    )

    return updatedUser
      ?.let { response ->
        ServerResponse.ok()
          .body(response)
      }
      ?: notFoundResponse(id)
  }

  fun deleteUserById(
    request: ServerRequest
  ): ServerResponse {
    val id = request.pathVariable("id")
      .toLongOrNull()
      ?: return badRequestResponse("Invalid id")

    userRepository.deleteById(id)

    return ServerResponse.noContent()
      .build()
  }

  private fun badRequestResponse(reason: String): ServerResponse =
    ServerResponse.badRequest()
      .body(
        ErrorResponse(reason)
      )

  private fun notFoundResponse(id: Long): ServerResponse =
    ServerResponse.badRequest()
      .body(
        ErrorResponse("User with id: $id was not found.")
      )

}

private fun UserDTO.toModel(): User =
  User(
    email = this.email,
    name = this.name,
    age = this.age
  )

private fun User.toDTO(): UserDTO =
  UserDTO(
    email = this.email,
    name = this.name,
    age = this.age
  )

As we can see, the handler has only one parameter of the UserRepository  type. Thanks do that, we are not coupled with any implementation. Moreover, we gained flexibility and we can provide the implementation even on the fly when configuring beans.

Another interesting thing is the usage of ServerRequests and ServerResponses. When working with Spring Boot and Kotlin routes DSL, that’s how we deal with incoming requests and responses. And although for more details I redirect you to my previous article about this topic, I wanted to mention this because of one thing. As you can see- instead of throwing exceptions, which then Spring would translate to HTTP responses, we have the possibility to return our custom payload (and status codes, as well).

5.2 Implement Routing

Nevertheless, handler implementation is not sufficient. We have to instruct Spring about how HTTP requests should be handled.

To do so, let’s add the Routes.kt file inside the config package:

fun appRouter(userHandler: UserHandler) = router {
  "/api".nest {
    "/users".nest {
      POST(userHandler::createUser)
      GET(userHandler::findAllUsers)
      GET("/{id}", userHandler::findUserById)
      PUT("/{id}", userHandler::updateUserById)
      DELETE("/{id}", userHandler::deleteUserById)
    }
  }
}

The appRouter function has one parameter of the UserHandler type, which we then use to provide references to functions inside it.

It’s worth mentioning that function references in Kotlin are an interesting syntactic sugar. However, if you prefer not to use them, you can always simply make use of brackets:

POST { request -> userHandler.createUser(request) }

6. Kotlin Bean Definition DSL

With all of that being done, we can finally learn how the Kotlin bean definition DSL work with Spring Boot 3.

6.1 Implement BeansConfig

Firstly, let’s add a new file called BeansConfig.kt inside the config package:

val beans = beans {
  // beans definitions
}

The beans {} is nothing else than a function, which leverages the concept of type-safe builders.

Secondly, let’s add the BeansConfig class:

class BeansConfig : ApplicationContextInitializer<GenericApplicationContext> {

  override fun initialize(context: GenericApplicationContext) =
    beans.initialize(context)

}

In order to register the beans in our application context, we have to invoke the initialize().

Lastly, we need to make Spring aware of our initializer in application.yaml:

context:
  initializer:
    classes: com.codersee.kotlindsl.config.BeansConfig

6.2 Autowiring By Type

As the next step, let’s see how we can define UserHandler and router beans and instruct Spring to autowire by type:

bean<UserHandler>()
bean(::appRouter)

As we can see, we don’t even have to specify the types of particular parameters and Spring will take care of that automatically.

Moreover, with callable reference, we can define a bean with the appRouter top-level function.

6.3 Explicitly Specify Bean Type

Of course, if we would like to specify the type of the bean manually or wire by name, then we can do it, as well:

bean("myHandlerBean") {
  UserHandler(ref())
}
bean {
  appRouter(
    ref("myHandlerBean")
  )
}

And this time we simply make use of the ref function, which is responsible for getting a reference to beans by type (line 2), or by type and name (line 6).

6.4 Conditional Beans Based On Profile

Following, let’s see how we can differentiate the data source based on the profile property:

profile("dev") {
  bean {
    UserCrudRepository(devDataSource)
  }
}

profile("prod") {
  bean {
    UserCrudRepository(prodDataSource)
  }
}

With this approach, beans defined inside the profile() won’t be created unless the specified profile is active.

Of course, we can activate profiles, for example inside the application.yaml:

spring:
  profiles:
    active: dev

6.5 Define Additional Beans Properties

Following, let’s see how we can customize bean definitions:

bean(
  name = "userHandler",
  scope = BeanDefinitionDsl.Scope.SINGLETON,
  isLazyInit = true,
  isPrimary = true,
  isAutowireCandidate = true,
  initMethodName = "",
  destroyMethodName = "",
  description = "description",
  role = BeanDefinitionDsl.Role.APPLICATION
)

As we can see, all parameters of the bean function have default values assigned.

Nevertheless, if we would like to set any values explicitly, then we can do it with ease.

6.6 Reading Environment Variables

As the last thing, let’s see how we can read environment variables:

val someVariable = env.systemEnvironment["SOME_VARIABLE"]

The systemEnvironment is nothing else than a Map<String, Object> instance, which is a result of System.getenv() invocation.

7. Spring Boot 3 Kotlin DSL Summary

And that’s all for this tutorial about how to implement a REST API without annotations using Spring Boot 3, Kotlin bean definition, and router DSL.

I’m happy to hear your feedback– trust me, such a simple comment can motivate a lot and help me to work on my weaknesses ๐Ÿ™‚

Of course, if you would like to see the source code for this article, then you can find it in this GitHub repository.

Share this:

Related content

Newsletter
Image presents 3 ebooks with Java, Spring and Kotlin interview questions.

Never miss any important updates from the Kotlin world and get 3 ebooks!

You may opt out any time. Terms of Use and Privacy Policy