Skip to content

Database

Neton's data layer follows one rule: an entity is plain data, a table is the access point. There is no companion-object magic and no runtime reflection — everything is generated by KSP at compile time, which is what makes it work on Kotlin/Native.

Design

ConceptResponsibilityNotes
ControllerHTTP endpointAccepts the request, validates input, calls a Logic
LogicBusiness aggregationHand-written; joins, transactions, caching, use cases
TableSingle-table CRUDGenerated by KSP: get / insert / update / destroy / query
EntityPlain data classA data class annotated with @Serializable and @Table

The constraints:

  • entities contain no database logic and use no companion objects
  • tables are generated from entity annotations, never written by hand
  • nothing relies on runtime reflection

Defining an entity

kotlin
import kotlinx.serialization.Serializable
import neton.database.annotations.Table
import neton.database.annotations.Id

@Serializable
@Table("users")
data class User(
    @Id val id: Long?,
    val name: String,
    val email: String,
    val status: Int,
    val age: Int
)

Annotations

AnnotationPurposeParameters
@Table("name")Marks the entity and names the tablevalue: table name, defaulting to the lower-cased class name
@IdMarks the primary keyautoGenerate: default true
@ColumnCustomises the column mappingname, nullable, ignore
@CreatedAtFilled with the current time on insert (epoch millis, UTC)
@UpdatedAtFilled with the current time on insert and update (epoch millis, UTC)

Declare the key as Long? and pass null when creating; the database generates it.

kotlin
@Serializable
@Table("roles")
data class Role(
    @Id val id: Long?,
    val name: String
)

@Serializable
@Table("user_roles")
data class UserRole(
    @Id val id: Long?,
    val userId: Long,
    val roleId: Long
)

Tables (generated)

Every @Table entity gets a table object — User produces UserTable — implementing Table<T, ID>, where ID is inferred from the key type.

CRUD

kotlin
val user: User? = UserTable.get(1L)

val allUsers: List<User> = UserTable.findAll()

val newUser = UserTable.insert(User(null, "Alice", "alice@example.com", 1, 25))

UserTable.update(existingUser.copy(name = "New Name"))

UserTable.destroy(1L)

val total: Long = UserTable.count()

val exists: Boolean = UserTable.exists(1L)

Batches

kotlin
val users = listOf(user1, user2, user3)
UserTable.insertBatch(users)

UserTable.updateBatch(users)

insert returns the persisted entity including its generated key. insertBatch returns only the affected-row count: it does not mutate the inputs and does not return their keys. When you need those keys, call insert per entity inside an explicit transaction.

The query DSL

Queries are built with query { where { } }, using KProperty1 references such as User::status together with PredicateScope's all, and and or:

kotlin
import neton.database.dsl.*

Basics

kotlin
val activeUsers = UserTable.query { where { User::status eq 1 } }.list()

val adults = UserTable.query { where { User::age gt 18 } }.list()

val matched = UserTable.query { where { User::name like "%Alice%" } }.list()

val all = UserTable.query { where { all() } }.list()

Combining conditions

and and or are functions on PredicateScope, not infix operators:

kotlin
val result = UserTable.query {
    where { and(User::status eq 1, User::age gt 18) }
}.list()

val result = UserTable.query {
    where { or(User::name eq "Alice", User::name eq "Bob") }
}.list()

Ordering and pagination

kotlin
val sorted = UserTable.query {
    where { User::status eq 1 }
    orderBy(User::age.desc())
    limitOffset(20, 0)
}.list()

// pages are one-based
val pageResult = UserTable.query { where { User::status eq 1 } }.page(1, 20)
// pageResult.items      -> List<User>
// pageResult.total      -> total rows
// pageResult.page       -> current page
// pageResult.size       -> page size
// pageResult.totalPages -> total pages

Single rows and counts

kotlin
val first = UserTable.query { where { User::status eq 1 }; limitOffset(1, 0) }.list().firstOrNull()

val one = UserTable.oneWhere { User::email eq "alice@example.com" }

val exists = UserTable.existsWhere { User::email eq "alice@example.com" }

val count = UserTable.query { where { User::status eq 1 } }.count()

Concurrency-safe updates

Reading a row, changing it and writing it back loses updates under concurrency. UpdateScope therefore offers increment and decrement, which render col = col + ? and let the database do the arithmetic. Combined with where { } this is a compare-and-swap: when the guard does not hold, the affected-row count is 0.

kotlin
val affected = InviteCodeTable.query {
    where { and(InviteCode::id eq id, InviteCode::usedCount lt maxUses) }
}.update { increment(InviteCode::usedCount) }

if (affected == 0L) throw BadRequestException("INVITE_CODE_EXHAUSTED")

Balance deduction has the same shape — guard on Account::balance ge amount, then decrement(Account::balance, amount).

Known limits

Predicates compare a column against a bound value, never against another column, so a guard such as used_count < max_uses still needs raw SQL.

There is also no idempotent-insert API in this release. ON CONFLICT DO NOTHING expresses it exactly on PostgreSQL and SQLite, but MySQL has no equivalent that both avoids swallowing unrelated errors and reports its outcome unambiguously, so shipping it would freeze a partial method into the 1.0 ABI. Use raw SQL through DbContext for now.

Installing the component

kotlin
import neton.core.Neton
import neton.http.http
import neton.database.database
import neton.routing.routing

fun main(args: Array<String>) {
    Neton.run(args) {
        http { port = 8081 }

        database { }

        routing { }
    }
}

This initialises DbSessionProvider and DbContext. Generated tables need no runtime registration. In a real application the schema comes from migrations, and startup never calls ensureTable().

A CRUD controller

Controllers do not reference tables directly; data access goes through a Logic:

kotlin
import logic.UserLogic
import model.User
import neton.core.annotations.*
import neton.core.http.*
import neton.logging.Logger
import neton.logging.Log

@Controller("/api/users")
@Log
class UserController(
    private val log: Logger,
    private val userLogic: UserLogic
) {

    @Get
    suspend fun all(): List<User> = userLogic.all()

    @Get("/{id}")
    suspend fun get(id: Long): User? {
        log.info("user.get", mapOf("userId" to id))
        return userLogic.get(id)
    }

    @Post
    suspend fun create(@Body user: User): User = userLogic.create(user)

    @Put("/{id}")
    suspend fun update(id: Long, @Body user: User): User =
        userLogic.update(id, user)

    @Delete("/{id}")
    suspend fun delete(id: Long) = userLogic.delete(id)
}

The Logic layer

Use a Logic for joins, transactions and anything spanning tables. It reaches the database through DbContext for raw SQL, or through the table DSL for single-table work, and it is the only business layer between controllers and tables.

kotlin
@Serializable
data class UserWithRoles(
    val user: User,
    val roles: List<Role>
)
kotlin
import neton.core.annotations.Logic
import neton.database.api.DbContext

@Logic
class UserLogic(private val db: DbContext) : DbContext by db {

    suspend fun all(): List<User> =
        UserTable.query { where { User::status eq 1 } }.list()

    suspend fun get(id: Long): User? = UserTable.get(id)

    suspend fun create(user: User): User = UserTable.insert(user)

    suspend fun getWithRoles(userId: Long): UserWithRoles? {
        val sql = """
            SELECT u.id, u.name, u.email, u.status, u.age,
                   r.id AS role_id, r.name AS role_name
            FROM users u
            LEFT JOIN user_roles ur ON ur.user_id = u.id
            LEFT JOIN roles r ON r.id = ur.role_id
            WHERE u.id = :uid
        """.trimIndent()

        val rows = fetchAll(sql, mapOf("uid" to userId))
        if (rows.isEmpty()) return null

        val first = rows.first()
        val user = User(
            id = first.long("id"),
            name = first.string("name"),
            email = first.string("email"),
            status = first.int("status"),
            age = first.int("age")
        )
        val roles = rows.mapNotNull { r ->
            r.longOrNull("role_id")?.let {
                Role(it, r.string("role_name"))
            }
        }.distinctBy { it.id }

        return UserWithRoles(user, roles)
    }
}

Optional dependencies are not injected

@Logic skips constructor parameters that have default values. A dependency written as redis: RedisClient? = null is never injected, even when the binding exists, and no warning is emitted. Declare dependencies without defaults.

kotlin
@Controller("/api/users")
class UserController(
    private val userLogic: UserLogic
) {
    @Get
    suspend fun all(): List<User> = userLogic.all()

    @Get("/{id}")
    suspend fun get(id: Long): User? = userLogic.get(id)

    @Get("/{id}/with-roles")
    suspend fun getWithRoles(id: Long): UserWithRoles? =
        userLogic.getWithRoles(id)

    @Post
    suspend fun create(@Body user: User): User = userLogic.create(user)
}

Table versus Logic

DimensionTableLogic
OriginGenerated by KSPWritten by hand
ScopeSingle-table CRUD and the query DSLJoins, transactions, business aggregation
SQLNone — the DSL generates itMostly the table DSL; raw SQL through DbContext as the escape hatch
Use forStandard create/read/update/deleteComposite use cases, reports, related data

Configuration

config/database.conf:

toml
# config/database.conf
[default]
driver = "MEMORY"
uri = "sqlite::memory:"
debug = true
OptionMeaningExample
driverOne of POSTGRESQL, MYSQL, SQLITE, MEMORY; a missing or unknown value fails at startup"MEMORY", "SQLITE", "POSTGRESQL"
uriConnection URI"sqlite::memory:", "postgres://localhost/mydb"
debugPrint SQLtrue / false

One data source in 1.0

Only the [default] section is read; others such as [analytics] are ignored silently. Multiple data sources are out of scope for 1.0, and wrapping the file in a [database] section is not allowed.

Platform note

neton-database has no macosX64 target, because its driver (sqlx4k) publishes no macosX64 artifact. Database work needs Apple Silicon, Linux or Windows.

Creating tables

ensureTable() exists for demos and throwaway test databases only:

kotlin
UserTable.ensureTable()

Never call it during startup in a real application. It cannot express ALTER, indexes, foreign keys, unique constraints, data migration, versioning or rollback. Schema evolution belongs in versioned migration SQL.

Transactions

Every table operation inside the block joins the same session:

kotlin
db.transaction {
    val user = UserTable.insert(User(null, "Alice", "alice@example.com", 1, 25))
    // if a later step fails, the whole transaction rolls back
    UserTable.destroy(user.id!!)
}

Nested transaction { } calls join the outer transaction. db.inTransaction() reports whether the current coroutine is inside one, which lets code that must run transactionally — the domain event outbox, for instance — fail fast instead of committing early:

kotlin
check(db.inTransaction()) { "must be called inside db.transaction { }" }

Migrations

Migration is built into neton-database; the separate neton-migrate CLI was retired in June 2026.

  • Entry point — the application binary carries the subcommand: ./application.kexe migrate up applies everything pending. Each module owns its own SQL and history table, such as neton_schema_history_member.
  • SQL is compiled in — a Gradle task turns each module's sql/postgresql/V*.sql into Kotlin constants, so nothing reads .sql files at runtime. This is what makes single-binary Kotlin/Native deployment work.
  • Startup never migrates. When a pending migration is detected the application refuses to start and lists what is outstanding, telling you to run migrate up first.
  • Modules declare @Module(migrations = true); KSP checks that the flag matches the SQL directory.

Neton Framework Documentation