Reactive Spring Boot REST API CRUD with Kotlin and MongoDB

1. Introduction

2. Run MongoDB Server

2.1. Deploy MongoDB as a Container

docker pull mongo
docker run -d -p 27017-27019:27017-27019 --name mongodb mongo
docker ps

#command output
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
26bd5c8dcc79 mongo "docker-entrypoint.s…" 6 seconds ago Up 5 seconds 0.0.0.0:27017-27019->27017-27019/tcp mongodb

3. Imports

implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
testImplementation("io.projectreactor:reactor-test")

4. Configure Application Properties

server:
error:
include-message: always

5. Create Models

@Document("companies")
data class Company(
@Id
val id: String? = null,
var name: String,
@Field("company_address")
var address: String
)
data class Employee(
@Id
val id: ObjectId? = null,
var firstName: String,
var lastName: String,
var email: String,
var company: Company?
)

5.1. What is ObjectId?

6. Add Custom Exception

@ResponseStatus(HttpStatus.NOT_FOUND)
class NotFoundException(msg: String) : RuntimeException(msg)

7. Create Repositories

interface CompanyRepository : ReactiveMongoRepository<Company, String>
interface EmployeeRepository : ReactiveMongoRepository<Employee, ObjectId> {
fun findByCompanyId(id: String): Flux<Employee>
}

8. Create Services

8.1. Implement CompanyService

class CompanyRequest(
val name: String,
val address: String
)
@Service
class CompanyService(
private val companyRepository: CompanyRepository,
private val employeeRepository: EmployeeRepository
) {

fun createCompany(request: CompanyRequest): Mono<Company> =
companyRepository.save(
Company(
name = request.name,
address = request.address
)
)

fun findAll(): Flux<Company> =
companyRepository.findAll()

fun findById(id: String): Mono<Company> =
companyRepository.findById(id)
.switchIfEmpty {
Mono.error(
NotFoundException("Company with id $id not found")
)
}

fun updateCompany(id: String, request: CompanyRequest): Mono<Company> =
findById(id)
.flatMap {
companyRepository.save(
it.apply {
name = request.name
address = request.address
}
)
}
.doOnSuccess { updateCompanyEmployees(it).subscribe() }

fun deleteById(id: String): Mono<Void> =
findById(id)
.flatMap(companyRepository::delete)

private fun updateCompanyEmployees(updatedCompany: Company): Flux<Employee> =
employeeRepository.saveAll(
employeeRepository.findByCompanyId(updatedCompany.id!!)
.map { it.apply { company = updatedCompany } }
)
}
fun createCompany(request: CompanyRequest): Mono<Company> =
companyRepository.save(
Company(
name = request.name,
address = request.address
)
)

fun findAll(): Flux<Company> =
companyRepository.findAll()
fun findById(id: String): Mono<Company> =
companyRepository.findById(id)
.switchIfEmpty {
Mono.error(
NotFoundException("Company with id $id not found")
)
}
fun updateCompany(id: String, request: CompanyRequest): Mono<Company> =
findById(id)
.flatMap {
companyRepository.save(
it.apply {
name = request.name
address = request.address
}
)
}
.doOnSuccess { updateCompanyEmployees(it).subscribe() }

private fun updateCompanyEmployees(updatedCompany: Company): Flux<Employee> =
employeeRepository.saveAll(
employeeRepository.findByCompanyId(updatedCompany.id!!)
.map { it.apply { company = updatedCompany } }
)

8.2. MongoDB Relations

{
"firstName": "Piotr",
"lastName": "Wolak",
"email": "contact@codersee.com",
"company": {
"_id": {
"$oid": "5fcb90f830e3af4497f5de14"
},
"name": "Company Two",
"company_address": "Address Two"
},
"_class": "com.codersee.mongocrud.model.Employee"
}
fun deleteById(id: String): Mono<Void> =
findById(id)
.flatMap(companyRepository::delete)

