Codersee

Reactive REST API With Spring, Kotlin, and Coroutines

Image is a featured image for article titled " Reactive REST API With Spring, Kotlin and Coroutines" and contains Spring WebFlux logo in the foreground and people sitting in the office near computers in the background.

1. Introduction

Hello friend! In this, practical step-by-step guide, I will teach you how to create a reactive REST API using Spring, Kotlin, coroutines, and Kotlin Flows entirely from scratch.

And although Spring internally uses Reactor implementation, coroutines provide a more straightforward and natural way of writing asynchronous, non-blocking code. Thanks to this, we can enjoy the benefits of a non-blocking code without compromising the readability of the code (which might become an issue when using Project Reactor in more mature and complex projects).

At the end of this tutorial, you will know precisely how to:

  • set up a Spring Boot 3 project to work with Kotlin coroutines,
  • run a PostgreSQL instance using Docker,
  • create an example database and a schema,
  • query and persist data using R2DBC and CoroutineCrudRepository,
  • expose reactive REST API with coroutines and Kotlin Flows.

So, without any further ado, let’s get to work šŸ™‚

Video Tutorial

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

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

2. PostgreSQL DB

As the first step, let’s learn how to set up a fresh PostgreSQL instance using Docker and populate it with the necessary data. Of course, feel free to skip step 2.1 if you already have some development database installed.

2.1 Start a Postgres Instance

In order to start a fresh instance, let’s navigate to the terminal and specify the following command:

docker run --name some-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres

After that, let’s wait a while until the database is up and running.

We can additionally verify if that’s the case using docker ps:

CONTAINER ID IMAGE    COMMAND                CREATED     STATUS           PORTS                  NAMES
1863e6ec964a postgres "docker-entrypoint.sā€¦" 6 hours ago Up About an hour 0.0.0.0:5432->5432/tcp some-postgres

This one, simple command results in a couple of interesting things happening underneath:

  • firstly, it creates a new container named some-postgres and exposes its port 5432 to 5432 of the host machine (localhost in most cases),
  • secondly, it creates a default Postgres user called postgres and sets its password using the POSTGRES_PASSWORD environment value (of course, we can create another user using the POSTGRES_USER environment variable),
  • and lastly, it starts in a detached mode thanks to the -d flag.

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

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

2.2 Create And Populate The Database

As the next step, let’s connect to the database, and create a schema with two tables:

create schema if not exists application;

create table if not exists application.company(
  id serial not null primary key,
  name varchar(255) not null,
  address varchar(255) not null
);

create table if not exists application.app_user(
  id serial not null primary key,
  email varchar(255) not null unique,
  name varchar(255) not null,
  age int not null, 
  company_id bigint not null references application.company(id) on delete cascade
);

As we can see, the purpose of our application will be users and companies management. Each user will have to be assigned to some company. Moreover, if a company is deleted, then the related user rows will be removed, as well.

Additionally, we can populate tables with the following script:

insert into application.company(name, address) values
  ('Company 1', 'Address 1'),
  ('Company 2', 'Address 2'),
  ('Company 3', 'Address 3');

insert into application.app_user(email, name, age, company_id) values
  ('email-1@codersee.com', 'John', 23, 1),
  ('email-2@codersee.com', 'Adam', 33, 1),
  ('email-3@codersee.com', 'Maria', 40, 2),
  ('email-4@codersee.com', 'James', 39, 3),
  ('email-5@codersee.com', 'Robert', 41, 3),
  ('email-6@codersee.com', 'Piotr', 28, 3);

3. Generate New Project

With that being done, let’s navigate to the Spring Initializr page and generate a new project:

Image is a screenshot from the Spring Initializr page showing necessary configuration to create a Spring Boot 3 Kotlin coroutines project.

The above configuration is all we need in order to create a fresh Spring Boot 3 project with Kotlin and coroutines. Additionally, in order to connect to the Postgres database, we need two more dependencies: Spring Data R2DBC and PostgreSQL Driver.

