Ktor(ケイター)は、JetBrains が作っている Kotlin のサーバー用フレームワークです。 注釈ではなく、Kotlin のコード(DSL)でルートを書きます。必要な機能は「プラグイン」として install で足していく作りです。

fun Application.module() {
    install(ContentNegotiation) { json() }       // JSON の変換
    routing {
        get("/hello") { call.respond(mapOf("message" to "Hello")) }
    }
}

REST API でよく使うプラグインは次の4つです。

プラグイン 役割 依存(io.ktor:…)
Routing URL とメソッドで処理を振り分ける ktor-server-core に入っている
ContentNegotiation JSON とクラスを相互に変換する ktor-server-content-negotiation・ktor-serialization-kotlinx-json
StatusPages 例外やステータスごとに、返すレスポンスを決める ktor-server-status-pages
RequestValidation 受け取った値を検証する ktor-server-request-validation

確認環境:Ktor 3.6.0(ktor.io のドキュメントの版、start.ktor.io の既定)と Kotlin 2.4.0(start.ktor.io の既定)。API と設定は ktor.io のドキュメントで、既定のステータスや例外の振り分けは Ktor のリポジトリのタグ 3.6.0 のソースコードとテストで確かめました。「ハンドラでスレッドをふさぐとどうなるか」は、kotlinc 2.4.20 と、同梱の kotlinx-coroutines-core 1.8.0(OpenJDK 21.0.10、Linux)で計測しました(確認日 2026年10月12日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Ktor 入門|REST APIをJSON・例外処理・テストまで作る」

プロジェクトの作り方

ktor.io のドキュメントでは、作り方は3つです。

方法 手順
Web の生成ツール start.ktor.io で名前を入れ、プラグインを選んで Download
IntelliJ IDEA Ultimate New Project → Ktor
Ktor CLI brew install ktor(Windows は winget install JetBrains.KtorCLI)→ ktor new

start.ktor.io の設定(2026年10月12日時点)は次の通りです。

項目 既定 ほかの選択肢
Ktor 3.6.0 —
Kotlin 2.4.0 —
ビルド Gradle Kotlin(build.gradle.kts) Kotlin Toolchain・Maven
エンジン(HTTP サーバー) Netty Jetty・CIO・Tomcat
設定ファイル YAML コード・HOCON

この記事のコードを作るなら、プラグインは Routing・Content Negotiation・kotlinx.serialization・Status Pages・Request Validation を選びます。

build.gradle.kts とファイル構成

plugins {
    kotlin("jvm") version "2.4.0"
    kotlin("plugin.serialization") version "2.4.0"   // @Serializable のコードを生成する
    id("io.ktor.plugin") version "3.6.0"             // Ktor の版をそろえる。依存の版を省ける
}

group = "com.example"
version = "0.0.1"

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

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-server-core")
    implementation("io.ktor:ktor-server-netty")
    implementation("io.ktor:ktor-server-content-negotiation")
    implementation("io.ktor:ktor-serialization-kotlinx-json")
    implementation("io.ktor:ktor-server-status-pages")
    implementation("io.ktor:ktor-server-request-validation")
    implementation("ch.qos.logback:logback-classic:1.6.3")

    testImplementation("io.ktor:ktor-server-test-host")
    testImplementation("io.ktor:ktor-client-content-negotiation")
    testImplementation(kotlin("test"))
}

kotlin {
    jvmToolchain(21)
}

tasks.test {
    useJUnitPlatform()
}
行 意味
kotlin("plugin.serialization") kotlinx.serialization のコンパイラプラグイン。これが無いと @Serializable のクラスを JSON にできない
id("io.ktor.plugin") ドキュメントでは、これを入れると Ktor の依存の版を省けます
ktor-server-netty エンジン。Jetty・CIO・Tomcat に替えるときはここを替える
logback-classic ログの出力先。1.6.3 は、Ktor 3.6.0 のリポジトリの版の一覧で使われている版
ktor-server-test-host testApplication で、サーバーを立てずにテストする
ktor-client-content-negotiation テストのクライアントで JSON を送受信する

ドキュメントでは、settings.gradle.kts で io.ktor:ktor-version-catalog:3.6.0 を読み込み、implementation(ktorLibs.server.core) のように書く方法も紹介されています。start.ktor.io が作るプロジェクトはこちらの形です。

src/main/kotlin/com/example/
├── Application.kt   ← main と module
├── Task.kt          ← データの形と保存先
├── Plugins.kt       ← JSON・検証・例外の設定
└── Routing.kt       ← ルート
src/main/resources/logback.xml
src/test/kotlin/com/example/ApplicationTest.kt

