KTOR as a backend - part 2
In this blog post, we will take significant steps to enhance our game shop backend by incorporating Dependency Injection and integrating a robust Database system. First, we will explore the concept of Dependency Injection, understanding how it can help us manage dependencies more effectively and promote a cleaner, more modular codebase. We will then move on to setting up a Database, we will cover the entire process, from initial setup and configuration to implementing CRUD operations. By the end of this post, you will have a comprehensive understanding of how to improve the architecture and functionality of your game shop backend.
Dependency Injection — users endpoint
With the possibility of adding a game and creating an order, it would be nice to assign such an order with a user — we’ve got some working endpoints, and adding a new one is pretty straightforward, but we can do a bit more and improve our codebase by introducing the dependency injection library instead of using an object with repository — here comes the KOIN a well know DI framework straight from the android world.
I will not elaborate on how KOIN works and dive into the details, everything can be found in the documentation — here, we are just adding di to the project.
In the beginning, we need to add proper dependencies and install the plugin.
implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")g
fun Application.configureDI() {
install(Koin) {
slf4jLogger()
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureDI()
…
}
From now on, we can use all the good stuff that KOIN is capable of. Let’s proceed with some standard classes that we did in previous examples; User, UserRequest, UsersRepository, and UsersRoutes, without going into the details, the repo is still holding the data in a mutable list, but now it will be injected into the routes.
To do so, we need to declare the module, which in our case will be appModule, in this module, we will create the singleton for our repository.
val appModule = module {
single<UsersRepository> { UsersRepositoryImpl() }
}
Then this module needs to be added to the Koin container, after that, we are ready to go and can inject our repository wherever we want.
fun Application.configureDI() {
install(Koin) {
slf4jLogger()
modules(appModule)
}
}
A good place to start is the UsersRoutes, where we can create or get a list of our users. With this simple config, we are good to go.
fun Route.usersRouting() {
val repo by inject<UsersRepository>()
route("/users") {
get {
val users = repo.getUsers()
call.respond(
status = HttpStatusCode.OK,
message = users
)
}
}
Database — users endpoint
For the presentation, it was a simple and quick solution to store data in a mutable list, but the data vanishes with every application restart if we want our data to persist over the server downs and ups, we need to implement a database.
There are a few frameworks that will help us with databases. I will choose the Exposed delivered by Jetbrains as the ORM framework. It will be connected with H2 database — which is not a great choice for production but will be enough as an example.
gradle.properties
exposed_version = 0.36.2
h2_version = 1.4.200
build.gradle.kts
val exposed_version: String by project
val h2_version: String by project
dependencies {
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("com.h2database:h2:$h2_version")
}
For convenience, let’s put everything related to DB in the database package. We need to start with the users’ table to do so let’s create a model first.
object Users : UUIDTable() {
val name = varchar("name", 128)
val date_created = varchar("date_created", 10)
val username = varchar("username", 128)
val password = varchar("password", 128)
}
In a nutshell, the UUIDTable class from Exposed will create a table where the id will be the primary key with the auto-generated UUID, all fields of the stored user are varchar where the varchar function can receive the name of a column, and its max length. For more details about how to create models, you can check the documentation.
After the table is defined, we need to create a DatabaseFactory interface, and its implementation will be responsible for establishing the connection with the database.
interface DatabaseFactory {
fun create()
}
class DatabaseFactoryImpl : DatabaseFactory {
private companion object {
const val driverClassName = "org.h2.Driver"
const val jdbcURL = "jdbc:h2:file:./build/db"
}
override fun create() {
Database.connect(jdbcURL, driverClassName)
}
}
This should be enough for our DB to be available in the project. There is one more thing that we need to do — create the Users table. To do so, we can create a helper method that will be responsible for creating the schemas, let’s go back to DatabaseFactoryImpl.
private object SchemaDefinition {
fun createSchema() {
transaction {
SchemaUtils.create(Users)
}
}
}
fun Application.configureDatabase() {
…
SchemaDefinition.createSchema()
}
The database will be created in the build folder of our project. Then we need to add this factory to our Koin module and use it at the start of our server.
val appModule = module {
…
single<DatabaseFactory> { DatabaseFactoryImpl() }
}
fun Application.configureDatabase() {
val databaseFactory by inject<DatabaseFactory>()
databaseFactory.create()
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
…
configureDatabase()
}.start(wait = true)
}
So from now on, users can be stored in the database — but this is only halfway — we need to improve our repository to work with the DB fully. For this, we will create the UsersDAOFacade interface with implementation — this pattern will hide the details of DB from the application domain.
interface UsersDAOFacade {
suspend fun createUser(userRequest: UserRequest): User?
suspend fun getUsers(): List<User>
}
Instead of using blocking transactions in every DB query, we can use the coroutines and handle every query in a single coroutine — this little helper function can do the trick:
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
The implementation of the facade will be very easy. We need to map requests to the database representation and the other way around. Because the definition of a table is a kotlin object, we can access its properties everywhere.
override suspend fun createUser(userRequest: UserRequest) = dbQuery {
Users.insert {
it[name] = userRequest.name
it[username] = userRequest.username
it[password] = userRequest.password
it[date_created] = Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()
}
.resultedValues?.singleOrNull()?.toUser()
}
override suspend fun getUsers(): List<User> = dbQuery {
Users.selectAll().map { it.toUser() }
}
For the date handling, we are using kotlinx-datetime the functions are pretty clear, each db request is using our helper function, each request returns the ResultRow object that holds the data. The obtained data is then mapped to/from domain objects.
private fun ResultRow.toUser(): User {
return this.let {
User(
id = it[Users.id].toString(),
name = it[Users.name],
username = it[Users.username]
)
}
}
The last thing to do is to add the DB to the repository, let’s change the mutableList to the proper functions that will use a DB. For the Repository, we will inject the repo with the constructor (which includes a slight change in the koin module)
class UsersRepositoryImpl(private val dao: UsersDAOFacade) : UsersRepository {
override suspend fun addUser(userRequest: UserRequest): User? {
return dao.createUser(userRequest)
}
override suspend fun getUsers(): List<User> {
return dao.getUsers()
}
}
val appModule = module {
single<UsersRepository> { UsersRepositoryImpl(get()) }
}
From now on, every added user will be stored in the database and persisted between the server restarts. To start with the fresh DB, you can always go to /build folder and remove the database. There are some useful tools for handling migrations, for example, liquibase (which is not a part of this article).
We’ve reached the end of the second part of the article. I hope you’ve enjoyed it and you can’t wait for more.
This post was originally published on Speednet blog on 10.11.2022
Subscribe to my newsletter
Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by