With that being done, let’s hit the Generate button and import the project to our IDE (you can find a video on how to configure IntelliJ IDEA for Kotlin right here).

4. Configure R2DBC Connection

Nextly, let’s open up the application.properties file, change its extension to .yaml, and insert the connection config:

spring:
  r2dbc:
    url: r2dbc:postgresql://${DB_HOST:localhost}:5432/
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}

This configuration instructs Spring to check DB_HOST, DB_USERNAME, and DB_PASSWORD environment variables first. If a particular variable is missing, then we provide the default values– localhost and postgres.

5. Create Models

Following, let’s create a new package called model and introduce classes responsible for mapping database tables.

As the first one, let’s implement the Company:

@Table("application.company")
data class Company(
    @Id val id: Long? = null,
    val name: String,
    val address: String
)

The @Table and @Id annotations are pretty descriptive and they are necessary in order to configure mapping in Spring. Nevertheless, it’s worth mentioning that if we do not want to generate identifiers manually, then the identifier fields have to be nullable.

Similarly, let’s create the User data class:

@Table("application.app_user")
data class User(
    @Id val id: Long? = null,
    val email: String,
    val name: String,
    val age: Int,
    val companyId: Long
)

6. CRUD operations using Kotlin Coroutines

Moving forward, let’s create the repository package.

In our project, we will utilize the CoroutineCrudRepository– a dedicated Spring Data repository built on Kotlin coroutines. If you’ve ever been working with Reactor, then in a nutshell, Mono<T> functions are replaced with suspended functions returning the type T, and instead of creating Fluxes, we will generate Flows. On the other hand, if you have never worked with Reactor, then Flow<T> return type means that a function returns multiple asynchronously computed values suspend function returns only a single value.

Of course, this is a simplification and if you would like to learn the differences between Fluxes and Flows, then let me know in the comments.

6.1 Create UserRepository

To kick things off, let’s implement the UserRepository interface:

interface UserRepository : CoroutineCrudRepository<User, Long> {
    
  fun findByNameContaining(name: String): Flow<User>
    
  fun findByCompanyId(companyId: Long): Flow<User>

  @Query("SELECT * FROM application.app_user WHERE email = :email")
  fun randomNameFindByEmail(email: String): Flow<User>
}

The CoroutineCrudRepository extends the Spring Data Repository and requires us to provide two types: the domain type and the identifier type- a User and a Long in our case. This interface comes with 15 already implemented functions, like save, findAll, delete, etc.- responsible for generic CRUD operations. This way, we can tremendously reduce the amount of boilerplate in our Kotlin codebase.

Moreover, we make use of two, great features of Spring Data (which are not Kotlin, or coroutines specific):

  • Query Methods, which in simple terms allow us to define queries through function names. Just like above- the findByNameContaining will be translated into where like.. query and  findByCompanyId will let us search users by company identifier.
  • @Query, which lets us execute both JPQL and native SQL queries.

Note: I’ve named the third method randomNameFindByEmail just to emphasize, that the function name is irrelevant when using the Query, don’t do that in your codebase šŸ˜€

6.2 Add CompanyRepository

Nextly, let’s add the CompanyRepository with only one custom function:

@Repository
interface CompanyRepository : CoroutineCrudRepository<Company, Long> {
    fun findByNameContaining(name: String): Flow<Company>
}

7. Create Services

With model and repository layer implemented, we can move on and create a service package.

7.1 Implement UserService

Firstly, let’s add the UserService to our project:

@Service
class UserService(
    private val userRepository: UserRepository
) {

    suspend fun saveUser(user: User): User? =
        userRepository.randomNameFindByEmail(user.email)
            .firstOrNull()
            ?.let { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "The specified email is already in use.") }
            ?: userRepository.save(user)

    suspend fun findAllUsers(): Flow<User> =
        userRepository.findAll()

    suspend fun findUserById(id: Long): User? =
        userRepository.findById(id)

    suspend fun deleteUserById(id: Long) {
        val foundUser = userRepository.findById(id)

        if (foundUser == null)
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "User with id $id was not found.")
        else
            userRepository.deleteById(id)
    }

    suspend fun updateUser(id: Long, requestedUser: User): User {
        val foundUser = userRepository.findById(id)

        return if (foundUser == null)
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "User with id $id was not found.")
        else
            userRepository.save(
                requestedUser.copy(id = foundUser.id)
            )
    }

    suspend fun findAllUsersByNameLike(name: String): Flow<User> =
        userRepository.findByNameContaining(name)

    suspend fun findUsersByCompanyId(id: Long): Flow<User> =
        userRepository.findByCompanyId(id)
}

All the magic starts with the @Service annotation, which is a specialization of a @Component. This way, we simply instruct Spring to create a bean of UserService.

As we can clearly see, our service logic is really straightforward, and thanks to the coroutines we can write code similar to imperative programming.

Lastly, I just wanted to mention the logic responsible for User updates. The save method of the Repository interface works in two ways:

  • when the value of a field marked with @Id is set to null, a new entry will be created in the database,
  • nevertheless, if the id is not null, then the row with the specified will be updated.

7.2 Create CompanyService

Following, let’s implement the CompanyService responsible for companies management:

@Component
class CompanyService(
    private val companyRepository: CompanyRepository
) {

    suspend fun saveCompany(company: Company): Company? =
        companyRepository.save(company)

    suspend fun findAllCompanies(): Flow<Company> =
        companyRepository.findAll()

    suspend fun findCompanyById(id: Long): Company? =
        companyRepository.findById(id)

    suspend fun deleteCompanyById(id: Long) {
        val foundCompany = companyRepository.findById(id)

        if (foundCompany == null)
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "Company with id $id was not found.")
        else
            companyRepository.deleteById(id)
    }

    suspend fun findAllCompaniesByNameLike(name: String): Flow<Company> =
        companyRepository.findByNameContaining(name)

    suspend fun updateCompany(id: Long, requestedCompany: Company): Company {
        val foundCompany = companyRepository.findById(id)

        return if (foundCompany == null)
            throw ResponseStatusException(HttpStatus.NOT_FOUND, "Company with id $id was not found.")
        else
            companyRepository.save(
                requestedCompany.copy(id = foundCompany.id)
            )
    }

}

8. Implement Controllers

And the last thing we need to implement in our Spring Kotlin Coroutines project are… REST endpoints (and a couple of DTOs šŸ˜‰ ).

8.1. Create UserResponse and UserRequest

When working in real-life scenarios we can use different approaches, when it comes to serialization and deserialization of data (or in simple terms- JSON <-> Kotlin objects conversions). In some cases dealing with model classes might be sufficient, but introducing DTOs will usually be a better approach. In our examples, we will introduce separate request and response classes, which in my opinion let us maintain our codebase much easier.

To do so, let’s add two data classes to our codebase- the UserRequest and UserResponse (inside the dto package):

data class UserRequest(
    val email: String,
    val name: String,
    val age: Int,
    @JsonProperty("company_id") val companyId: Long
)

data class UserResponse(
    val id: Long,
    val email: String,
    val name: String,
    val age: Int
)

Request classes will be used to translate JSON payload into Kotlin objects, whereas the response ones will do the opposite.

Additionally, we make use of the @JsonProperty annotation, so that our JSON files will use the snake case.

8.2. Implement UserController

With that prepared, we have nothing else to do than create a controller package and implement the UserController:

