fbpx

Micronaut with MongoDB and Kotlin

1. Introduction

Hello dear readers. In this article, I would like to teach you how to create a Micronaut project with MongoDB and Kotlin.

Micronaut is a modern, JVM-based framework for building modular, easily testable microservice and serverless applications. If you have any prior experience with reflection-based frameworks, like Spring or Spring Boot, you probably noticed that the startup time and memory consumption are bound to the size of the codebase. With Micronaut, these problems have been solved using Java’s annotation processors, making it a really great choice for low memory-footprint environments, like microservices.

Moreover, the creators took a lot of inspiration from Spring and Grails, so if you’ve ever worked with any of them, you’ll see plenty of similarities.

In this guide, we will create a simple CRUD REST API, which will allow the user to operate on the data from MongoDB (for a similar project in Spring Boot, please see this article).

2. Run MongoDB Server

Just like in previous articles, we need to make sure, that our MongoDB server is up and running. For simplicity, we will deploy it as a docker container. Nevertheless, if you would like to install a MongoDB on your local machine, please refer to this official manual.

2.1. Deploy MongoDB as a Container

Firstly, let’s pull the mongo image from the Docker Hub registry:

docker pull mongo

After that, let’s deploy it in a detached mode ( -d flag):

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

Besides deploying in a detached mode, the above command publishes the container’s ports to the host- in simple terms, we will be able to connect to the MongoDB using localhost.

Finally, let’s check if everything is OK with the following command:

docker ps

#command output
CONTAINER ID        IMAGE               COMMAND                  CREALTED             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. Set Up The Project

3.1. Generate The Project

As the first step, let’s generate the project. We can do that either by installing the Micronaut CLI or with the online launcher (similar to the Spring Initializr). In my case, I’ve generated it using the web launcher, setting the following values:

  • Application Type: Micronaut Application
  • Java Version: 11
  • Base Package: com.codersee
  • Name: example
  • Language: Kotlin
  • Build: Gradle Kotlin
  • Test Framework: Junit
  • Included Features: mongo-sync

Please notice, that we’ve included the mongo-sync feature, which will provide the support for the Mongo Synchronous Driver in our application.

3.2. Add The No-arg Compiler Plugin

Additionally, let’s add the no-arg compiler plugin inside the build.gradle.kts file:

plugins {
    id("org.jetbrains.kotlin.plugin.noarg") version "1.4.10"
}

The no-arg compiler plugin generates an additional zero-argument constructor for classes with a specific annotation. I’ll show you, how to create such annotation and why should we use it in the next chapters.

3.3. Configure application.yml

As the next step, let’s the MongoDB connection config inside the application.yml file:

micronaut:
  application:
    name: example
mongodb:
  uri: 'mongodb://localhost:27017/example'

This configuration instructs the MongoDB driver, that it should connect to the example database of the MongoDB instance running on port 27017.

4. Create @NoArg annotation

As the next step, let’s utilize the no-arg plugin and create the NoArg.kt file under the annotation package:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class NoArg

Additionally, we have to add the following configuration inside the build.gradle.kts file:

noArg {
    annotation("com.codersee.annotation.NoArg")
}

Please keep in mind, that the parameter passed to the function, “com.codersee.annotation.NoArg” in my case, needs to match with the package inside your project.

5. Create Models

After all of the above being done, we can start defining models, which will be used to persist and fetch objects from the database. In our example, we will define two separate classes- a Company and an Employee:

@NoArg
data class Company(
    var id: ObjectId? = null,
    var name: String,
    var address: String
)
@NoArg
data class Employee(
    var id: ObjectId? = null,
    var firstName: String,
    var lastName: String,
    var email: String,
    var company: Company?,
)

As you might have noticed, both classes have been annotated with the @NoArg. Without it, we would have to make all of the fields nullable and that’s something I wanted to avoid.

5.1. What is ObjectId?