Gradle の書き方の対訳は、関連記事「build.gradle書き方|Groovy/Kotlin DSL対訳と依存設定一覧」で扱っています。

完成コード:タスク管理の REST API

/tasks に一覧・取得・登録・更新・削除の5つを作ります。

Application.kt

package com.example

import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

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

fun Application.module() {
    appModule(InMemoryTaskRepository())
}

// テストでは保存先を差し替えられるように、引数で受け取る
fun Application.appModule(repository: TaskRepository) {
    configureSerialization()
    configureValidation()
    configureStatusPages()
    configureRouting(repository)
}

fun Application.module() は、Application クラスの拡張関数です。Ktor のアプリは「モジュール」と呼ぶこの関数の中で、プラグインとルートを組み立てます。

Task.kt(データの形と保存先)

package com.example

import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong

@Serializable
data class Task(val id: Long, val title: String, val done: Boolean = false)

@Serializable
data class TaskRequest(val title: String, val done: Boolean = false)

@Serializable
data class ErrorResponse(val status: Int, val message: String, val details: List<String> = emptyList())

interface TaskRepository {
    fun all(): List<Task>
    fun find(id: Long): Task?
    fun add(request: TaskRequest): Task
    fun update(id: Long, request: TaskRequest): Task?
    fun delete(id: Long): Boolean
}

class InMemoryTaskRepository : TaskRepository {
    private val tasks = ConcurrentHashMap<Long, Task>()
    private val sequence = AtomicLong(0)

    override fun all(): List<Task> = tasks.values.sortedBy { it.id }

    override fun find(id: Long): Task? = tasks[id]

    override fun add(request: TaskRequest): Task {
        val task = Task(sequence.incrementAndGet(), request.title, request.done)
        tasks[task.id] = task
        return task
    }

    override fun update(id: Long, request: TaskRequest): Task? =
        tasks.computeIfPresent(id) { _, old -> old.copy(title = request.title, done = request.done) }

    override fun delete(id: Long): Boolean = tasks.remove(id) != null
}
  • @Serializable を付けたクラスだけが JSON に変換できます。 付け忘れると、送受信のときにエラーになります。
  • リクエスト(TaskRequest)とレスポンス(Task)を分けると、利用者が id を送ってきても無視できます。
  • 保存先はメモリの ConcurrentHashMap です。リクエストは複数のスレッドから同時に来るため、普通の HashMap は使いません。

Plugins.kt(JSON・検証・例外)

package com.example

import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.application.log
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.ContentTransformationException
import io.ktor.server.plugins.NotFoundException
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.requestvalidation.RequestValidation
import io.ktor.server.plugins.requestvalidation.RequestValidationException
import io.ktor.server.plugins.requestvalidation.ValidationResult
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond

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

fun Application.configureValidation() {
    install(RequestValidation) {
        validate<TaskRequest> { request ->
            when {
                request.title.isBlank() -> ValidationResult.Invalid("title を入力します")
                request.title.length > 100 -> ValidationResult.Invalid("title は100文字までです")
                else -> ValidationResult.Valid
            }
        }
    }
}

fun Application.configureStatusPages() {
    install(StatusPages) {
        exception<RequestValidationException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "入力に誤りがあります", cause.reasons))
        }
        exception<BadRequestException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, cause.message ?: "Bad Request"))
        }
        exception<NotFoundException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse(404, cause.message ?: "Not Found"))
        }
        exception<ContentTransformationException> { call, cause ->
            call.respond(HttpStatusCode.UnsupportedMediaType, ErrorResponse(415, cause.message ?: "Unsupported Media Type"))
        }
        exception<Throwable> { call, cause ->
            call.application.log.error("想定外のエラー", cause)
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse(500, "サーバーでエラーが起きました"))
        }
        // どのルートにも当たらなかったときも JSON で返す
        status(HttpStatusCode.NotFound) { call, status ->
            call.respond(status, ErrorResponse(404, "見つかりません"))
        }
    }
}

Routing.kt

package com.example

import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.NotFoundException
import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import io.ktor.server.routing.routing

