Ktor CRUD Rest Microservice

How to implement a Ktor microservice using Onion Architecture, persistence with exposed and coroutines

1. Overview

This tutorial will provide an introduction to building Kotlin microservices following the Onion Architecture as a design pattern in order to have a good clean component separation it will be used Ktor, Coroutines, and Exposed for persistence. source code be found in our repository

2. Ktor

Is a Kotlin-based web framework for building asynchronous server-side and client-side applications. It is an open-source framework developed by JetBrains, the company behind Kotlin.

Ktor is designed to be lightweight, flexible, and easy to use, making it an excellent choice for building microservices, web APIs, and web applications. It is built on top of coroutines, which allows for asynchronous and non-blocking programming, making it well-suited for building scalable and responsive applications.

Ktor provides a rich set of features, including HTTP client and server, WebSockets, authentication, routing, templating, and more. It also has built-in support for integrating with popular frameworks and technologies, such as Spring and GraphQL.

3. Onion Architecture

Is a software design pattern that has gained popularity in recent years. It is a robust and flexible architecture that can be used for developing software applications of varying complexities.

The onion architecture was introduced by Jeffrey Palermo in 2008 and has since been widely adopted by developers worldwide. The architecture is based on the principle of separating concerns by dividing an application into different layers, each having a distinct responsibility.

It consists of several layers commonly 4 are used, which are depicted as concentric circles, with the innermost layer representing the domain model and the outermost layer representing the user interface or infrastructure. The layers are arranged in such a way that the dependencies flow inwards, with each layer depending on the layer inside it.

Onion Architecture

The innermost layer of the onion architecture is the domain layer, which contains the business logic and rules of the application. This layer is responsible for defining the domain model, which represents the data and behaviour of the application. The domain layer is independent of any other layer and can be tested in isolation.

The next layer is the application layer, which is responsible for coordinating the interactions between the domain layer and the infrastructure layer. This layer contains the application services, which implement the use cases of the application. The application layer acts as a bridge between the domain layer and the infrastructure layer, translating the domain model into a form that can be used by the infrastructure layer, often this layer is broken down into repositories and services.

The infrastructure layer is responsible for providing the technical services required by the application, such as data storage, messaging, and external APIs. This layer contains the implementation details of the application, such as the database access code, message queues, and web services. on this layer, the implementation of the repositories and the integration normally takes place, The infrastructure layer is dependent on the application layer but is independent of the user interface layer.

The outermost layer is the user interface layer, which is responsible for presenting the application to the user. This layer contains the user interface components, such as web pages, mobile apps, or desktop applications. The user interface layer is dependent on the application layer but is independent of the infrastructure layer.

One of the key benefits of the onion architecture is that it allows for a clean separation of concerns, which makes the application more modular and easier to maintain. The architecture also enables a high degree of testability, as each layer can be tested in isolation without requiring dependencies on other layers.

Another benefit of the onion architecture is that it is highly adaptable and can be easily extended or modified to meet changing requirements. Since each layer has a clear responsibility, changes to one layer can be made without affecting the other layers.

for this example, a crud use case will be implemented for a User Entity.

4. Domain

This is the place where entities and domain logic are going to be placed.

                                
data class User(val id: Long?, val name: String, val email: String)
                                
                            

5. Repository

This layer abstracts the data store and enables you to replace your database without changing your business code, for the benefit parallelism we use suspend functions to allow coroutines to work

                                
interface UserRepository {
    suspend fun findAll(): List
    suspend fun findById(id: Long): User?
    suspend fun save(user: User): Unit
    suspend fun update(user: User): Unit
    suspend fun deleteById(id: Long): Unit
}

                                
                            

6. Infrastructure

This is the integration layer that implements database operations, clients, and business-to-business integration.

                                
import com.thesampleapp.domain.User
import com.thesampleapp.domain.UserRepository
import com.thesampleapp.infra.DatabaseFactory.dbQuery
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.config.*
import org.jetbrains.exposed.sql.Table
import kotlinx.coroutines.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.*
import org.jetbrains.exposed.sql.transactions.experimental.*

object Users : Table() {
    val id = long("id").autoIncrement()
    val name = varchar("name", 100)
    val email = varchar("email", 200)
    override val primaryKey = PrimaryKey(id)
}


object DatabaseFactory {
    fun init(config: ApplicationConfig) {
        val driverClassName = config.property("database.driverClassName").getString()
        val jdbcURL = config.property("database.url").getString()
        val username = config.property("database.username").getString()
        val password = config.property("database.password").getString()
        val database = Database.connect(
            createHikariDataSource(
                url = jdbcURL,
                driver = driverClassName,
                aUsername = username,
                aPassword = password
            )
        )
        transaction(database) {
            SchemaUtils.create(Users)
        }
    }