You might be wondering, what exactly the ObjectId is? Well, by default MongoDB uses ObjectIds as the default value of the _id field of each document. An ObjectId is a 12-byte BSON type having the following structure:

  • The first 4 bytes represent the seconds since the Unix epoch
  • The next 3 bytes are the machine identifier
  • The next 2 bytes consists of process id
  • The last 3 bytes are a random counter value

These 12 bytes altogether uniquely identify a document within the MongoDB collection and serve as a primary key for that document as well.

6. Add Custom Exception With Status Code

Nextly, let’s create a custom exception class named NotFoundException and ExceptionResponse, which will be used to serialize the response:

class NotFoundException(msg: String) : RuntimeException(msg)
class ExceptionResponse(
    val message: String?
)

NotFoundException is just a simple class extending the RuntimeException. Additionally, this class contains the msg field, which will be used to set and obtain the custom message.

With that being done, let’s create the NotFoundExceptionHandler class:

@Produces
@Singleton
class NotFoundExceptionHandler :
    ExceptionHandler<NotFoundException, HttpResponse<ExceptionResponse>> {

    override fun handle(
        request: HttpRequest<*>,
        exception: NotFoundException
    ): HttpResponse<ExceptionResponse> =
        HttpResponse.notFound(
            ExceptionResponse(
                message = exception.message
            )
        )

}

As you can see, we implement the ExceptionHandler interface and specify two things:

  • The Throwable, we’d like to handle- NotFoundException in our case
  • The result type- in our case, the  HttpResponse<ExceptionResponse>

Moreover, we provide the declaration for the handle function, in which we set the response body with the message from the exception and the 404 NOT FOUND response code.

6.1. What is @Singleton and @Produces?

Well, the @Signleton is used to register a Singleton in Micronaut’s application context (just like a @Component in Spring Boot). The @Produces annotation, on the other hand, is used to indicate, what MediaTypes will be produced by our component (by default, it’s set to the application/json).

7. Create Repositories

As the next step, let’s create repositories for our data. In our project, we’ll use the synchronous MongoClient to perform operations on the MongoDB.

7.1. Implement CompanyRepository

Let’s start by adding the CompanyRepository first:

@Singleton
class CompanyRepository(
    private val mongoClient: MongoClient
) {

    fun create(company: Company): InsertOneResult =
        getCollection()
            .insertOne(company)

    fun findAll(): List<Company> =
        getCollection()
            .find()
            .toList()

    fun findById(id: String): Company? =
        getCollection()
            .find(
                Filters.eq("_id", ObjectId(id))
            )
            .toList()
            .firstOrNull()

    fun update(id: String, update: Company): UpdateResult =
        getCollection()
            .replaceOne(
                Filters.eq("_id", ObjectId(id)),
                update
            )

    fun deleteById(id: String): DeleteResult =
        getCollection()
            .deleteOne(
                Filters.eq("_id", ObjectId(id))
            )

    private fun getCollection() =
        mongoClient
            .getDatabase("example")
            .getCollection("company", Company::class.java)
}

Let’s discuss, what exactly is happening in this class.

As the first step, we need to register it in the application context with @Signleton and inject the MongoClient instance through the constructor.

@Singleton
class CompanyRepository(
    private val mongoClient: MongoClient
) 

Nextly, we create the helper function, which will be responsible for getting the MongoCollection object of the company collection from the example database. Moreover, we pass the Company::class.java as the second parameter, so that all of the returned documents will be cast to this type.

private fun getCollection() =
    mongoClient
        .getDatabase("example")
        .getCollection("company", Company::class.java)

Following, let’s look at the functions responsible for getting the data from the database:

fun findAll(): List<Company> =
    getCollection()
        .find()
        .toList()

fun findById(id: String): Company? =
    getCollection()
        .find(
            Filters.eq("_id", ObjectId(id))
        )
        .toList()
        .firstOrNull()

As can be seen, in both cases the find() function has been used, returning the iterable, which we convert to the List. But, when looking for a specific company, we apply the additional filter looking for a document with the specified ID and either return the Company or null.

Lastly, let’s look at functions responsible for creating, updating, and deleting the data:

fun create(company: Company): InsertOneResult =
    getCollection()
        .insertOne(company)

fun update(id: String, update: Company): UpdateResult =
    getCollection()
        .replaceOne(
            Filters.eq("_id", ObjectId(id)),
            update
        )

fun deleteById(id: String): DeleteResult =
    getCollection()
        .deleteOne(
            Filters.eq("_id", ObjectId(id))
        )
}

As you can see, all of them return _Result instances. These classes contain a lot of useful information, like the count, or ID of affected documents (which we will use in the service layer).

7.2. Implement EmployeeRepository

Similarly, let’s implement the EmployeeRepository:

@Singleton
class EmployeeRepository(
    private val mongoClient: MongoClient
) {

    fun create(employee: Employee): InsertOneResult =
        getCollection()
            .insertOne(employee)

    fun findAll(): List =
        getCollection()
            .find()
            .toList()

    fun findAllByCompanyId(companyId: String): List<Employee> =
        getCollection()
            .find(
                Filters.eq("company._id", ObjectId(companyId))
            )
            .toList()


    fun findById(id: String): Employee? =
        getCollection()
            .find(
                Filters.eq("_id", ObjectId(id))
            )
            .toList()
            .firstOrNull()

    fun update(id: String, update: Employee): UpdateResult =
        getCollection()
            .replaceOne(
                Filters.eq("_id", ObjectId(id)),
                update
            )

    fun deleteById(id: String): DeleteResult =
        getCollection()
            .deleteOne(
                Filters.eq("_id", ObjectId(id))
            )

    private fun getCollection() =
        mongoClient
            .getDatabase("example")
            .getCollection("employee", Employee::class.java)
}

8. Create Services

8.1. Implement CompanyService

Before we will create the service, let’s add the CompanyRequest class, which will be used to store the data passed by the user:

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

Nextly, let’s create the CompanyService class:

@Singleton
class CompanyService(
    private val companyRepository: CompanyRepository,
    private val employeeRepository: EmployeeRepository
) {

    fun createCompany(request: CompanyRequest): BsonValue? {
        val insertedCompany = companyRepository.create(
            Company(
                name = request.name,
                address = request.address
            )
        )
        return insertedCompany.insertedId
    }

    fun findAll():  List<Company> {
        return companyRepository.findAll()
    }

    fun findById(id: String): Company {
        return companyRepository.findById(id)
            ?: throw NotFoundException("Company with id $id was not found")
    }

    fun updateCompany(id: String, request: CompanyRequest): Company {
        val updateResult = companyRepository.update(
            id = id,
            update = Company(name = request.name, address = request.address)
        )

        if (updateResult.modifiedCount == 0L)
            throw throw RuntimeException("Company with id $id was not updated")

        val updatedCompany = findById(id)
        updateCompanyEmployees(updatedCompany)

        return updatedCompany
    }

    fun deleteById(id: String) {
        val deleteResult = companyRepository.deleteById(id)

        if (deleteResult.deletedCount == 0L)
            throw throw RuntimeException("Company with id $id was not deleted")
    }

    private fun updateCompanyEmployees(updatedCompany: Company) {
        employeeRepository
            .findAllByCompanyId(updatedCompany.id!!.toHexString())
            .map {
                employeeRepository.update(
                    it.id!!.toHexString(),
                    it.apply { it.company = updatedCompany }
                )
            }
    }
}

Basically, these are just simple functions responsible for creating, deleting, and retrieving the data through the CompanyRepository.

However, you might have noticed that each time, we would like to update the company, we need to update all employees connected with it. That’s because we keep the related company in denormalized form- inside the employee document. To visualize that, let’s check the example document from our database:

{
    "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"
}