@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService
) {

    @PostMapping
    suspend fun createUser(@RequestBody userRequest: UserRequest): UserResponse =
        userService.saveUser(
            user = userRequest.toModel()
        )
            ?.toResponse()
            ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error during user creation.")

    @GetMapping
    suspend fun findUsers(
        @RequestParam("name", required = false) name: String?
    ): Flow<UserResponse> {
        val users = name?.let { userService.findAllUsersByNameLike(name) }
            ?: userService.findAllUsers()

        return users.map(User::toResponse)
    }

    @GetMapping("/{id}")
    suspend fun findUserById(
        @PathVariable id: Long
    ): UserResponse =
        userService.findUserById(id)
            ?.let(User::toResponse)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User with id $id was not found.")

    @DeleteMapping("/{id}")
    suspend fun deleteUserById(
        @PathVariable id: Long
    ) {
        userService.deleteUserById(id)
    }

    @PutMapping("/{id}")
    suspend fun updateUser(
        @PathVariable id: Long,
        @RequestBody userRequest: UserRequest
    ): UserResponse =
        userService.updateUser(
            id = id,
            requestedUser = userRequest.toModel()
        )
            .toResponse()
}

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

fun User.toResponse(): UserResponse =
    UserResponse(
        id = this.id!!,
        email = this.email,
        name = this.name,
        age = this.age
    )

I know, the class itself might be a little big, but let’s break it down into parts first. We have a couple of annotations here, so why don’t we start with them?

The @RestController is nothing else, then a combination of a @Controller– informing Spring that our class is a web controller and a @ResponseBody- which indicates that the things our functions return should be bound to the web response body (simply- returned to the API user).

The @RequestMapping allows us to specify the path, to which our class will respond. So, each time we will hit the localhost:8080/api/users, Spring will search for a handler function inside this class.

On the other hand, the @PostMapping, @GetMapping, etc. simply indicate for which HTTP methods a particular function should be invoked (and also can take the additional path segments).

Lastly, the @RequestParam, @PathVariable, and @RequestBody are used to map request parameters, segment paths, and body payload to Kotlin class instances.

The rest of the code is responsible for invoking our service methods and throwing meaningful errors when something is wrong (with a help of extension functions used to map between models and responses).

8.3. Implement CompanyResponse and CompanyRequest

Similarly, let’s add response and request classes for Company resources:

data class CompanyRequest(
    val name: String,
    val address: String
)

data class CompanyResponse(
    val id: Long,
    val name: String,
    val address: String,
    val users: List<UserResponse>
)

8.4. Add CompanyController

And this time, let’s add the CompanyController class:

@RestController
@RequestMapping("/api/companies")
class CompanyController(
    private val companyService: CompanyService,
    private val userService: UserService
) {

    @PostMapping
    suspend fun createCompany(@RequestBody companyRequest: CompanyRequest): CompanyResponse =
        companyService.saveCompany(
            company = companyRequest.toModel()
        )
            ?.toResponse()
            ?: throw ResponseStatusException(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "Unexpected error during company creation."
            )

    @GetMapping
    suspend fun findCompany(
        @RequestParam("name", required = false) name: String?
    ): Flow<CompanyResponse> {
        val companies = name?.let { companyService.findAllCompaniesByNameLike(name) }
            ?: companyService.findAllCompanies()

        return companies
            .map { company ->
                company.toResponse(
                    users = findCompanyUsers(company)
                )
            }
    }


    @GetMapping("/{id}")
    suspend fun findCompanyById(
        @PathVariable id: Long
    ): CompanyResponse =
        companyService.findCompanyById(id)
            ?.let { company ->
                company.toResponse(
                    users = findCompanyUsers(company)
                )
            }
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Company with id $id was not found.")

    @DeleteMapping("/{id}")
    suspend fun deleteCompanyById(
        @PathVariable id: Long
    ) {
        companyService.deleteCompanyById(id)
    }

    @PutMapping("/{id}")
    suspend fun updateCompany(
        @PathVariable id: Long,
        @RequestBody companyRequest: CompanyRequest
    ): CompanyResponse =
        companyService.updateCompany(
            id = id,
            requestedCompany = companyRequest.toModel()
        )
            .let { company ->
                company.toResponse(
                    users = findCompanyUsers(company)
                )
            }

    private suspend fun findCompanyUsers(company: Company) =
        userService.findUsersByCompanyId(company.id!!)
            .toList()
}


private fun CompanyRequest.toModel(): Company =
    Company(
        name = this.name,
        address = this.address
    )

private fun Company.toResponse(users: List<User> = emptyList()): CompanyResponse =
    CompanyResponse(
        id = this.id!!,
        name = this.name,
        address = this.address,
        users = users.map(User::toResponse)
    )

And although this controller class looks similar, I wanted to emphasize two things:

  • Firstly, thanks to coroutines we don’t have to do any sophisticated mapping, zipping, etc. (known from Reactor) in order to fetch users for each company,
  • and secondly- in order to edit Flow elements we use the map intermediate operator, which works just like a map when dealing with collections.

8.5 Create IdNameTypeResponse

As the last thing, I wanted to show you how easily we can merge two Flows. And to do so, let’s introduce a new search endpoint, which will be used to return both users and companies by their names.

So firstly, let’s add the IdNameTypeResponse:

data class IdNameTypeResponse(
    val id: Long,
    val name: String,
    val type: ResultType
)

enum class ResultType {
    USER, COMPANY
}

8.6 Add SearchController

Moving forward, let’s implement the SearchController:

@RestController
@RequestMapping("/api/search")
class SearchController(
  private val userService: UserService,
  private val companyService: CompanyService
) {

  @GetMapping
  suspend fun searchByNames(
    @RequestParam(name = "query") query: String
  ): Flow<IdNameTypeResponse> {
    val usersFlow = userService.findAllUsersByNameLike(name = query)
      .map(User::toIdNameTypeResponse)
    val companiesFlow = companyService.findAllCompaniesByNameLike(name = query)
      .map(Company::toIdNameTypeResponse)

    return merge(usersFlow, companiesFlow)
  }
}

  private fun User.toIdNameTypeResponse(): IdNameTypeResponse =
    IdNameTypeResponse(
      id = this.id!!,
      name = this.name,
      type = ResultType.USER
    )

  private fun Company.toIdNameTypeResponse(): IdNameTypeResponse =
    IdNameTypeResponse(
      id = this.id!!,
      name = this.name,
      type = ResultType.COMPANY
    )

As we can see, in order to combine together both user and company results we can use the merge function. This way, our flows are merged concurrently (and without preserving the order), without limit on the number of simultaneously collected flows.

9. Testing

At this point, we have everything we need to start testing, so let it be your homework. Going through all of the handler methods and preparing appropriate requests will be a great opportunity to recap everything we learned today šŸ™‚

As a bonus- right here you can find a ready-to-go Postman collection, which you can import to your computer.

10. REST API with Spring, Kotlin, coroutines, and Kotlin Flows Summary

And that’s all for this hands-on tutorial, in which we’ve learned together how to create a REST API using Spring, Kotlin, coroutines, and Kotlin Flows. As always, you can find the whole project in this GitHub repository.

If you’re interested in learning more about the reactive approach, then check out my other materials in the Spring Webflux tag.

I hope you enjoyed this article and will be forever thankful for your feedback in the comments section šŸ™‚

Share this:

Hi there! šŸ‘‹

Hi there! šŸ‘‹

My name is Piotr and I've created Codersee to share my knowledge about Kotlin, Spring Framework, and other related topics through practical, step-by-step guides. Always eager to chat and exchange knowledge.

Related content

Leave a Reply

Your email address will not be published. Required fields are marked *

Newsletter

Join our community and get

3 free ebooks

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

Zero fluff.

100% practical knowledge

Image presents a Kotlin Course box mockup for "Kotlin Handbook. Learn Through Practice"
Image is the logo of the Kotlin course and consist of Kotlin logo with a PC in the background and the author- Piotr Wolak
EVERYTHING YOU NEED TO KNOW ABOUT KOTLIN FOR

0,39$

per lesson!

To make Codersee work, we log user data. By using our site, you agree to our Privacy Policy and Terms of Use.