Blog post

Building High-Quality Microservices with Kotlin: Best Practices for Developers

Imran Bilal Solanki

-
September 16, 2024
Kotlin Backend
Microservices
Functional Programming
Kotlin Libraries
Software Development
Photo by Ben Collins on Unsplash

I recently worked on a Payment Gateway project for an Indian unicorn, focusing on developing core components with high-quality code. We opted for a microservices architecture to build small, independent services, ensuring the code was clean, readable, maintainable, and extensible.

Developing these services in Kotlin presented challenges and required careful strategy to avoid blurring boundaries. Without clear boundaries, the outcome largely depends on the developers’ experience, expertise and knowledge of best practices. After thorough research, our team selected the libraries and tools that would best support our development efforts.

Ktor

Ktor is a Kotlin framework used for building asynchronous servers and clients. It is highly customisable and allows developers to build web applications, APIs, microservices, and more. Ktor emphasises flexibility, making it possible to structure applications in a modular way with minimal overhead.

Key Features of Ktor

  1. Asynchronous by Default: Ktor is built on Kotlin coroutines, allowing it to handle many requests concurrently without blocking threads.
  2. Modularity: You can choose the components you need. For instance, you can opt-in for features like routing, content negotiation, authentication, etc.
  3. Routing: Ktor provides a DSL for defining routes in a clear and intuitive manner.
  4. Flexible Configuration: Ktor allows you to configure different parts of your application such as the server engine, content type, and error handling.
  5. Multi-platform: Ktor supports both server-side and client-side applications, which can be helpful in building full-stack solutions in Kotlin.

Example of a Simple Ktor Server

import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main {
embeddedServer(Netty, port = 8080 ) {
routing {
get ( "/" ) {
call.respondText(text = "Hello, Ktor!" , status = HttpStatusCode.OK) } } }.start(wait = true ) }

Ktor Use Cases

  • Building REST APIs: Ktor’s routing DSL and support for content negotiation make it a great tool for building REST APIs.
  • Microservices: Its lightweight and modular design makes it suitable for microservice architectures.
  • WebSockets: Ktor has built-in support for WebSocket communication, making it easier to handle real-time data streaming.
  • Server and Client: You can use Ktor on both server and client-side applications for a unified codebase in full-stack Kotlin projects

Ktor project generator is a helpful tool to create project templates based on the selected plugins. This is similar to Spring Initializr

Koin

Koin is a lightweight dependency injection (DI) framework for Kotlin. It’s specifically designed for Kotlin developers and leverages Kotlin features like DSL (domain-specific language) to create a very intuitive, concise, and type-safe way of managing dependencies.

Key Features of Koin

  1. No Proxies or Code Generation: Koin does not rely on reflection or proxies to inject dependencies, making it faster and simpler than other DI frameworks like Dagger.
  2. Kotlin-first: Since Koin is designed with Kotlin in mind, it integrates naturally into Kotlin projects, with idiomatic syntax.
  3. Lightweight: The framework is minimalistic in terms of its API and runtime overhead, providing just enough to manage dependency injection effectively.
  4. Modular: Koin encourages modularization and allows for breaking down your application into smaller, testable components.
  5. Testability: Koin makes unit testing easier by providing mock dependencies without complex configurations.

Basic Koin Setup

// Koin
for Kotlin apps compile "io.insert-koin:koin-core: $koin_version "

Defining Modules

Koin uses modules to define how your dependencies are provided.

import org.koin.dsl. module
val appModule module {
single {
PaymentOrderConfigs() } // Creates a single instance of PaymentOrderConfigs factory {
Service() } // Creates a single instance of Service }
  • single: Provides a singleton instance of the class.
  • factory: Provides a new instance each time the class is requested.

Starting Koin

import org.koin.core.context.startKoin
fun main {
startKoin {
modules(appModule) } }

Injecting Dependencies

import org.koin.ktor.ext.inject
fun Route. internalRouting {
val config inject<PaymentOrderConfigs>() route( "/api/pay/v1/orders" ) {
post( "/{orderId}/reconcile" ) {
...... config.client.reconcileOrder(orderId) ...... } } }

Benefits of Koin

  • No boilerplate: Koin requires minimal setup and no code generation, making it less verbose than other DI frameworks.
  • Kotlin DSL: Using Kotlin’s DSL, it’s easy to define modules and dependencies in a clear and readable way.
  • Simple testing: Koin allows easy injection of test dependencies without complex mocking frameworks.

Koin is a good choice if you prefer a simple, non-intrusive DI framework for Kotlin projects.

Exposed

Exposed is a SQL ORM framework for Kotlin that provides both DSL (Domain-Specific Language) and DAO (Data Access Object) layers for interacting with relational databases. It simplifies writing SQL queries while still giving developers fine-grained control over the database interactions.

