Exposed は、JetBrains が作っている Kotlin 用のデータベースライブラリです。SQL に近い形で書く「DSL」と、行をオブジェクトとして扱う「DAO」の2つの書き方があります。 どちらも同じテーブル定義(object Employees : IntIdTable(...))を使い、transaction { } の中で実行します。

// DSL:SQL に近い書き方
Employees.selectAll()
    .where { (Employees.salary greaterEq 300_000) and Employees.email.isNull() }
    .orderBy(Employees.salary, SortOrder.DESC)

// DAO:行をオブジェクトとして扱う書き方
val e = Employee.new { name = "高橋"; salary = 350_000; dept = soumu }
e.salary = 360_000

上の DSL を SQLite で実行したときに、Exposed が出した SQL です。

SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees WHERE (employees.salary >= 300000) AND (employees.email IS NULL) ORDER BY employees.salary DESC
項目 DSL DAO
書き方 Employees.selectAll().where { … } Employee.find { … }・Employee.new { … }
結果の形 ResultRow(row[Employees.name] で読む) エンティティ(e.name で読む)
出る SQL 書いたとおりの1本 関連を読むたびに追加の SELECT が出ることがある
向いている処理 集計・JOIN・一括更新・細かい SQL の制御 1件ずつの作成・更新、関連のたどり
必要なモジュール exposed-core・exposed-jdbc 上に加えて exposed-dao

確認環境:Exposed 1.5.1(GitHub のタグ 1.5.1、2026年10月9日のコミット)の exposed-core・exposed-jdbc・exposed-dao のソースを Kotlin コンパイラ kotlinc 2.4.20 でコンパイルし(日時型の列のパッケージを除く)、OpenJDK 21.0.10(Linux)で実行しました。データベースは GitHub リリースから取得した sqlite-jdbc 3.53.2.1(SQLite 3.53.0)と H2 2.5.252 です。この記事の SQL とエラー文は、そのとき出力されたものです。1.0 の変更点と Spring Boot の設定は、jetbrains.com の Exposed ドキュメントと、リポジトリのソース・CHANGELOG で確かめました(確認日 2026年10月13日)。

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

(記載例)出典:株式会社RJC「Kotlin Exposed入門|DSLとDAOの書き方とSQLの対応」

最新版と 1.0 での変更点

GitHub のタグと CHANGELOG では 1.5.1 が最新です(Releases ページの「Latest」表示は 1.5.0)。1.0.0 は、0.x から大きく変わった版です。jetbrains.com の移行ガイド(Migrating from 0.61.0 to 1.0.0)の要点です。

変わったところ 0.x 1.0 以降
パッケージ org.jetbrains.exposed.sql.* org.jetbrains.exposed.v1.core.*
JDBC 用の関数 sql パッケージ org.jetbrains.exposed.v1.jdbc.*(Database・SchemaUtils・selectAll・insert・update など)
DAO org.jetbrains.exposed.dao.* org.jetbrains.exposed.v1.dao.*(EntityID は v1.core.dao.id)
eq・less などの演算子 SqlExpressionBuilder の中 トップレベル関数。import org.jetbrains.exposed.v1.core.eq などが要る
deleteWhere { } の中 it が式ビルダー テーブルが受け手(deleteWhere { salary less 0 })
コルーチン newSuspendedTransaction suspendTransaction(旧名は非推奨)
非同期ドライバー なし R2DBC 対応(exposed-r2dbc・R2dbcDatabase)
Spring Boot 4 なし exposed-spring-boot4-starter
Kotlin ― 2.2 以上が必要

0.x の記事のコードは、import を直さないと 1.x ではコンパイルできません。 検索で見つかるコードの import が org.jetbrains.exposed.sql なら、0.x 向けです。

1.1〜1.5 の主な追加は、CHANGELOG によると次のとおりです。

  • 1.4.0:クエリのタイムアウトの全体設定、Maven 用のマイグレーションプラグイン、exposed-version-catalog
  • 1.5.0:複数行の VALUES による一括 INSERT を全データベースに拡大、Amazon Redshift の JDBC 対応
  • 1.5.1:locate()・concat() などの文字列の引数を、SQL に埋め込まずバインド変数で渡すよう修正

依存の書き方(Gradle)

jetbrains.com の Get started では、バージョンカタログ(gradle/libs.versions.toml)に次のように書いています(ドキュメントの例は 1.5.0)。

[versions]
exposed = "1.5.1"
h2 = "2.4.240"

[libraries]
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-dao  = { module = "org.jetbrains.exposed:exposed-dao",  version.ref = "exposed" }
h2 = { module = "com.h2database:h2", version.ref = "h2" }
// build.gradle.kts
dependencies {
    implementation(libs.exposed.core)
    implementation(libs.exposed.jdbc)
    implementation(libs.exposed.dao)        // DAO を使うときだけ
    implementation(libs.h2)                 // 使うデータベースの JDBC ドライバー
}

日時の列は exposed-java-time(java.time)か exposed-kotlin-datetime を足します。バージョンカタログの書き方は、関連記事「build.gradle.kts書き方|Groovy対応表とバージョンカタログ」で扱っています。

完成コード

部署(departments)と社員(employees)の2つのテーブルを、DSL と DAO の両方で操作する1ファイルです。main の引数で sqlite と h2 を切り替えます。

// Main.kt
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.*
import org.jetbrains.exposed.v1.jdbc.transactions.transaction

// ---------- テーブル定義(DSL と DAO で共通) ----------
object Departments : IntIdTable("departments") {
    val name = varchar("name", 50).uniqueIndex()
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 50)
    val salary = integer("salary")
    val email = varchar("email", 100).nullable()
    val dept = reference("dept_id", Departments)
}

// ---------- DAO のエンティティ ----------
class Department(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Department>(Departments)
    var name by Departments.name
    val employees by Employee referrersOn Employees.dept
}

class Employee(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Employee>(Employees)
    var name by Employees.name
    var salary by Employees.salary
    var email by Employees.email
    var dept by Department referencedOn Employees.dept
}

fun section(t: String) = println("\n===== $t")

