Computer scienceBackendKtorKtor Advanced

Microservice Architecture with Ktor

7 minutes read

In the modern world of software development, microservice architecture has emerged as a popular design pattern. It breaks down a monolithic application into smaller, independent services that communicate with each other. This approach offers several benefits, such as improved scalability, fault isolation, and the ability to use different technologies for different services.

In this guide, we will create two simple services: User Service and Banking Service. The User Service will handle user-related operations, while the Banking Service will manage banking transactions. These services will communicate with each other, demonstrating the core principles of a microservice architecture.

Base project

When we work with microservices, unlike working with a single service, we will first need to create a project that will not be a purely Ktor project. This project will manage the assembly of all microservices:

Create Empty Project

In this project, we will create another one, which will be called "services". The services themselves will be located here, as well as files for gradle assembly:

Create project from IDE

Project Configuration

Now the structure of your project should be something like this:

KBank/
└── services/
    ├── ...
    ├── src/
    ├── gradle.properties
    ├── build.gradle.kts
    └── settings.gradle.kts

However, we still need to create two subprojects that will be Ktor applications. Therefore, delete the KBank/services/src folder and create two Ktor projects: "user" and "banking" without any plugins:

Create Ktor project

The file structure of the project will look like this:

KBank/
└── services/
    ├── ...
    ├── banking/
    │   ├── ...
    │   ├── build.gradle.kts
    │   └── src/
    │       ├── test/
    │       └── main/
    │           └── kotlin/
    │               ├── resources/
    │               └── com.example/
    │                   ├── ...
    │                   └── Application.kt
    ├── user/
    │   ├── ...
    │   ├── build.gradle.kts
    │   └── src/
    │       ├── test/
    │       └── main/
    │           └── kotlin/
    │               ├── resources/
    │               └── com.example/
    │                   ├── ...
    │                   └── Application.kt
    ├── gradle.properties
    ├── build.gradle.kts
    └── settings.gradle.kts

Now you need to configure the IDE so that it sees the assembly files in the services subfolder. To do this, change the file structure:

Project Structure

Here, click on the "Modules" > plus sign > "Import module" and select the path to the services folder. As a result, you will see this:

Services

Gradle configuration

First, we need to configure the main services/build.gradle.kts, which will initialize the assembly of microservices. In the services/gradle.properties file, you can specify the versions of dependencies that you are going to use in the project in the format:

ktor_version=2.3.2
kotlin_version=1.8.22
logback_version=1.2.11
...

Then, we will pass these versions to the file services/build.gradle.kts:

val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

Also, in the main file build.gradle.kts we have to specify plugins that will be used later in microservices. We will need a Ktor plugin, as well as a serialization plugin:

plugins {
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.serialization") version "1.8.22"
    id("io.ktor.plugin") version "2.3.2"
}

Let's describe the general dependencies that will be connected to all microservices. If a microservice requires some unique dependencies, we will link them separately in the build file of the specific microservice. In the main assembly file, common dependencies are connected in this way:

subprojects {
    apply {
        plugin("org.jetbrains.kotlin.jvm")
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
        implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
        implementation("ch.qos.logback:logback-classic:$logback_version")

        implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
        implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")

        testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
        testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
    }
}

In this context, the apply function is used to add a plugin to each subproject. The specific plugin added here is org.jetbrains.kotlin.jvm, which enables JVM target for the Kotlin language. In other words, it allows Kotlin code to be compiled into JVM bytecode.

Now that we have described the main build file, the build files services/user/build.gradle.kts and services/banking/build.gradle.kts for microservices will look quite small. No need to specify the plugin version here, as the version from the main build.gradle.kts will be used:

plugins {
    kotlin("jvm")
    kotlin("plugin.serialization")
    id("io.ktor.plugin")
}

group = "com.example"

application {
    mainClass.set("com.example.ApplicationKt")
}

The last thing left for us is to tell gradle which microservices need to be included in the build. To do this, add the include method to services/settings.gradle.kts:

include(
    "user",
    "banking"
)

Shared module