Key Components of Exposed

  • DSL (Domain-Specific Language): The DSL in Exposed lets you write type-safe SQL queries in Kotlin using a structured syntax.
  • DAO (Data Access Object): This layer allows you to map database tables to Kotlin objects, providing a higher level of abstraction than the DSL.
  • Schema Management: Exposed can automatically create and modify database schemas from Kotlin code.
  • Supports Multiple Databases: Works with various relational databases like PostgreSQL, MySQL, SQLite, etc.

Quick Overview of Exposed DSL

  1. Dependencies: Add Exposed to your build.gradle.kts file.
dependencies {
implementation ("org.jetbrains.exposed:exposed-core: 0.42 .") implementation ("org.jetbrains.exposed:exposed-dao: 0.42 .") implementation ("org.jetbrains.exposed:exposed-jdbc: 0.42 .") implementation ("org.jetbrains.exposed:exposed-java-time: 0.42 .") // If you use Java time API implementation ("org.postgresql:postgresql: 42.2 .") // For PostgreSQL, or other DB drivers }

2. Connecting to the Database:

import org.jetbrains.exposed.sql.Database // Connect to the PostgreSQL database Database.connect( url = "jdbc:postgresql://localhost:5432/mydb"
driver = "org.postgresql.Driver"
user = "username"
password = "password" )

3. Defining a Table:

import org.jetbrains.exposed.sql.Table
object Users : Table( "users" ) {
val id = integer( "id" ).autoIncrement()
val name = varchar( "name" , )
val email = varchar( "email" , 100 ) override
val primaryKey = PrimaryKey(id) }

4. Inserting Data

import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction transaction {
Users.insert {
it[name] = "John Doe" it[email] = "john@example.com" } }

5. Querying Data

import org.jetbrains.exposed.sql.selectAll transaction {
Users.selectAll().forEach {
println( " ${it[Users.name]} - ${it[Users.email]} " ) } }

6. Updating Data

import org.jetbrains.exposed.sql.update transaction {
Users.update({
Users.id eq }) {
it[name] = "Jane Doe" } }

7. Deleting Data

import org.jetbrains.exposed.sql.deleteWhere transaction {
Users.deleteWhere {
Users.id eq } }

DAO Example

For a higher abstraction level, the DAO approach represents rows as objects.

import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
object Users : IntIdTable() {
val name = varchar( "name" , )
val email = varchar( "email" , 100 ) }
class User (id: Int ) : IntEntity(id) {
companion object : IntEntityClass<User>(Users)
var name Users.name
var email Users.email }
transaction {
val user = User.new {
name = "John Doe" email = "john@example.com" }
println( " ${user.name} - ${user.email} " ) }

Advantages of Exposed

  1. Type-Safe Queries: Queries are written in Kotlin and are type-safe.
  2. Flexibility: Use either the DSL or DAO approach based on your project’s needs.
  3. Multiple Database Support: Works seamlessly with popular databases like PostgreSQL, MySQL, and SQLite.
  4. Migrations Support: Works well with tools like Flyway or Liquibase for database migrations.

Exposed is ideal for Kotlin projects that want SQL-like control with Kotlin’s type safety and functional programming capabilities.

Either

The Either type in the Arrow-KT library is a functional programming tool used to represent a value that can be one of two possible types: a "left" or a "right." This is often used for handling computations that might result in success (typically represented by Right) or failure (represented by Left).

Basic structure

Either is a sealed class with two possible variants:

  • Left: Represents a failure or error
  • Right: Represents a success value

It’s used as a safer alternative to exceptions in Kotlin, ensuring explicit handling of errors.

This is useful for avoiding null values and exceptions. Instead, you explicitly define both success and failure cases in the type system, making it easier to reason about the code.

Key Features

  1. Error Handling: Use Left for failures and Right for successful values.
  2. Type-safe: Eliminates nullable types or unchecked exceptions.
  3. Chaining Operations: Combine computations with functions like map, flatMap, and fold.

Below is a simple example

val result: Either<String, Int > = Either.Right() result.map {
it * } // Produces Either.Right(10)

Why use Either?

  1. Error Handling: Instead of using exceptions, errors are represented explicitly in the type.
  2. More Readable: Helps in making code more predictable and easier to maintain.
  3. No Nulls: Avoids null pointer exceptions by clearly separating success and failure cases.
