Розробка бекенду сайту на Kotlin (Ktor)

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка бекенду сайту на Kotlin (Ktor)
Складна
від 1 тижня до 3 місяців
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка бекенду сайту на Kotlin (Ktor)

Ktor — HTTP-фреймворк із екосистеми Tokio, написаний на Kotlin для Kotlin. Він не намагається бути Spring Boot — жодної магії аннотацій, жодного сканування classpath. Додаток збирається вручну через DSL: встановлюєте плагіни, описуєте маршрути, налаштовуєте серіалізацію. Це робить поведінку передбачуваною і легко тестованою.

Kotlin-корутини — не надстройка над потоками, а першокласний механізм. Ktor використовує їх нативно: кожен запит обробляється в корутині, I/O — неблокуючий. Це дає хорошу продуктивність при скромному споживанні пам'яті.

Ktor вибирають: команди з Kotlin/Android-фоном, проекти де важна корутинна модель, розробка mobile-backend (Kotlin Multiplatform).

Налаштування додатку

// Application.kt
fun main() {
    embeddedServer(Netty, port = System.getenv("PORT")?.toInt() ?: 8080) {
        configureApplication()
    }.start(wait = true)
}

fun Application.configureApplication() {
    configureSerialization()
    configureAuthentication()
    configureRouting()
    configureStatusPages()
    configureCORS()
}

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = false
            isLenient = false
            ignoreUnknownKeys = true
            encodeDefaults = false
            serializersModule = SerializersModule {
                // кастомні сериалізатори
            }
        })
    }
}

fun Application.configureCORS() {
    install(CORS) {
        allowMethod(HttpMethod.Options)
        allowMethod(HttpMethod.Put)
        allowMethod(HttpMethod.Delete)
        allowHeader(HttpHeaders.Authorization)
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        System.getenv("ALLOWED_ORIGINS")?.split(",")?.forEach { host ->
            allowHost(host.trim(), schemes = listOf("https", "http"))
        }
    }
}

Маршрутизація

fun Application.configureRouting() {
    routing {
        route("/api/v1") {
            authRoutes()

            route("/products") {
                get { /* публічний */ productHandler.list(call) }
                get("/{id}") { productHandler.get(call) }

                authenticate("jwt") {
                    post { productHandler.create(call) }
                    put("/{id}") { productHandler.update(call) }
                    delete("/{id}") {
                        call.requireRole("admin")
                        productHandler.delete(call)
                    }
                }
            }

            authenticate("jwt") {
                get("/profile") { authHandler.profile(call) }
            }
        }
    }
}

fun Route.authRoutes() {
    route("/auth") {
        post("/login") { authHandler.login(call) }
        post("/refresh") { authHandler.refresh(call) }
    }
}

Обробник

@Serializable
data class CreateProductRequest(
    val name: String,
    val price: Double,
    val categoryId: Long? = null,
    val description: String? = null
)

@Serializable
data class ProductResponse(
    val id: Long,
    val name: String,
    val slug: String,
    val price: Double,
    val category: CategoryDto? = null,
    val createdAt: String
)

class ProductHandler(private val service: ProductService) {

    suspend fun list(call: ApplicationCall) {
        val page  = call.request.queryParameters["page"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1
        val limit = call.request.queryParameters["limit"]?.toIntOrNull()
            ?.coerceIn(1, 100) ?: 20
        val categoryId = call.request.queryParameters["category_id"]?.toLongOrNull()

        val (products, total) = service.list(page, limit, categoryId)
        call.respond(mapOf(
            "data" to products,
            "pagination" to mapOf("page" to page, "limit" to limit, "total" to total)
        ))
    }

    suspend fun create(call: ApplicationCall) {
        val req = call.receive<CreateProductRequest>()
        validate(req)
        val product = service.create(req, call.principal<JWTPrincipal>()!!.userId)
        call.respond(HttpStatusCode.Created, product)
    }

    suspend fun get(call: ApplicationCall) {
        val id = call.parameters["id"]?.toLongOrNull()
            ?: throw BadRequestException("Invalid id")
        val product = service.findById(id) ?: throw NotFoundException("Product not found")
        call.respond(product)
    }

    private fun validate(req: CreateProductRequest) {
        val errors = mutableMapOf<String, String>()
        if (req.name.length < 2) errors["name"] = "Мінімум 2 символи"
        if (req.price <= 0) errors["price"] = "Ціна має бути більше нуля"
        if (errors.isNotEmpty()) throw UnprocessableEntityException(errors)
    }
}

JWT аутентифікація

fun Application.configureAuthentication() {
    val secret  = System.getenv("JWT_SECRET") ?: error("JWT_SECRET not set")
    val issuer  = System.getenv("JWT_ISSUER") ?: "https://myapp.com"

    install(Authentication) {
        jwt("jwt") {
            realm = "myapp"
            verifier(JWT.require(Algorithm.HMAC256(secret)).withIssuer(issuer).build())
            validate { credential ->
                if (credential.payload.getClaim("sub").asString().isNullOrBlank()) null
                else JWTPrincipal(credential.payload)
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Invalid or expired token"))
            }
        }
    }
}

val JWTPrincipal.userId: Long
    get() = payload.getClaim("sub").asString().toLong()

val JWTPrincipal.role: String
    get() = payload.getClaim("role").asString() ?: "user"

suspend fun ApplicationCall.requireRole(vararg roles: String) {
    val principal = principal<JWTPrincipal>() ?: throw UnauthorizedException()
    if (principal.role !in roles) {
        throw ForbiddenException("Required role: ${roles.joinToString()}")
    }
}

База даних через Exposed

Exposed — Kotlin SQL-бібліотека від JetBrains з type-safe DSL:

// Оголошення схеми
object ProductsTable : LongIdTable("products") {
    val name       = varchar("name", 255)
    val slug       = varchar("slug", 255).uniqueIndex()
    val price      = decimal("price", 10, 2)
    val categoryId = long("category_id").nullable()
    val isActive   = bool("is_active").default(true)
    val createdAt  = timestamp("created_at").defaultExpression(CurrentTimestamp)
}

// Repository
class ProductRepository(private val db: Database) {