Often, microservices use the same code: for example, it can be the same DTO or utilities. To avoid copying such code, we should create a module we call "shared". It contains parts of the code used in different places. To do this, create a New Project with the appropriate name. For convenience, make sure that the all microservices have the same groupId. In our case, this is "com.example":

Create New Project

The file build.gradle.kts for this module will also be very small:

plugins {
    kotlin("jvm")
    kotlin("plugin.serialization")
}

group = "com.example"

Don't forget to add the shared module to the include method inside the services/settings.gradle.kts so that gradle includes it in the build:

include(
    ...
    "shared"
)

Now we can write a piece of code related to our original idea, a banking service. Let's create a DTO of a user and a bank account. These DTO will be used by both services (user and banking). Since these DTOs are used by both services, we create them in the shared module:

services/shared/src/main/kotlin/User.kt

@Serializable
data class User(
    val id: Long,
    val name: String
)

services/shared/src/main/kotlin/Account.kt

@Serializable
data class Account(
    val userId: Long,
    val amount: Double
)

Writing services

Go to the user's service and make an instance of the User class returns to the GET request /me. We need to add a dependency on the shared module since we are going to use the class from there:

services/user/build.gradle.kts

dependencies {
    implementation(project(":shared"))
}

services/user/src/main/kotlin/com/example/Application.kt

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }

    val user = User(id = 1, name = "John Doe")

    routing {
        get("/") {
            call.respond(user)
        }
    }
}

Similarly, update services/banking/build.gradle.kts, and write such logic for the banking service:

services/banking/src/main/kotlin/com/example/Application.kt

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }

    val account = Account(userId = 1, amount = 100_000.0)

    routing {
        get("/") {
            call.respond(account)
        }
    }
}

However, such a configuration of services will not start at the same time, since the user's service and the bank's service listen to the same port 8080. To fix this, let's change the banking service port to 8090:

services/banking/src/main/kotlin/com/example/Application.kt

fun main() {
    embeddedServer(Netty, port = 8090, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

Now we can run both services at the same time, but they don't know anything about each other and don't interact with each other in any way.

Connecting services

Imagine that we want to make an endpoint GET /account on a user service that will return information about the user and its bank account. For a request between services, we will need an HTTP client. Ktor has its own HTTP client, but we will use Retrofit, as it is great for microservice development.

To begin with, add the dependencies of Retrofit and Gson library for JSON serialization to the services/user/build.gradle.kts file:

dependencies {
    ...
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
}

Although the kotlinx approach is used for sterilization in the project, GSON is more compatible specifically for Retrofit.

Now let's define the banking service interface for the user service:

services/user/src/main/kotlin/com/example/BankingService.kt

interface BankingService {
    @GET("/")
    suspend fun getAccount(): Account
}

The @GET annotation tells Retrofit that this is a GET request.

Before you can use your BankingService interface, you need to build an instance of Retrofit:

services/user/src/main/kotlin/com/example/RetrofitManager.kt

object RetrofitManager {
    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("http://localhost:8090")
        .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
        .build()

    private val service = retrofit.create(BankingService::class.java)

    fun getBankingService(): BankingService = service
        ?: throw Exception("The banking service is not responding")
}

This will create a new instance of Retrofit with the base URL for your API and the GsonConverterFactory which is used to convert the JSON responses from your API into Kotlin objects.

Finally, we can update the endpoints of the user service:

services/user/src/main/kotlin/com/example/UserAccount.kt

class UserAccount(
    val user: User,
    val account: Account
)

services/user/src/main/kotlin/com/example/Application.kt

routing {
    ...
    get("/account") {
        val service = RetrofitManager.getBankingService()
        val account = service.getAccount()
        call.respond(UserAccount(user = user, account = account))
    }
}

Now we can check if everything is working. We will launch both services and make the appropriate requests:

Request to user service

Request to banking service

Request to user and banking service

Conclusion

We learned how to create a multi-service project, made sure that the services communicate with each other and receive the necessary information, and also set up a shared module to avoid code repetition.

Thus, we have achieved that we have created two services with a strictly limited area of responsibility, which makes it easier to develop and maintain large projects.

3 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo