- Kotlin
Ktor 入門|REST APIをJSON・例外処理・テストまで作る
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期連続の黒字決算。