    suspend fun findAll(page: Int, limit: Int, categoryId: Long?): Pair<List<Product>, Long> =
        db.dbQuery {
            val query = ProductsTable
                .leftJoin(CategoriesTable, { ProductsTable.categoryId }, { CategoriesTable.id })
                .select { ProductsTable.isActive eq true }
                .apply {
                    if (categoryId != null) andWhere { ProductsTable.categoryId eq categoryId }
                }

            val total    = query.count()
            val products = query
                .orderBy(ProductsTable.createdAt to SortOrder.DESC)
                .limit(limit, offset = ((page - 1) * limit).toLong())
                .map { toProduct(it) }

            products to total
        }

    suspend fun insert(dto: CreateProductRequest): Product = db.dbQuery {
        val id = ProductsTable.insertAndGetId {
            it[name]  = dto.name
            it[slug]  = dto.name.toSlug()
            it[price] = dto.price.toBigDecimal()
        }.value

        ProductsTable.select { ProductsTable.id eq id }.first().let { toProduct(it) }
    }
}

// Допоміжна функція для корутин
suspend fun <T> Database.dbQuery(block: () -> T): T =
    withContext(Dispatchers.IO) {
        transaction { block() }
    }

Обробка помилок

fun Application.configureStatusPages() {
    install(StatusPages) {
        exception<BadRequestException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, mapOf("error" to cause.message))
        }
        exception<NotFoundException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, mapOf("error" to cause.message))
        }
        exception<UnprocessableEntityException> { call, cause ->
            call.respond(HttpStatusCode.UnprocessableEntity, mapOf("errors" to cause.errors))
        }
        exception<ForbiddenException> { call, cause ->
            call.respond(HttpStatusCode.Forbidden, mapOf("error" to cause.message))
        }
        exception<Throwable> { call, cause ->
            application.log.error("Unhandled exception", cause)
            call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Internal server error"))
        }
    }
}

Тестування

Ktor має чудову підтримку тестів через testApplication:

class ProductRouteTest {

    @Test
    fun `GET products returns paginated list`() = testApplication {
        application {
            configureApplication()
            // Замінюємо залежності на моки
        }

        val response = client.get("/api/v1/products?page=1&limit=10")

        assertEquals(HttpStatusCode.OK, response.status)
        val body = Json.decodeFromString<Map<String, Any>>(response.bodyAsText())
        assertNotNull(body["data"])
        assertNotNull(body["pagination"])
    }

    @Test
    fun `POST products returns 401 without token`() = testApplication {
        application { configureApplication() }

        val response = client.post("/api/v1/products") {
            contentType(ContentType.Application.Json)
            setBody("""{"name": "Test", "price": 10.0}""")
        }

        assertEquals(HttpStatusCode.Unauthorized, response.status)
    }
}

Графік розробки

  • Налаштування + плагіни + DI (Koin) — 4–6 днів
  • Routing + handlers + serialization — 1–1,5 тижні
  • Auth + JWT — 3–5 днів
  • Database layer (Exposed + міграції Flyway) — 1 тиждень
  • Тести — 1 тиждень
  • Docker + CI — 2–3 дні

Бекенд середнього масштабу: 7–12 тижнів. Ktor — відмінний вибір для команд з Kotlin-опитом, особливо при наявності Android/KMP проекту, де можна переіспользувати моделі та логіку.