As you can see, the company document is stored as an inner document of each employee. This might seem pretty weird, especially when you have any previous experience working with SQL databases, but this is the optimal choice for most real-life scenarios when using MongoDB (if you would like to learn more about MongoDB relationships, please let me know and I will create another tutorial about it :).

8.2. Implement EmployeeService

In the same fashion, let’s create the EmployeeRequest responsible for data handling and the EmployeeService:

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

) {

    fun createEmployee(request: EmployeeRequest): BsonValue? {
        val company = request.companyId?.let { companyService.findById(it) }

        val insertedEmployee = employeeRepository.create(
            Employee(
                firstName = request.firstName,
                lastName = request.lastName,
                email = request.email,
                company = company
            )
        )
        return insertedEmployee.insertedId
    }

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

    fun findById(id: String): Employee =
        employeeRepository.findById(id)
            ?: throw NotFoundException("Employee with id $id not found")

    fun updateEmployee(id: String, request: EmployeeRequest): Employee {
        val foundCompany = request.companyId?.let { companyService.findById(it) }

        val updateResult = employeeRepository.update(
            id = id,
            update = Employee(
                firstName = request.firstName,
                lastName = request.lastName,
                email = request.email,
                company = foundCompany
            )
        )

        if (updateResult.modifiedCount == 0L)
            throw throw RuntimeException("Employee with id $id was not updated")

        return findById(id)
    }

    fun deleteById(id: String) {
        val deleteResult = employeeRepository.deleteById(id)

        if (deleteResult.deletedCount == 0L)
            throw throw RuntimeException("Company with id $id was not deleted")
    }
}

9. Implement REST Controllers

As the last step, we will implement the controller layer responsible for request handling. But before that, let’s prepare two classes: CompanyResponse and EmployeeResponse responsible for returning the data to the user.

Important note: although the creation of separate classes for transferring the incoming and outgoing data adds some redundancy to the code, I personally believe it is a good way to separate these concepts and make the code cleaner.

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) }
            )
    }
}

As you can see, both classes contain fromEntity functions inside companion objects. As the name suggests, they will be used to convert entity objects to response instances.

9.1. Create CompanyController

Let’s add the CompanyController class to our project first:

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

    @Post
    fun create(@Body request: CompanyRequest): HttpResponse<Void> {
        val createdId = companyService.createCompany(request)

        return HttpResponse.created(
            URI.create(
                createdId!!.asObjectId().value.toHexString()
            )
        )
    }

    @Get
    fun findAll(): HttpResponse<List<CompanyResponse>> {
        val companies = companyService
            .findAll()
            .map { CompanyResponse.fromEntity(it) }

        return HttpResponse.ok(companies)
    }

    @Get("/{id}")
    fun findById(@PathVariable id: String): HttpResponse<CompanyResponse> {
        val company = companyService.findById(id)

        return HttpResponse.ok(
            CompanyResponse.fromEntity(company)
        )
    }

    @Put("/{id}")
    fun update(
        @PathVariable id: String,
        @Body request: CompanyRequest
    ): HttpResponse<CompanyResponse> {
        val updatedCompany = companyService.updateCompany(id, request)

        return HttpResponse.ok(
            CompanyResponse.fromEntity(updatedCompany)
        )
    }

    @Delete("/{id}")
    fun deleteById(@PathVariable id: String): HttpResponse<Void> {
        companyService.deleteById(id)

        return HttpResponse.noContent()
    }
}

As you can see,  in order to make the class a controller inside the Micronaut context, we need to annotate it with @Controller. Additionally, we can pass the base URI to it, as we’ve done in the above code snippet.

Basically, the CompanyController utilizes already prepared functions from the CompanyService. Each function needs to be marked with appropriate annotation (like @Get, @Put, etc.) in order to serve as an HTTP request handler. By default, it will use the base URI set for the class (/api/company in our case).