fun main(args: Array<String>) {
    val db = when (args.firstOrNull() ?: "sqlite") {
        "h2" -> Database.connect("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
        else -> Database.connect("jdbc:sqlite:demo.db", driver = "org.sqlite.JDBC")
    }

    transaction(db) {
        println("dialect=${db.dialect.name} version=${db.version}")
        addLogger(StdOutSqlLogger)

        section("テーブル作成")
        SchemaUtils.create(Departments, Employees)

        section("INSERT(DSL)")
        val dev = Departments.insertAndGetId { it[name] = "開発" }
        val sales = Departments.insertAndGetId { it[name] = "営業" }
        Employees.insert {
            it[name] = "田中"; it[salary] = 400_000; it[dept] = dev; it[email] = "tanaka@example.com"
        }
        Employees.batchInsert(listOf("佐藤" to 320_000, "鈴木" to 280_000)) { (n, s) ->
            this[Employees.name] = n; this[Employees.salary] = s; this[Employees.dept] = sales
        }

        section("SELECT(DSL)")
        Employees.selectAll()
            .where { (Employees.salary greaterEq 300_000) and Employees.email.isNull() }
            .orderBy(Employees.salary, SortOrder.DESC)
            .forEach { println("  -> ${it[Employees.name]} ${it[Employees.salary]}") }

        Employees.select(Employees.name)
            .where { Employees.name like "佐%" }
            .map { it[Employees.name] }
            .also { println("  -> $it") }

        section("UPDATE / DELETE(DSL)")
        val updated = Employees.update({ Employees.name eq "鈴木" }) {
            it[salary] = 300_000
        }
        println("  -> updated=$updated")
        val deleted = Employees.deleteWhere { salary less 0 }
        println("  -> deleted=$deleted")

        section("JOIN と集計(DSL)")
        val count = Employees.id.count()
        val avg = Employees.salary.avg()
        (Employees innerJoin Departments)
            .select(Departments.name, count, avg)
            .groupBy(Departments.name)
            .orderBy(Departments.name)
            .forEach { println("  -> ${it[Departments.name]} 人数=${it[count]} 平均=${it[avg]}") }

        section("LEFT JOIN(社員のいない部署も出す)")
        Departments.insert { it[name] = "総務" }
        (Departments leftJoin Employees)
            .select(Departments.name, Employees.name)
            .orderBy(Departments.id)
            .forEach { println("  -> ${it[Departments.name]} / ${it[Employees.name]}") }
    }

    section("トランザクションのロールバック")
    runCatching {
        transaction(db) {
            addLogger(StdOutSqlLogger)
            Employees.update({ Employees.name eq "田中" }) { it[salary] = 0 }
            error("途中で失敗")
        }
    }.onFailure { println("  -> 例外: ${it.message}") }
    transaction(db) {
        println("  -> 田中の給与 = " + Employees.selectAll().where { Employees.name eq "田中" }.single()[Employees.salary])
    }

    section("DAO:作成・読み・更新")
    transaction(db) {
        addLogger(StdOutSqlLogger)
        val soumu = Department.find { Departments.name eq "総務" }.single()
        val e = Employee.new {
            name = "高橋"; salary = 350_000; dept = soumu
        }
        println("  -> new id=${e.id.value}")
        e.salary = 360_000          // 変更は次のクエリの前かトランザクションの終わりに UPDATE される
        println("  -> 総務の人数=${soumu.employees.count()}")
    }

    section("DAO:N+1(関連を1件ずつ読む)")
    transaction(db) {
        addLogger(StdOutSqlLogger)
        Employee.all().forEach { println("  -> ${it.name} (${it.dept.name})") }
    }

    section("DAO:with() で先に読む(eager loading)")
    transaction(db) {
        addLogger(StdOutSqlLogger)
        Employee.all().with(Employee::dept).forEach { println("  -> ${it.name} (${it.dept.name})") }
    }

    transaction(db) { SchemaUtils.drop(Employees, Departments) }
}

以下、この完成コードを動かして出た SQL を、処理ごとに見ていきます。

テーブル定義と CREATE TABLE

IntIdTable は、id(自動採番の整数の主キー)を持つテーブルです。reference() は外部キー、nullable() は NULL を許す列、uniqueIndex() は一意の索引になります。SchemaUtils.create() が出した SQL です。

-- SQLite
SQL: CREATE TABLE IF NOT EXISTS departments (id INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR(50) NOT NULL, CONSTRAINT chk_departments_signed_integer_id CHECK (id BETWEEN -2147483648 AND 2147483647))
SQL: CREATE UNIQUE INDEX departments_name ON departments ("name")
SQL: CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR(50) NOT NULL, salary INT NOT NULL, email VARCHAR(100) NULL, dept_id INT NOT NULL, CONSTRAINT fk_employees_dept_id__id FOREIGN KEY (dept_id) REFERENCES departments(id) ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT chk_employees_signed_integer_id CHECK (id BETWEEN -2147483648 AND 2147483647), CONSTRAINT chk_employees_signed_integer_salary CHECK (salary BETWEEN -2147483648 AND 2147483647))

-- H2
SQL: CREATE TABLE IF NOT EXISTS DEPARTMENTS (ID INT AUTO_INCREMENT PRIMARY KEY, "name" VARCHAR(50) NOT NULL)
SQL: ALTER TABLE DEPARTMENTS ADD CONSTRAINT DEPARTMENTS_NAME_UNIQUE UNIQUE ("name")
SQL: CREATE TABLE IF NOT EXISTS EMPLOYEES (ID INT AUTO_INCREMENT PRIMARY KEY, "name" VARCHAR(50) NOT NULL, SALARY INT NOT NULL, EMAIL VARCHAR(100) NULL, DEPT_ID INT NOT NULL, CONSTRAINT FK_EMPLOYEES_DEPT_ID__ID FOREIGN KEY (DEPT_ID) REFERENCES DEPARTMENTS(ID) ON DELETE RESTRICT ON UPDATE RESTRICT)