    private fun createHikariDataSource(
        url: String,
        driver: String,
        aUsername: String,
        aPassword: String,
    ) = HikariDataSource(HikariConfig().apply {
        driverClassName = driver
        jdbcUrl = url
        username = aUsername
        password = aPassword
        maximumPoolSize = 3
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })


    suspend fun  dbQuery(block: suspend () -> T): T =
        newSuspendedTransaction(Dispatchers.IO) { block() }
}

class UserRepositoryImpl : UserRepository {
    private fun resultRowToArticle(row: ResultRow) = User(
        id = row[Users.id],
        name = row[Users.name],
        email = row[Users.email],
    )

    override suspend fun findAll(): List = dbQuery {
        Users.selectAll().map(::resultRowToArticle)
    }

    override suspend fun findById(id: Long): User? = dbQuery {
        Users.select { Users.id eq id }.map(::resultRowToArticle).singleOrNull()
    }

    override suspend fun save(user: User) {
        dbQuery {
            val insertStatement = Users.insert {
                it[name] = user.name
                it[email] = user.email
            }
            insertStatement.resultedValues?.singleOrNull()?.let(::resultRowToArticle)
        }
    }

    override suspend fun update(user: User) {
        user.id?.let { id ->
            Users.update({ Users.id eq id }) {
                it[name] = name
                it[email] = email
            } > 0
        }
    }

    override suspend fun deleteById(id: Long) {
        dbQuery {
            Users.deleteWhere { Users.id eq id } > 0
        }
    }

}

val userRepository: UserRepository = UserRepositoryImpl().apply {}
                                
                            

7. HTTP Routes

Routing is the core Ktor plugin for handling incoming HTTP requests in a server application. When the client makes a request to a specific URL (for example, /hello), the routing mechanism allows us to define how we want this request to be served.

                                
import com.thesampleapp.domain.User
import com.thesampleapp.infra.userRepository
import io.ktor.http.*
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.util.*

fun Application.configureUserRouting() {
    routing {
        route("users"){
            get("{id}") {
                val id = call.parameters.getOrFail("id").toLong()
                val user =
                    userRepository.findById(id) ?: return@get call.respondText(
                        "No customer with id $id",
                        status = HttpStatusCode.NotFound
                    )
                call.respond(user)
            }
            put("{id}"){
                val id = call.parameters.getOrFail("id").toLong()
                val formParameters = call.receiveParameters()
                val name = formParameters.getOrFail("name")
                val email = formParameters.getOrFail("email")
                userRepository.update(user = User(id = id, name = name, email = email))
                call.respond(HttpStatusCode.NoContent)
            }
            delete("{id}") {
                val id = call.parameters.getOrFail("id").toLong()
                userRepository.deleteById(id)
                call.respond(HttpStatusCode.NoContent)
            }
            post{
                val formParameters = call.receiveParameters()
                val name = formParameters.getOrFail("name")
                val email = formParameters.getOrFail("email")
                userRepository.save(user = User(id=null,name = name, email = email))
                call.respond(HttpStatusCode.NoContent)
            }
        }
    }
}

                                
                            

8. Configuration

Ktor allows you to configure various server parameters, such as a host address and port, modules to load, and so on. The configuration depends on the way you used to create a server.

Ktor loads its configuration from a configuration file that uses the HOCON or YAML format

                                
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
    application {
        modules = [ com.thesampleapp.ApplicationKt.module ]
    }
}
database {
    driverClassName = "org.h2.Driver"
    url = "jdbc:h2:mem:db;DB_CLOSE_DELAY=-1"
    username = "sa"
    password = "sa"
}

                                
                            

9. Start the Application

To deliver a Ktor server application as a self-contained package, you need to create a server first. Server configuration can include different settings: a server engine (such as Netty, Jetty, etc.), various engine-specific options, host and port values, and so on. There are two main approaches in Ktor for creating and running a server:

  • The embeddedServer function is a simple way to configure server parameters in code and quickly run an application.
  • EngineMain provides more flexibility to configure a server. You can specify server parameters in a file and change a configuration without recompiling your application. Moreover, you can run your application from a command line and override the required server parameters by passing corresponding command-line arguments.
                                

import com.thesampleapp.plugins.configureUserRouting
import com.thesampleapp.plugins.configureSerialization
import io.ktor.server.application.*

fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    configureSerialization()
    configureUserRouting()
}