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).
Note: I’ve released a newer, refreshed version of this article, which you can find right here.
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.
6 Responses
> 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
Hi Sergio,
Totally agree with You. Already fixed as it should be.
Thanks for your feedback and awareness 🙂
Thanks for the example. Good starting point to dive in deeper.
For me i had a Problem with Getting the Data. Therefor i needed to change the Entity Classes like the following:
{code}
@NoArg
data class Company @BsonCreator constructor(
@BsonProperty(“id”) var id: ObjectId? = null,
@BsonProperty(“name”) var name: String,
@BsonProperty(“address”) var address: String
)
{code}
Hello Alex and thank you for your feedback!
Could you please share what error did you get? Definitely need to revisit some artciles as they were created a long time ago
Regarding adding @BsonProperty, as Alex mentioned, that seems necessary now.
Error:
WARN org.bson.codecs.pojo – Cannot use ‘Company’ with the PojoCodec.
org.bson.codecs.configuration.CodecConfigurationException: Invalid @BsonCreator constructor in Company. All parameters in the @BsonCreator method / constructor must be annotated with a @BsonProperty.
There must also be a constructor for the data class.
Hi Eric!
I will create in the near future the article, in which I will revisit this problem. Definitely something worth checking 🙂