同じ定義でも、データベースごとに SQL が変わります。SQLite では、整数の列に Kotlin の Int の範囲を守る CHECK 制約が付きました(SQLite の INTEGER は 64 ビットまで入るため)。name は予約語と重なるので、どちらも "name" と引用符で囲まれました。

よく使う列の型

定義 Kotlin の型 用途
integer("c")・long("c") Int・Long 整数
varchar("c", 50)・text("c") String 文字列(長さ指定あり・なし)
bool("c") Boolean 真偽値
decimal("c", 10, 2) BigDecimal 金額など
enumerationByName<Role>("c", 20) enum enum を名前で保存
reference("c", Other) EntityID<Int> 外部キー(optReference で NULL 可)
uuid("c") kotlin.uuid.Uuid UUID(java.util.UUID は javaUUID)
datetime("c")(exposed-java-time) LocalDateTime 日時

列の後ろに付ける指定は、.nullable()・.default(値)・.clientDefault { }(Kotlin 側で値を作る)・.uniqueIndex()・.index() です。本番のテーブル変更は SchemaUtils.create ではなく、Flyway などのマイグレーションツールで管理する形が一般的です。

CRUD:DSL と SQL の対応

完成コードの DSL と、実際に出た SQL(SQLite)を並べます。

処理 DSL 出た SQL
追加して id を得る Departments.insertAndGetId { it[name] = "開発" } INSERT INTO departments ("name") VALUES ('開発')
まとめて追加 Employees.batchInsert(list) { … } 1件ごとの INSERT(ログ上は2行)
条件で検索 selectAll().where { … }.orderBy(…) SELECT … WHERE (…) AND (…) ORDER BY … DESC
列を選ぶ select(Employees.name).where { … like "佐%" } SELECT employees."name" FROM employees WHERE … LIKE '佐%'
更新 update({ Employees.name eq "鈴木" }) { it[salary] = 300_000 } UPDATE employees SET salary=300000 WHERE …
削除 deleteWhere { salary less 0 } DELETE FROM employees WHERE employees.salary < 0
===== UPDATE / DELETE(DSL)
SQL: UPDATE employees SET salary=300000 WHERE employees."name" = '鈴木'
  -> updated=1
SQL: DELETE FROM employees WHERE employees.salary < 0
  -> deleted=0

update と deleteWhere は、変わった行の数を返します。

ログの SQL は値を埋め込んだ形ですが、実際はバインド変数(?)で送られます。 StdOutSqlLogger は、expandArgs()(パラメーターを文字にして埋めた文)を出力するしくみです(exposed-core の SQLLog.kt)。'鈴木' のような文字が SQL に直接入るわけではありません。

WHERE で使う演算子

Exposed SQL
a eq b・a neq b =・<>
a less b・lessEq・greater・greaterEq <・<=・>・>=
a like "佐%" LIKE
a inList listOf(1, 2) IN (1, 2)
a.between(1, 10) BETWEEN 1 AND 10
a.isNull()・a.isNotNull() IS NULL・IS NOT NULL
(条件1) and (条件2)・or・not(…) AND・OR・NOT

1.0 以降、これらは org.jetbrains.exposed.v1.core のトップレベル関数です。完成コードは import org.jetbrains.exposed.v1.core.* で一度に読み込んでいます。1つずつ書く場合は import org.jetbrains.exposed.v1.core.eq のようにします。

JOIN と集計

innerJoin・leftJoin は、reference() で外部キーを定義していれば、結合の条件(ON)を自動で付けます。

===== JOIN と集計(DSL)
SQL: SELECT departments."name", COUNT(employees.id), AVG(employees.salary) FROM employees INNER JOIN departments ON departments.id = employees.dept_id GROUP BY departments."name" ORDER BY departments."name" ASC
  -> 営業 人数=2 平均=310000.00
  -> 開発 人数=1 平均=400000.00