import arrow.core.Either
import java.lang.Exception // PaymentMethod
interface representing different payment methods
interface PaymentMethod {
fun pay (amount: Double ) : Either<Exception, Boolean > } // CreditCardPayment
class implementing PaymentMethod
class CreditCardPayment (
val cardNumber: String) : PaymentMethod {
override
fun pay (amount: Double ) : Either<Exception, Boolean > {
return (amount <= ) {
Either.Left(IllegalArgumentException( "Payment amount must be greater than zero" )) }
else {
println( "Paid $ $amount using Credit Card ending in ${cardNumber.takeLast()} " ) Either.Right( true ) } } } // PayPalPayment
class implementing PaymentMethod
class PayPalPayment (
val email: String) : PaymentMethod {
override
fun pay (amount: Double ) : Either<Exception, Boolean > {
return (amount <= ) {
Either.Left(IllegalArgumentException( "Payment amount must be greater than zero" )) }
else {
println( "Paid $ $amount using PayPal account: $email " ) Either.Right( true ) } } } // PaymentProcessor
class to process payments
class PaymentProcessor ( private
val method: PaymentMethod) {
fun processPayment (amount: Double ) : Either<Exception, Boolean > {
return method.pay(amount) } }

Now, let’s have a look at tests written for the above code

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.either.shouldBeLeft
import io.kotest.matchers.either.shouldBeRight
import io.kotest.matchers.shouldBe
class PaymentProcessorTest : StringSpec({
"should process successful payment with credit card" {
val paymentMethod CreditCardPayment( "1234567890123456" )
val processor PaymentProcessor(paymentMethod)
val result processor.processPayment( 100.0 ) result.shouldBeRight( true ) } "should fail payment with credit card due to invalid amount" {
val paymentMethod CreditCardPayment( "1234567890123456" )
val processor PaymentProcessor(paymentMethod)
val result processor.processPayment(- 50.0 ) result.shouldBeLeft<IllegalArgumentException>() result.fold({
it.message shouldBe "Payment amount must be greater than zero" }, {}) } "should process successful payment with PayPal" {
val paymentMethod PayPalPayment( "user@example.com" )
val processor PaymentProcessor(paymentMethod)
val result processor.processPayment( 200.0 ) result.shouldBeRight( true ) } "should fail payment with PayPal due to invalid amount" {
val paymentMethod PayPalPayment( "user@example.com" )
val processor PaymentProcessor(paymentMethod)
val result processor.processPayment( 0.0 ) result.shouldBeLeft<IllegalArgumentException>() result.fold({
it.message shouldBe "Payment amount must be greater than zero" }, {}) } })

1. Explicit Success and Failure Handling

With Either, both success (Right) and failure (Left) are clearly represented. Tests ensure you handle both cases properly, unlike traditional error handling with exceptions or nulls.

2. Prevents Unhandled Exceptions

Since errors are encapsulated in Either.Left, there are no unhandled exceptions. Testing guarantees that all error conditions are properly captured and returned.

3. Better Code Coverage

Tests for Either naturally cover both success and failure paths, ensuring edge cases like invalid inputs are handled and improving overall code coverage.

4. Simpler, More Readable Tests

Testing Either with assertions like shouldBeRight and shouldBeLeft is clear and straightforward, making the tests easier to write and read without complex try-catch blocks.

result.shouldBeLeft<IllegalArgumentException>() result.fold({
it.message shouldBe "Payment amount must be greater than zero" }, {})

Testing with Either ensures that all paths (success and failure) are handled explicitly and cleanly, leading to more robust and maintainable code.

CircuitBreaker

In Arrow-KT, the concept of a Circuit Breaker is implemented as part of the Arrow FX module, which provides functional tools for handling side effects in Kotlin. A circuit breaker is useful for preventing cascading failures in a system by limiting the number of failed operations (like HTTP calls, database interactions, etc.) and stopping further requests until the system recovers.

Overview of Circuit Breaker

The Circuit Breaker pattern allows you to:

  • Stop making requests after a certain number of failures.
  • “Open” the circuit for a defined period (cooling off) before allowing another request.
  • The transition between Closed, Open, and Half-Open states to control the flow of requests.

Arrow-KT’s circuit breaker provides these functionalities in a functional, declarative style, allowing fine-grained control over failure and recovery logic.

Setting Up Dependencies

dependencies {
implementation ("io.arrow-kt:arrow-fx-coroutines: 1.1 .") }

Example of a Circuit Breaker in Arrow-KT

import arrow.fx.coroutines.*
import kotlin.time.Duration.Companion.seconds
import java.io.IOException // Function that simulates a failing operation
suspend fun flakyOperation : String {
(Math.random() > 0.7 ) {
return "Success" }
else {
throw IOException( "Service unavailable" ) } } // Define a circuit breaker with rules
suspend fun main {
val circuitBreaker = CircuitBreaker( maxFailures = , // Max allowed failures before opening the circuit resetTimeout = seconds, // How long to wait before retrying (in half-open state) exponentialBackoffFactor = 1.5 , // Increase reset timeout exponentially after repeated failures ) repeat() {
attempt ->
try {
// Using the circuit breaker to protect the operation
val result = circuitBreaker.protectOrThrow {
flakyOperation() }
println( "Attempt $attempt : $result " ) }
catch (e: IOException) {
println( "Attempt $attempt : CircuitBreaker prevented the call - ${e.message} " ) }
catch (e: CircuitBreakerOpen) {
println( "Attempt $attempt : Circuit is open, skipping request" ) } } }

Explanation

CircuitBreaker Setup

  1. maxFailures: Maximum number of failures before the circuit goes "open" (stops allowing requests).
  2. resetTimeout: Time to wait before moving from "open" to "half-open" to allow a test request.
  3. exponentialBackoffFactor: Increases the reset timeout after each failure to give the system more time to recover.

Using the Circuit Breaker

  1. circuitBreaker.protectOrThrow { flakyOperation() } wraps your function call with the circuit breaker logic. If the function fails more than the allowed limit, the breaker opens and subsequent calls are blocked.

Circuit States

  1. Closed: The circuit is allowing requests, and all failures are counted.
  2. Open: The circuit prevents requests because there were too many failures.
  3. Half-Open: After the resetTimeout, the circuit allows one request to test if the system is available.
  4. Error Handling: If the circuit is open, a CircuitBreakerOpen exception is thrown, and the operation is skipped.

Advantages

  • Failure Protection: Automatically stop calling a failing service after a certain number of failures.
  • Recovery Control: The system can automatically recover after a specified timeout.
  • Resilience: Helps in making systems more resilient by preventing cascading failures.

Real-World Usage

You can use this pattern to protect calls to external services, databases, or any operation that might fail frequently. By using Arrow’s Circuit Breaker, you can limit the number of retries and define recovery strategies in a functional way.

Hoplite

Hoplite is a Kotlin library designed for type-safe configuration. It simplifies loading configurations from various sources (like YAML, JSON, properties files, environment variables, or command-line arguments) and automatically maps them to Kotlin data classes. This allows you to easily manage your application’s settings while maintaining strong type safety.

Key Features of Hoplite

  1. Type-safe Configuration: Automatically maps configurations to Kotlin data classes, ensuring type safety.
  2. Multiple Sources: Supports loading configurations from multiple formats (YAML, JSON, TOML, HOCON, properties files) and even environment variables or command-line arguments.
  3. Default Values and Nullability: Supports setting default values and managing nullable fields in configuration classes.
  4. Decoding and Parsing: Built-in support for decoding various types, including collections, maps, sealed classes, and even custom types.
  5. Merge Configurations: You can load configurations from multiple sources and merge them together.

How Hoplite Works

  1. Adding Hoplite to Your Project
implementation ("com.sksamuel.hoplite:hoplite-core:.x.x") implementation ("com.sksamuel.hoplite:hoplite-yaml:.x.x") // For YAML support
  1. Define Your Configuration as Data Classes: You define your application’s configuration using Kotlin data classes
data
class AppConfig (
val server: ServerConfig,
val database: DatabaseConfig )
data class ServerConfig (
val host: String,
val port: Int )
data class DatabaseConfig (
val url: String,
val user: String,
val password: String )
  1. Creating Configuration Files
server: host: "localhost" port: 8080 database: url: "jdbc:mysql://localhost:3306/mydb" user: "root" password: "secret"
  1. Loading and Parsing Configuration
import com.sksamuel.hoplite.ConfigLoader
fun main {
val config = ConfigLoader().loadConfigOrThrow<AppConfig>( "/path/to/config.yaml" ) println(config.server.host) // Outputs: localhost }

Hoplite Features in Detail

  1. Default Values: You can define default values in your data classes, and Hoplite will use them if the configuration file does not provide a value.
data
class ServerConfig (
val host: String = "127.0.0.1" ,
val port: Int = 8080 )

2. Nullability: Hoplite supports nullable types, so fields can be optional in the configuration.

data
class ServerConfig (
val host: String?,
val port: Int ? )

3. Loading from Multiple Sources: Hoplite can load configurations from multiple files or sources, merging them together.

val config ConfigLoader() .withFile( "/path/to/config.yaml" ) .withEnvironment() .loadConfigOrThrow<AppConfig>()

4. Nested and Complex Structures: Hoplite handles nested data structures, collections, sealed classes, and enums automatically.

Why Use Hoplite?

  • Simple and Clean: The DSL-based approach makes configuration management easy and intuitive.
  • Type-safe: Your configurations are strongly typed, reducing the risk of runtime errors due to misconfigured values.
  • Wide Format Support: Hoplite supports many common configuration formats, making it versatile.
  • Environment and Argument Parsing: You can easily integrate configurations from environment variables and command-line arguments.

Hoplite is ideal for Kotlin applications that require easy-to-manage, type-safe configurations, and it can be particularly useful for microservices or applications with complex configuration needs.

Related Blog Posts