As the last thing, please notice the @PathVariable- used to bind the parameter of the function exclusively from the path variable- and the @Body– indicating that the method argument is bound from the HTTP body.

9.2. Create EmployeeController

Similarly, let’s implement the EmployeeController:

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

    @Post
    fun create(@Body request: EmployeeRequest): HttpResponse {
        val createdId = employeeService.createEmployee(request)

        return HttpResponse.created(
            URI.create(
                createdId!!.asObjectId().value.toHexString()
            )
        )
    }

    @Get
    fun findAll(): HttpResponse<List> {
        val employees = employeeService
            .findAll()
            .map { EmployeeResponse.fromEntity(it) }

        return HttpResponse.ok(employees)
    }

    @Get("/{id}")
    fun findById(@PathVariable id: String): HttpResponse {
        val employee = employeeService.findById(id)

        return HttpResponse.ok(
            EmployeeResponse.fromEntity(employee)
        )
    }

    @Put("/{id}")
    fun update(
        @PathVariable id: String,
        @Body request: EmployeeRequest
    ): HttpResponse {
        val updatedEmployee = employeeService.updateEmployee(id, request)

        return HttpResponse.ok(
            EmployeeResponse.fromEntity(updatedEmployee)
        )
    }

    @Delete("/{id}")
    fun deleteById(@PathVariable id: String): HttpResponse {
        employeeService.deleteById(id)

        return HttpResponse.noContent()
    }
}

10. Test with CURL

Finally, we can run our application- for instance, with the ./gradlew run command- and test the endpoints using cURL. If you would like to explore the database, I highly recommend the MongoDB Compass, which is the dedicated GUI for MongoDB.

Let’s start by adding two companies:

# 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"
}'

To validate, let’s list all companies:

curl --location --request GET 'localhost:8080/api/company'

# Example output
[
  {
    "id": "5fcba07a30e3af4497f5de16",
    "name": "Company 1",
    "address": "Address 1"
  },
  {
    "id": "5fcba07c30e3af4497f5de17",
    "name": "Company 2",
    "address": "Address 2"
  }
]

If we would like to find a specific company, then let’s use the following command:

curl --location --request GET 'localhost:8080/api/company/5fcba07c30e3af4497f5de17'

# Expected output
{
  "id": "5fcba07c30e3af4497f5de17",
  "name": "Company 2",
  "address": "Address 2"
}

To update the company, let’s use the PUT request:

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

If everything went well, we should see that our company data has been updated- you can always check it with another endpoint as well.

To delete the company, let’s use the DELETE handling endpoint:

curl --location --request DELETE 'localhost:8080/api/company/5fcba07c30e3af4497f5de17'

This time, we should receive an empty response body with 204 No Content status and a company should be deleted as well.

Similarly, we can test the rest of the endpoints responsible for employees management- let it be homework for you- I’ve always believed there is no other way to learn anything, than trying on our own 🙂

11. Summary

And that’s all for this article. We’ve gone step by step through the process of creating the Micronaut project with MongoDB and Kotlin

I hope, you really enjoyed it and that after reading it, you’ll get more interested in the Micronaut project. If you would like to ask about anything or add your own suggestions, please do it in the comment section below, or by using the contact form.

As usual, you can find the working project in this GitHub repository.

Share on facebook
Share on twitter
Share on linkedin

2 thoughts on “Micronaut with MongoDB and Kotlin”

  1. > We can do that either by installing the Micronaut Launch or with the online launcher (similar to the Spring Initializr).

    Hi, We typically refer to the command line utility as Micronaut CLI not Micronaut Launch. We use Micronaut Launch to refer to the Web application exposed at launch.micronaut.io

Leave a Comment

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

Subscribe to our Newsletter

Join the community and get free eBooks.

Image shows the covers of free ebooks accessible for newsletter subscribers.

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

Find us also on...

Join the FREE weekly newsletter and get two free eBooks as well:

Image shows the covers of free ebooks accessible for newsletter subscribers.

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

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