===== LEFT JOIN(社員のいない部署も出す)
SQL: SELECT departments."name", employees."name" FROM departments LEFT JOIN employees ON departments.id = employees.dept_id ORDER BY departments.id ASC
  -> 開発 / 田中
  -> 営業 / 佐藤
  -> 営業 / 鈴木
  -> 総務 / null
  • 集計の値は、val count = Employees.id.count() のように変数に入れておき、select と it[count] の両方で同じものを使います。
  • LEFT JOIN で相手が無い行は、it[Employees.name] が null になりました。
  • 外部キーが無いテーブル同士や、条件を変えたいときは Employees.join(Departments, JoinType.INNER, onColumn = …, otherColumn = …) の形で書きます。

トランザクション

Exposed の操作は、すべて transaction { } の中で行います。ブロックが最後まで進むとコミット、例外が出るとロールバックです。

===== トランザクションのロールバック
SQL: UPDATE employees SET salary=0 WHERE employees."name" = '田中'
  -> 例外: 途中で失敗
  -> 田中の給与 = 400000

UPDATE は実行されましたが、そのあとの例外でロールバックされ、給与は 400000 のままでした。

入れ子の transaction は既定で外側と同じ

transaction { } の中にもう一つ transaction { } を書き、内側で例外を出して runCatching で受け止めた結果です(H2)。

-- useNestedTransactions = false(既定)
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('外側')
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('内側')
  内側の例外: 内側で失敗
useNestedTransactions=false → 残った行: [内側, 外側]

-- useNestedTransactions = true
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('外側')
  内側の例外: 内側で失敗
useNestedTransactions=true → 残った行: [外側]

既定では、内側の transaction は外側と同じトランザクションです。 内側で例外が出ても、外側で受け止めると、内側の INSERT もコミットされました。受け止めずに例外が外側まで届けば、jetbrains.com のドキュメントのとおり、外側の変更ごとロールバックされます。内側だけを取り消したいときは、Database.connect(…, databaseConfig = DatabaseConfig { useNestedTransactions = true }) にします(db.useNestedTransactions = true でも設定できます)。このときは内側の変更だけがロールバックされました(内側は別のトランザクションの扱いなので、外側で付けた addLogger のログは出ていません)。

コルーチンの中では suspendTransaction { } を使います(1.0 で newSuspendedTransaction から名前が変わりました)。

DAO:エンティティで書く

DAO は、IntEntity を継承したクラスに、列を by でつなぎます。Employee.new { } で追加、プロパティへの代入で更新です。

===== DAO:作成・読み・更新
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments."name" = '総務'
SQL: INSERT INTO employees ("name", salary, dept_id) VALUES ('高橋', 350000, 3)
  -> new id=4
SQL: UPDATE employees SET salary=360000 WHERE id = 4
SQL: SELECT COUNT(*) FROM employees WHERE employees.dept_id = 3
  -> 総務の人数=1

e.salary = 360_000 を書いた行では SQL は出ず、次のクエリ(人数の COUNT)の直前に UPDATE が出ました。DAO は変更をためておき、まとめて書き込みます。

DAO の書き方 意味
Employee.new { … } 1件追加
Employee.findById(1) 主キーで1件(無ければ null)
Employee.find { Employees.salary greater 300_000 } 条件で検索
Employee.all() 全件
e.salary = 1 更新(あとでまとめて UPDATE)
e.delete() 削除
var dept by Department referencedOn Employees.dept 多対一(社員 → 部署)
val employees by Employee referrersOn Employees.dept 一対多(部署 → 社員)

N+1 と with()

DAO で関連をたどると、1件ごとに SELECT が出ることがあります(N+1 問題)。

===== DAO:N+1(関連を1件ずつ読む)
SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 1
  -> 田中 (開発)
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 2
  -> 佐藤 (営業)
  -> 鈴木 (営業)
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 3
  -> 高橋 (総務)

===== DAO:with() で先に読む(eager loading)
SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id IN (1, 2, 3)

社員4人で、部署の SELECT が3本出ました(同じ部署の2人目はキャッシュから読まれ、SQL は出ていません)。.with(Employee::dept) を付けると、部署は IN (1, 2, 3) の1本にまとまりました。 一覧画面のように関連を何件も読む処理では、with() を付けるか、DSL の JOIN で書きます。1件のエンティティの関連を先に読むときは、ドキュメントでは Employee.findById(1)?.load(Employee::dept) の load() が紹介されています。

DSL と DAO の使い分け

場面 向いている方 理由
一覧・検索・集計の画面 DSL 出る SQL が1本で、列も選べる
一括の UPDATE・DELETE DSL 1本の SQL で済む。DAO だと1件ずつ
1件の作成・編集の画面 DAO プロパティへの代入で書ける
関連をたどる処理 DAO+with() with() を忘れると N+1
SQL を細かく決めたい DSL 書いたものがそのまま SQL になる

同じテーブル定義を共有しているので、1つのアプリの中で DSL と DAO を混ぜて使えます。 完成コードでも、DAO の Department.find { Departments.name eq "総務" } の条件は DSL の式です。

Spring Boot との組み合わせ

Spring Boot 4 では exposed-spring-boot4-starter、Spring Boot 3 では exposed-spring-boot-starter を使います。jetbrains.com のドキュメントには、Spring Boot 3 向けのスターターは次のメジャー版で外す予定と書かれています。リポジトリのサンプル(exposed-spring)のアプリケーションクラスです。

import org.jetbrains.exposed.v1.spring.boot4.autoconfigure.ExposedAutoConfiguration
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration
import org.springframework.boot.runApplication

@SpringBootApplication
@ImportAutoConfiguration(
    value = [ExposedAutoConfiguration::class],
    exclude = [DataSourceTransactionManagerAutoConfiguration::class]
)
class ExposedSpringApplication

fun main() {
    runApplication<ExposedSpringApplication>()
}

サービスでは transaction { } を書かず、Spring の @Transactional を使います。スターターが Exposed 用の SpringTransactionManager を登録するためです。

@Service
@Transactional
class EmployeeService {
    fun findRich(): List<String> =
        Employees.selectAll().where { Employees.salary greaterEq 300_000 }.map { it[Employees.name] }
}

application.properties で使える設定です(スターターのソースと設定メタデータで確かめたもの)。

プロパティ 既定 意味
spring.datasource.url など ― 接続先(Spring Boot の標準の設定)
spring.exposed.show-sql false 実行した SQL をログに出す
spring.exposed.generate-ddl false 起動時に Table を継承したテーブルを作る
spring.exposed.excluded-packages 空 自動作成から外すパッケージ

DatabaseConfig の Bean を定義すると、useNestedTransactions などの設定を変えられます。Spring Boot の @Transactional の注意点は、関連記事「Spring @Transactionalが効かない原因8選|ロールバックの直し方」で扱っています。Boot 4 への移行は、関連記事「Spring Boot 4移行の手順|3から4の変更点一覧とチェックリスト」で扱っています。

よくあるエラー(実際に出た原文)

H2 で、わざと間違えて実行したときの例外です。

原因 例外とメッセージ
transaction { } の外で SELECT した IllegalStateException: No transaction in context.
transaction から Query を返し、外で toList() した IllegalStateException: No transaction in context.
エンティティを外へ持ち出し、外で e.dept を読んだ IllegalStateException: Can't init value outside the transaction
select(Employees.name) の結果で salary を読んだ IllegalStateException: Employees.salary is not in record set
一意の列に同じ値を入れた ExposedSQLException: …JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: …
無い部署の id を入れた ExposedSQLException: …Referential integrity constraint violation: "FK_EMPLOYEES_DEPT_ID__ID: …"
必須の列(salary)を入れ忘れた ExposedSQLException: …NULL not allowed for column "SALARY"
H2 で db.dialect.name を transaction の外で読んだ IllegalStateException: No transaction in context.
  • Query は遅延評価です。 selectAll() は SQL を組み立てるだけで、toList()・map・forEach で初めて実行されます。transaction の中で map { … } してリストにして返すと、外でも使えました。
  • DAO のエンティティを画面やAPIに返すときは、transaction の中で data class に詰め替えると、外での読み込みエラーを避けられます。
  • 必須の列の入れ忘れは、Exposed ではなくデータベースが止めます。メッセージの形はデータベースごとに違います。

よくある質問

Q. Exposed と JPA(Spring Data JPA)は何が違いますか。 A. Exposed の DSL は SQL をそのまま Kotlin で書く形で、エンティティの状態管理がありません。DAO は JPA に近いですが、アノテーションではなく object のテーブル定義と by の委譲で書きます。JPA は関連記事「Spring Data JPA使い方|クエリメソッド命名一覧と@Query実例」で扱っています。

Q. SQL を直接書けますか。 A. transaction { exec("SELECT …") { rs -> … } } で書けます。ラムダに JDBC の ResultSet が渡ります。値は exec(sql, args = listOf(IntegerColumnType() to 1)) のように ? で渡せます(exposed-jdbc の JdbcTransaction.kt による)。

Q. SQLite でファイルを使わずにメモリーで動かせますか。 A. jdbc:sqlite::memory: で試すと、同じ transaction の中では読めましたが、次の transaction では no such table: departments になりました。Exposed は transaction ごとに接続を取り、SQLite のメモリー上のデータベースは接続ごとに別になるためです。この記事ではファイル(demo.db)を使いました。H2 は jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1 で、接続が閉じても中身が残ります。

Q. by の委譲や object の書き方がよく分かりません。 A. 関連記事「Kotlin by 委譲の使い方 クラス委譲とby lazy・監視」と「Kotlin companion object|static相当とobject」で扱っています。

まとめ

  • Exposed は DSL(SQL に近い)と DAO(オブジェクト) の2つの書き方。テーブル定義は共通
  • 1.0 で import が org.jetbrains.exposed.v1.* に変わった。eq などはトップレベル関数。0.x の記事のコードは import の書き換えが要る
  • GitHub のタグ・CHANGELOG の最新は 1.5.1
  • すべての操作は transaction { } の中。例外でロールバック。入れ子は既定で外側と同じで、useNestedTransactions = true で内側だけ取り消せる
  • Query は遅延評価。transaction の中でリストにしてから返す
  • DAO の関連は N+1 になりやすい。with() で IN (…) の1本にまとまった
  • Spring Boot 4 は exposed-spring-boot4-starter と @Transactional。spring.exposed.show-sql=true で SQL が出る

参考・出典

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

  • Exposed「Get started with Exposed」:https://www.jetbrains.com/help/exposed/get-started-with-exposed.html
  • Exposed「Migrating from 0.61.0 to 1.0.0」:https://www.jetbrains.com/help/exposed/migration-guide-1-0-0.html
  • Exposed「Spring Boot integration」:https://www.jetbrains.com/help/exposed/spring-boot-integration.html
  • Exposed「Working with transactions」:https://www.jetbrains.com/help/exposed/transactions.html
  • Exposed「DAO API」:https://www.jetbrains.com/help/exposed/dao-crud-operations.html
  • JetBrains/Exposed(リポジトリ・CHANGELOG):https://github.com/JetBrains/Exposed
  • JetBrains/Exposed Releases:https://github.com/JetBrains/Exposed/releases
  • xerial/sqlite-jdbc Releases:https://github.com/xerial/sqlite-jdbc/releases
  • H2 Database Engine Releases:https://github.com/h2database/h2database/releases

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

(記載例)出典:株式会社RJC「Kotlin Exposed入門|DSLとDAOの書き方とSQLの対応」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?