fun Application.configureRouting(repository: TaskRepository) {
    routing {
        route("/tasks") {
            get {
                call.respond(repository.all())
            }

            get("/{id}") {
                val id = call.taskId()
                val task = repository.find(id) ?: throw NotFoundException("ID $id のタスクはありません")
                call.respond(task)
            }

            post {
                val request = call.receive<TaskRequest>()   // JSON の変換と RequestValidation の検証
                val task = repository.add(request)
                call.response.header(HttpHeaders.Location, "/tasks/${task.id}")
                call.respond(HttpStatusCode.Created, task)
            }

            put("/{id}") {
                val id = call.taskId()
                val request = call.receive<TaskRequest>()
                val task = repository.update(id, request) ?: throw NotFoundException("ID $id のタスクはありません")
                call.respond(task)
            }

            delete("/{id}") {
                val id = call.taskId()
                if (!repository.delete(id)) throw NotFoundException("ID $id のタスクはありません")
                call.respond(HttpStatusCode.NoContent)
            }
        }
    }
}

private fun ApplicationCall.taskId(): Long =
    parameters["id"]?.toLongOrNull() ?: throw BadRequestException("id は数値で指定します")
書き方 意味
route("/tasks") { … } 共通のパスでまとめる
get("/{id}") { … } {id} がパスの変数。call.parameters["id"] で文字列として取れる
call.receive<T>() リクエストの本文を T に変換する(ContentNegotiation が使われる)
call.respond(値)・call.respond(状態, 値) 値を JSON にして返す。状態を省くと 200
call.response.header(…) レスポンスのヘッダーを足す

toLongOrNull() は、数値にできない文字列で null を返す Kotlin の関数です。?: throw と組み合わせると、「数値でなければ 400」を1行で書けます。Kotlin の型変換は、関連記事「Kotlin 型変換とキャスト as・as?・is と toIntOrNull」で扱っています。

ドキュメントでは、Ktor 3.6 には call.requirePathParameter("id")(無ければ MissingRequestParameterException)という書き方もあります。

logback.xml

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
    <logger name="io.netty" level="INFO"/>
</configuration>

動かし方と、このコードが返すもの

./gradlew run

curl -i -X POST localhost:8080/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"牛乳を買う"}'
リクエスト 返すもの
POST /tasks(正しい JSON) 201・Location: /tasks/1 と {"id":1,"title":"牛乳を買う","done":false}
POST /tasks(title が空) 400・details に「title を入力します」
POST /tasks(JSON が壊れている・項目が無い) 400・Failed to convert request body to …
POST /tasks(Content-Type: text/plain) 415
GET /tasks/abc 400・「id は数値で指定します」
GET /tasks/999 404・「ID 999 のタスクはありません」
GET /nothing(ルートが無い) 404・「見つかりません」
DELETE /tasks/1 204・本文なし

JSON の変換:ContentNegotiation と kotlinx.serialization

json() に何も渡さないと、Ktor が用意している DefaultJson という設定が使われます。Ktor 3.6.0 のソースでは次の通りです。

設定 DefaultJson の値 意味
encodeDefaults true 既定値のままのプロパティも JSON に出す("done":false)
isLenient true 引用符の無いキーなど、ゆるい JSON も読む
allowSpecialFloatingPointValues true NaN・Infinity を扱う
allowStructuredMapKeys true Map のキーにオブジェクトを使える
prettyPrint false 改行・インデントなしで出す
ignoreUnknownKeys 指定なし(kotlinx.serialization の既定は false) 知らないキーが来ると変換エラー

ignoreUnknownKeys が false なので、{"title":"a","priority":1} のように、クラスに無いキーを送ると 400 になります。 クライアントが余分な項目を送る API では、設定を変えます。

install(ContentNegotiation) {
    json(Json {
        ignoreUnknownKeys = true
        encodeDefaults = true
    })
}

Json { … } で作り直すと DefaultJson の設定は引き継がれないので、要る設定(encodeDefaults など)は書き直します。

JSON の変換に失敗すると、Ktor は BadRequestException を投げます。 ContentNegotiation のソース(RequestConverter.kt)では、変換中の例外をすべて受けて、次のように投げ直しています。

throw BadRequestException("Failed to convert request body to ${receiveType.type}", cause)

元の例外(kotlinx.serialization の SerializationException など)は cause に入っています。

例外処理:StatusPages と、Ktor の既定のステータス

StatusPages が無いときの既定のステータス

ハンドラで投げた例外を誰も受けないとき、Ktor は例外の型でステータスを決めます。Ktor 3.6.0 のソース(defaultExceptionStatusCode)の対応です。

例外 既定のステータス
BadRequestException(MissingRequestParameterException・ParameterConversionException を含む) 400
NotFoundException 404
UnsupportedMediaTypeException・CannotTransformContentToTypeException 415
PayloadTooLargeException 413
TimeoutException・TimeoutCancellationException 504
それ以外(IllegalArgumentException など) 500

このとき、本文には例外のメッセージがそのまま入ります(ソースでは call.respond(statusCode, error.message))。中の情報を外に出したくない場合や、エラーの JSON の形をそろえたい場合に、StatusPages を使います。

気をつけたい2つの点(ソースから分かること)

① RequestValidationException は 400 ではなく 500 になる。 ソースでは RequestValidationException は IllegalArgumentException を継承していて、BadRequestException の仲間ではありません。上の対応表に無いので、StatusPages で受けないと 500 です。上のコードでは exception<RequestValidationException> で 400 にしています。

② exception<Throwable> を書くと、Ktor の既定(400・404・415)も、その処理に吸い込まれる。 StatusPages は「例外の型に一致する登録のうち、一番近い親クラス」を選びます(ソースの selectNearestParentClass)。Throwable だけを登録すると、BadRequestException も Throwable の処理に来て 500 になります。上のコードで BadRequestException・NotFoundException・ContentTransformationException を別に登録しているのはこのためです。登録の順番は関係なく、型の近さで決まります。

StatusPages の書き方 意味
exception<T> { call, cause -> … } T とその子の例外を受ける
status(HttpStatusCode.NotFound) { call, status -> … } そのステータスで返るときに、内容を差し替える
statusFile(…, filePattern = "error#.html") # をステータスの番号にしたファイルを返す

exception の処理が 404 を返したときは、同じリクエストで status(NotFound) の処理は動きません(ソースでは、一度 StatusPages が動いたリクエストに印を付けて、二重に動かないようにしています)。

Kotlin の例外の基本は、関連記事「Kotlin例外処理 try-catchとrunCatchingの使い分け」で扱っています。

入力チェック:RequestValidation

install(RequestValidation) {
    validate<TaskRequest> { request ->
        if (request.title.isBlank()) ValidationResult.Invalid("title を入力します")
        else ValidationResult.Valid
    }
}
  • validate<T> は、call.receive<T>() で T を受け取ったときに動きます。
  • ValidationResult.Invalid には、文字列1つか、文字列のリストを渡せます。
  • 失敗すると RequestValidationException が投げられ、reasons に理由のリストが入ります。

Spring Boot の @NotBlank のような注釈ではなく、Kotlin のコードで条件を書く方式です。

テスト:testApplication

package com.example

import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ApplicationTest {

    private fun ApplicationTestBuilder.jsonClient() = createClient {
        install(ContentNegotiation) { json() }
    }

    @Test
    fun `登録すると201でLocationが返る`() = testApplication {
        application { appModule(InMemoryTaskRepository()) }
        val client = jsonClient()

        val response = client.post("/tasks") {
            contentType(ContentType.Application.Json)
            setBody(TaskRequest(title = "牛乳を買う"))
        }

        assertEquals(HttpStatusCode.Created, response.status)
        val task = response.body<Task>()
        assertEquals("牛乳を買う", task.title)
        assertEquals("/tasks/${task.id}", response.headers[HttpHeaders.Location])
    }

    @Test
    fun `titleが空なら400で理由が返る`() = testApplication {
        application { appModule(InMemoryTaskRepository()) }
        val client = jsonClient()

        val response = client.post("/tasks") {
            contentType(ContentType.Application.Json)
            setBody(TaskRequest(title = ""))
        }

        assertEquals(HttpStatusCode.BadRequest, response.status)
        assertEquals(listOf("title を入力します"), response.body<ErrorResponse>().details)
    }

    @Test
    fun `壊れたJSONは400`() = testApplication {
        application { appModule(InMemoryTaskRepository()) }

        val response = client.post("/tasks") {
            contentType(ContentType.Application.Json)
            setBody("""{"title":""")
        }

        assertEquals(HttpStatusCode.BadRequest, response.status)
    }

    @Test
    fun `text_plainは415`() = testApplication {
        application { appModule(InMemoryTaskRepository()) }

        val response = client.post("/tasks") {
            contentType(ContentType.Text.Plain)
            setBody("牛乳を買う")
        }

        assertEquals(HttpStatusCode.UnsupportedMediaType, response.status)
    }

    @Test
    fun `無いIDは404でJSONが返る`() = testApplication {
        application { appModule(InMemoryTaskRepository()) }

        val response = client.get("/tasks/999")

        assertEquals(HttpStatusCode.NotFound, response.status)
        assertTrue("ID 999" in response.bodyAsText())
    }
}
ポイント 説明
testApplication { … } サーバーを立てずに、メモリの中でアプリを動かす
application { appModule(…) } テストで使うモジュールを読み込む
client 最初から用意されているクライアント。本文は文字列で扱う
createClient { install(ContentNegotiation) { json() } } JSON の送受信ができるクライアントを作る
response.body<T>() JSON をクラスに戻す(クライアントに ContentNegotiation が要る)

モジュールは application { } で明示して読み込みます。 testApplication の KDoc には「resources の application.conf のモジュールを自動で読み込む」という注記がありますが、Ktor 3.6.0 のソースでは、設定を指定しないと空の設定が使われます。Ktor 自身のテストにも testDefaultConfigDoesNotLoad(既定の設定は読み込まれない)というテストがあります。設定ファイルを使うときは、ドキュメントにある通り configure("application.yaml") のように指定します。

@Test
fun `設定ファイルのモジュールを使う`() = testApplication {
    configure("application.yaml")
    // ...
}

configure() を引数なしで呼ぶと既定の設定ファイルが、ファイル名を渡すとそのファイルが読み込まれ、書かれたモジュールも動きます(Ktor のテスト testExplicitDefaultConfig・testConfigLoadsModules より)。

設定ファイルで起動する(EngineMain)

上のコードは embeddedServer で、ポートなどをコードに書いています。設定ファイルに書く場合は、ドキュメントの例では次の形です。

# src/main/resources/application.yaml
ktor:
  deployment:
    port: 8080
  application:
    modules:
      - com.example.ApplicationKt.module
// build.gradle.kts
application {
    mainClass.set("io.ktor.server.netty.EngineMain")
}

YAML を読むには io.ktor:ktor-server-config-yaml も依存に足します(start.ktor.io で YAML を選ぶと入ります)。

ハンドラは suspend 関数:ブロッキング処理の扱い(kotlinc で計測)

Ktor のルートの { … } の中は suspend 関数(コルーチン)です。 少ない数のスレッドで多くのリクエストをさばく作りなので、JDBC のような「スレッドをふさぐ処理」をそのまま書くと、ほかのリクエストが待たされます。

Ktor そのものではなく、「ハンドラを動かすスレッドが2本」という見立てで、10件のリクエストを同時に処理する時間を計りました(3回の値)。

val handlerThreads = Executors.newFixedThreadPool(2).asCoroutineDispatcher()

suspend fun handleBlocking() { Thread.sleep(200) }                               // ブロッキング
suspend fun handleSuspend() { delay(200) }                                       // 中断する
suspend fun handleOffload() = withContext(Dispatchers.IO) { Thread.sleep(200) } // IO に逃がす
ハンドラの中身 1回目 2回目 3回目
Thread.sleep(200) 1073 ms 1016 ms 1015 ms
delay(200) 205 ms 208 ms 211 ms
withContext(Dispatchers.IO) { Thread.sleep(200) } 214 ms 217 ms 239 ms

Thread.sleep では、2本のスレッドを10件が取り合い、200 ms × 5回分の約1秒かかりました。 delay は待っている間スレッドを手放すので、10件がほぼ同時に終わりました。ブロッキングする処理でも、withContext(Dispatchers.IO) で別のスレッドに逃がすと、同じくらいの時間で終わりました。

DB のドライバなど、ブロッキングする処理を Ktor から呼ぶときは withContext(Dispatchers.IO) { … } で包みます。

get("/{id}") {
    val id = call.taskId()
    val task = withContext(Dispatchers.IO) { jdbcRepository.find(id) }
        ?: throw NotFoundException("ID $id のタスクはありません")
    call.respond(task)
}

コルーチンと withContext の基本は、関連記事「Kotlinコルーチン入門|launch・async・runBlocking」で扱っています。

Spring Boot との比較表

観点 Spring Boot Ktor
プロジェクトの作成 start.spring.io start.ktor.io・IntelliJ IDEA・Ktor CLI
ルートの書き方 注釈(@GetMapping("/x")) DSL(get("/x") { … })
機能の足し方 スターターを入れると自動設定 プラグインを install で明示
JSON Jackson(jackson-module-kotlin) kotlinx.serialization(@Serializable)
例外 → レスポンス @RestControllerAdvice・@ExceptionHandler StatusPages の exception<T>
入力チェック Bean Validation(@field:NotBlank) RequestValidation の validate<T>
DI(依存の注入) 標準で入っている ktor-server-di プラグインなど
DB Spring Data JPA など 自分でライブラリを選ぶ
HTTP サーバー Tomcat(webmvc スターター) Netty・Jetty・CIO・Tomcat から選ぶ
処理のしかた Spring MVC はリクエストごとにスレッド ハンドラは suspend 関数
Kotlin 用のプラグイン kotlin-spring・kotlin-jpa が要る kotlinx.serialization のプラグイン
テスト @WebMvcTest+MockMvc testApplication

Spring Boot は「入れれば動く」自動設定と、周辺の機能(セキュリティ・DB・バッチ)の多さが強みです。 Ktor は、使う機能を自分で選んで組み立てる分、何が動いているかがコードに全部出ます。小さな API や、コルーチンを中心に書きたい場合に向いています。

Spring Boot で REST API を作る流れ(Java)は、関連記事「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」で扱っています。

よくある質問

Q. @Serializable を付けたのに、シリアライザーが見つからないというエラーになります。 A. kotlin("plugin.serialization") が入っていないと、@Serializable のコードが生成されません。plugins ブロックに、Kotlin と同じ版で入れます。

Q. 日時(LocalDateTime)を JSON にできません。 A. kotlinx.serialization は、Java の LocalDateTime の変換を標準では持っていません。文字列で持つか、自分で KSerializer を書くか、kotlinx-datetime の型を使います。Kotlin の日時の扱いは、関連記事「Kotlin 日付と時刻の扱い方 LocalDateのフォーマットと差分計算」で扱っています。

Q. ルートが増えてきたらどう分けますか。 A. この記事の configureRouting のように、Application か Route の拡張関数に分けて、routing { taskRoutes(repo); userRoutes(repo2) } のように呼ぶ形がよく使われます。

Q. Spring Boot のように Jackson を使えますか。 A. ドキュメントでは、ContentNegotiation で使える JSON の変換として kotlinx.serialization のほかに Gson と Jackson も挙がっています。依存を ktor-serialization-jackson に替えて、json() の代わりに jackson { … } を使います。

まとめ

  • Ktor は DSL でルートを書き、プラグインを install で足す。Ktor 3.6.0・Kotlin 2.4.0 が start.ktor.io の既定
  • JSON は ContentNegotiation + @Serializable。kotlin("plugin.serialization") を忘れない
  • 既定の DefaultJson は 知らないキーで 400。変換の失敗は BadRequestException になる
  • 受けない例外は型で 400・404・415・413・504、それ以外は 500。RequestValidationException は 500 になるので StatusPages で受ける
  • exception<Throwable> だけを書くと、既定の 400・404・415 も 500 になる。型ごとに登録する
  • テストは testApplication + application { }。設定ファイルは自動では読まれない
  • ハンドラは suspend。ブロッキング処理は withContext(Dispatchers.IO)(計測で約1秒 → 約0.2秒)

参考・出典

確認日はいずれも 2026年10月12日です。

  • Ktor ドキュメント「Creating a new Ktor project」:https://ktor.io/docs/server-create-a-new-project.html
  • Ktor ドキュメント「Adding Ktor dependencies」:https://ktor.io/docs/server-dependencies.html
  • Ktor ドキュメント「Content negotiation and serialization」:https://ktor.io/docs/server-serialization.html
  • Ktor ドキュメント「Status pages」:https://ktor.io/docs/server-status-pages.html
  • Ktor ドキュメント「Request validation」:https://ktor.io/docs/server-request-validation.html
  • Ktor ドキュメント「Handling requests」:https://ktor.io/docs/server-requests.html
  • Ktor ドキュメント「Testing in Ktor Server」:https://ktor.io/docs/server-testing.html
  • Ktor ドキュメント「Create RESTful APIs in Kotlin」:https://ktor.io/docs/server-create-restful-apis.html
  • Ktor ドキュメント「Logging」:https://ktor.io/docs/server-logging.html
  • Ktor API リファレンス「io.ktor.server.plugins」:https://api.ktor.io/ktor-server-core/io.ktor.server.plugins/index.html
  • Ktor(GitHub、タグ 3.6.0 のソース):https://github.com/ktorio/ktor
  • start.ktor.io:https://start.ktor.io/
  • kotlinx.serialization(GitHub):https://github.com/Kotlin/kotlinx.serialization

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Ktor 入門|REST APIをJSON・例外処理・テストまで作る」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?