8.3. Create EmployeeService

class EmployeeRequest(
val firstName: String,
val lastName: String,
val email: String,
val companyId: String?
)
@Service
class EmployeeService(
private val companyService: CompanyService,
private val employeeRepository: EmployeeRepository
) {

fun createEmployee(request: EmployeeRequest): Mono<Employee> {
val companyId = request.companyId

return if (companyId == null) {
createEmployeeWithoutCompany(request)
} else {
createEmployeeWithCompany(companyId, request)
}
}

private fun createEmployeeWithoutCompany(request: EmployeeRequest): Mono<Employee> {
return employeeRepository.save(
Employee(
firstName = request.firstName,
lastName = request.lastName,
email = request.email,
company = null
)
)
}

private fun createEmployeeWithCompany(companyId: String, request: EmployeeRequest) =
companyService.findById(companyId)
.flatMap {
employeeRepository.save(
Employee(
firstName = request.firstName,
lastName = request.lastName,
email = request.email,
company = it
)
)
}

fun findAll(): Flux<Employee> =
employeeRepository.findAll()

fun findAllByCompanyId(id: String): Flux<Employee> =
employeeRepository.findByCompanyId(id)

fun findById(id: ObjectId): Mono<Employee> =
employeeRepository.findById(id)
.switchIfEmpty {
Mono.error(
NotFoundException("Employee with id $id not found")
)
}

fun updateEmployee(id: ObjectId, request: EmployeeRequest): Mono<Employee> {
val employeeToUpdate = findById(id)

val companyId = request.companyId
return if (companyId == null) {
updateEmployeeWithoutCompany(employeeToUpdate, request)
} else {
updateEmployeeWithCompany(companyId, employeeToUpdate, request)
}
}

private fun updateEmployeeWithoutCompany(employeeToUpdate: Mono, request: EmployeeRequest) =
employeeToUpdate.flatMap {
employeeRepository.save(
it.apply {
firstName = request.firstName
lastName = request.lastName
email = request.email
company = null
}
)
}

private fun updateEmployeeWithCompany(
companyId: String,
employeeToUpdate: Mono,
request: EmployeeRequest
) =
companyService.findById(companyId)
.zipWith(employeeToUpdate)
.flatMap {
employeeRepository.save(
it.t2.apply {
firstName = request.firstName
lastName = request.lastName
email = request.email
company = it.t1
}
)
}

fun deleteById(id: ObjectId): Mono<Employee> {
return findById(id)
.flatMap(employeeRepository::delete)
}
}
private fun updateEmployeeWithCompany(
companyId: String,
employeeToUpdate: Mono<Employee>,
request: EmployeeRequest
) =
companyService.findById(companyId)
.zipWith(employeeToUpdate)
.flatMap {
employeeRepository.save(
it.t2.apply {
firstName = request.firstName
lastName = request.lastName
email = request.email
company = it.t1
}
)
}

9. Implement REST Controllers

class CompanyResponse(
val id: String,
val name: String,
val address: String
) {
companion object {
fun fromEntity(company: Company): CompanyResponse =
CompanyResponse(
id = company.id!!,
name = company.name,
address = company.address
)
}
}
class EmployeeResponse(
val id: String,
val firstName: String,
val lastName: String,
val email: String,
val company: CompanyResponse?
) {
companion object {
fun fromEntity(employee: Employee): EmployeeResponse =
EmployeeResponse(
id = employee.id!!.toHexString(),
firstName = employee.firstName,
lastName = employee.lastName,
email = employee.email,
company = employee.company?.let { CompanyResponse.fromEntity(it) }
)
}
}

9.1. Create CompanyController

@RestController
@RequestMapping("/api/company")
class CompanyController(
private val companyService: CompanyService
) {

@PostMapping
fun createCompany(@RequestBody request: CompanyRequest): Mono<CompanyResponse> {
return companyService.createCompany(request)
.map { CompanyResponse.fromEntity(it) }
}

@GetMapping
fun findAllCompanies(): Flux<CompanyResponse> {
return companyService.findAll()
.map { CompanyResponse.fromEntity(it) }
.delayElements(Duration.ofSeconds(2))
}

@GetMapping("/{id}")
fun findCompanyById(@PathVariable id: String): Mono<CompanyResponse> {
return companyService.findById(id)
.map { CompanyResponse.fromEntity(it) }
}

@PutMapping("/{id}")
fun updateCompany(
@PathVariable id: String,
@RequestBody request: CompanyRequest
): Mono<CompanyResponse> {
return companyService.updateCompany(id, request)
.map { CompanyResponse.fromEntity(it) }
}

@DeleteMapping("/{id}")
fun deleteCompany(@PathVariable id: String): Mono<Void> {
return companyService.deleteById(id)
}
}

9.2. Create EmployeeController

@RestController
@RequestMapping("/api/employee")
class EmployeeController(
private val employeeService: EmployeeService
) {

@PostMapping
fun createEmployee(@RequestBody request: EmployeeRequest): Mono<EmployeeResponse> {
return employeeService.createEmployee(request)
.map { EmployeeResponse.fromEntity(it) }
}

@GetMapping
fun findAllEmployees(): Flux<EmployeeResponse> {
return employeeService.findAll()
.map { EmployeeResponse.fromEntity(it) }
}

@GetMapping("/{id}")
fun findEmployeeById(@PathVariable id: ObjectId): Mono<EmployeeResponse> {
return employeeService.findById(id)
.map { EmployeeResponse.fromEntity(it) }
}

@GetMapping("/company/{companyId}")
fun findAllByCompanyId(@PathVariable companyId: String): Flux<EmployeeResponse> {
return employeeService.findAllByCompanyId(companyId)
.map { EmployeeResponse.fromEntity(it) }
}

@PutMapping("/{id}")
fun updateUpdateEmployee(
@PathVariable id: ObjectId,
@RequestBody request: EmployeeRequest
): Mono<EmployeeResponse> {
return employeeService.updateEmployee(id, request)
.map { EmployeeResponse.fromEntity(it) }
}

@DeleteMapping("/{id}")
fun deleteEmployee(@PathVariable id: ObjectId): Mono<Void> {
return employeeService.deleteById(id)
}
}

10. Test with CURL

# 1st company
curl --location --request POST 'localhost:8080/api/company' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Company 1",
"address": "Address 1"
}'

# 2nd company
curl --location --request POST 'localhost:8080/api/company' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Company 2",
"address": "Address 2"
}'
curl 'localhost:8080/api/company'

# Example output
[
{
"id": "5fcba07a30e3af4497f5de16",
"name": "Company 1",
"address": "Address 1"
},
{
"id": "5fcba07c30e3af4497f5de17",
"name": "Company 2",
"address": "Address 2"
}
]
curl http://localhost:8080/api/company -H "Accept: text/event-stream"

# Example output
data:{"id":"5fcba07a30e3af4497f5de16","name":"Company 1","address":"Address 1"}

data:{"id":"5fcba07c30e3af4497f5de17","name":"Company 2","address":"Address 2"}
curl --location --request GET 'localhost:8080/api/company/5fcba07c30e3af4497f5de17'

# Expected output
{
"id": "5fcba07c30e3af4497f5de17",
"name": "Company 2",
"address": "Address 2"
}
curl --location --request PUT 'localhost:8080/api/company/5fcba07c30e3af4497f5de17' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Company 2 Updated",
"address": "Address 2 Updated"
}'
curl --location --request DELETE 'localhost:8080/api/company/5fcba07c30e3af4497f5de17'

11. Summary

Hi, my name is Piotr and I am the founder of Codersee- a technical blog, where I am teaching programming by practical step by step tutorials.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store