Разработка бэкенда сайта на 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-фреймворк от JetBrains, написанный на 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) }
    }
}

Handler

@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 проекта, где можно переиспользовать модели и логику.