Last year I started learning Kotlin and I was surprised at how easy it was to convert a Java application. IntelliJ and a few other IDEs offer nice tools for automatic conversion, and with a few adjustments you can end up with a much more concise and less error-prone code.
So, I decided to create a sample application to show my new favorite combination: Kotlin, Spring Boot, Spring Data, and Couchbase:
Creating a User Profile Service
You can clone the whole project here:
https://github.com/couchbaselabs/try-cb-kotlin
Let’s start by creating our main class:
1 2 3 4 5 6 |
@SpringBootApplication open class KotlinDemoApplication fun main(args: Array<String>) { SpringApplication.run(KotlinDemoApplication::class.java, *args) } |
 Note: You class must be open otherwise you will end up with the following error:
1 2 3 4 |
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: @Configuration class 'KotlinDemoApplication' may not be final. Remove the final modifier to continue. Offending resource: com.couchbase.KotlinDemoApplication at org.springframework.beans.factory.parsing.FailFastProblemReporter.error(FailFastProblemReporter.java:70) ~[spring-beans-4.3.13.RELEASE.jar:4.3.13.RELEASE] at org.springframework.context.annotation.ConfigurationClass.validate(ConfigurationClass.java:214) ~[spring-context-4.3.13.RELEASE.jar:4.3.13.RELEASE] |
Here is our User entity, it is very similar to the Java one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@Document class User(): BasicEntity() { constructor(id: String, name: String, address: Address, preferences: List<Preference>, securityRoles: List<String>): this(){ this.id = id; this.name = name; this.address = address; this.preferences = preferences; this.securityRoles = securityRoles; } @Id var id: String? = null @NotNull var name: String? = null @Field var address: Address? = null @Field var preferences: List<Preference> = emptyList() @Field var securityRoles: List<String> = emptyList() } |
- @Document: Couchbase’s annotation which defines an entity, similar to @Entity in JPA. Couchbase will automatically add a property called _class in the document to use it as the document type.
- @Id: The document’s key
- @Field: Couchbase’s annotations, similar to JPA’s @Column. It is not mandatory, but we do recommend using it.
Mapping attributes in Couchbase are really simple. They will be directly mapped to the correspondent structure in JSON:
- Simple Properties: Straightforward mapping to JSON:
1 2 3 4 |
{ "id": "user::1", "name": "Denis Rosa" } |
- Arrays: As you might expect, arrays like securityRoles will be converted to JSON arrays:Â
1 2 3 |
{ "securityRoles": ["admin", "user"] } |
- Nested Entities: Do you hate to map @ManyToOne relationships? Me too. As we are using a document database, there is no need to write these relationships anymore, nested entities are also directly translated to JSON.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
{ "id":"user::1", "name":"Denis Rosa", "address":{ "streetName":"A Street Somewhere", "houseNumber":"42", "postalCode":"81234", "city":"Munich", "country":"DE" }, "preferences":[ { "name":"lang", "value":"EN" } ], "securityRoles":[ "admin", "user" ] } |
Repositories
Now, let’s take a look at how our repository will look like:
1 2 3 4 5 6 7 8 9 10 11 12 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "user") interface UserRepository : CouchbasePagingAndSortingRepository<User, String> { fun findByName(name: String): List<User> @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and ANY preference IN " + " preferences SATISFIES preference.name = $1 END") fun findUsersByPreferenceName(name: String): List<User> @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and meta().id = $1 and ARRAY_CONTAINS(securityRoles, $2)") fun hasRole(userId: String, role: String): User } |
- @N1qlPrimaryIndexed: This annotation makes sure that the bucket associated with the current repository will have a N1QL primary index
- @ViewIndexed:  This annotation lets you define the name of the design document and View name as well as a custom map and reduce function.
As you can see below, you can leverage all Spring Data keywords to query the database, such as FindBy, Between, IsGreaterThan, Like, Exists, etc.
1 |
fun findByName(name: String): List<User> |
The repository is extending CouchbasePagingAndSortingRepository, which allows you to paginate your queries by simply adding a Pageable param at the end of your method definition. If you need to write more powerful queries, you can also use N1QL:
1 2 3 4 5 |
@Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and ANY preference IN " + " preferences SATISFIES preference.name = $1 END") fun findUsersByPreferenceName(name: String): List<User> @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and meta().id = $1 and ARRAY_CONTAINS(securityRoles, $2)") fun hasRole(userId: String, role: String): User |
The queries above have a few syntax-sugars to make it smaller:
- #(#n1ql.bucket):Â Use this syntax avoids hard-coding the bucket name in your query
- #{#n1ql.selectEntity}: syntax-sugar to SELECT * FROM #(#n1ql.bucket):
- #{#n1ql.filter}: syntax-sugar to filter the document by type, technically it means class = ‘myPackage.MyClassName’ (_class is the attribute automatically added to the document to define its type when you are working with Couchbase on Spring Data )
- #{#n1ql.fields}Â will be replaced by the list of fields (eg. for a SELECT clause) necessary to reconstruct the entity.
- #{#n1ql.delete} will be replaced by the delete from statement.
- #{#n1ql.returning}Â will be replaced by returning clause needed for reconstructing entity.
Services
Our service is basically forwarding requests to our repository, but if you need to write ad-hoc queries, here is the right place:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Service class UserService { @Autowired lateinit var userRepository: UserRepository; fun findByName(name: String): List<User> = userRepository.findByName(name) fun findById(userId: String) = userRepository.findOne(userId) fun save(@Valid user: User) = userRepository.save(user) fun findUsersByPreferenceName(name: String): List<User> = userRepository.findUsersByPreferenceName(name) fun hasRole(userId: String, role: String): Boolean { return userRepository.hasRole(userId, role) != null } /** * Example of ad hoc queries */ fun findUserByAddress(streetName: String = "", number: String = "", postalCode: String = "", city: String = "", country: String = ""): List<User> { var query = "SELECT meta(b).id as id, b.* FROM " + getBucketName() + " b WHERE b._class = '" + User::class.java.getName() + "' " if (!streetName.isNullOrBlank()) query += " and b.address.streetName = '$streetName' " if (!number.isNullOrBlank()) query += " and b.address.houseNumber = '$number' " if (!postalCode.isNullOrBlank()) query += " and b.address.postalCode = '$postalCode' " if (!city.isNullOrBlank()) query += " and b.address.city = '$city' " if (!country.isNullOrBlank()) query += " and b.address.country = '$country' " val params = N1qlParams.build().consistency(ScanConsistency.REQUEST_PLUS).adhoc(true) val paramQuery = N1qlQuery.parameterized(query, JsonObject.create(), params) return userRepository.getCouchbaseOperations().findByN1QLProjection(paramQuery, User::class.java) } fun getBucketName() = userRepository.getCouchbaseOperations().getCouchbaseBucket().bucketManager().info().name() } |
Controllers
Finally, let’s also add a controller to test our services via rest:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@RestController @RequestMapping("/api/user") class UserController { @Autowired lateinit var userService: UserService @GetMapping(value = "/{id}") fun findById(@PathParam("id") id: String) = userService.findById(id) @GetMapping(value = "/preference") fun findPreference(@RequestParam("name") name: String): List<User> { return userService.findUsersByPreferenceName(name) } @GetMapping(value = "/find") fun findUserByName(@RequestParam("name") name: String): List<User> { return userService.findByName(name) } @PostMapping(value = "/save") fun findUserByName(@RequestBody user: User) = userService.save(user) @GetMapping(value = "/findByAddress") fun findByAddress(@RequestParam("streetName", defaultValue = "") streetName: String, @RequestParam("number", defaultValue = "") number: String, @RequestParam("postalCode", defaultValue = "") postalCode: String, @RequestParam("city", defaultValue = "") city: String, @RequestParam("country", defaultValue = "") country: String): List<User> { return userService.findUserByAddress(streetName, number, postalCode, city, country); } } |
Writing Integration Tests with Kotlin
To run the integrations tests correctly, don’t forget to configure the credentials of your database in the application.properties file:
1 2 3 4 |
spring.couchbase.bootstrap-hosts=localhost spring.couchbase.bucket.name=test spring.couchbase.bucket.password=somePassword spring.data.couchbase.auto-index=true |
Here, you can see how our tests look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Test fun testComposedAddress() { val address1 = Address("street1", "1", "0000", "santo andre", "br") val address2 = Address("street1", "2", "0000", "santo andre", "br") val address3 = Address("street2", "12", "1111", "munich", "de") userService.save(User(USER_1, "user1", address1, emptyList(), emptyList())) userService.save(User("user::2", "user2", address2, emptyList(), emptyList())) userService.save(User("user::3", "user3", address3, emptyList(), emptyList())) var users = userService.findUserByAddress(streetName = "street1") assertThat(users, hasSize<Any>(2)) users = userService.findUserByAddress(streetName = "street1", number= "1") assertThat(users, hasSize<Any>(1)) users = userService.findUserByAddress(country = "de") assertThat(users, hasSize<Any>(1)) } |
Kotlin and Maven Dependencies
Kotlin is moving quickly, so be aware to use the most recent versions of each dependency:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
<dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>1.2.41</version> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-reflect</artifactId> <version>1.2.41</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.module</groupId> <artifactId>jackson-module-kotlin</artifactId> <version>2.9.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-couchbase</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-rest</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <version>1.3</version> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-test</artifactId> <version>${kotlin.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-allopen</artifactId> <version>1.2.41</version> </dependency> </dependencies> |
You can view the whole pom.xml here.