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
| Concept | Responsibility | Notes |
|---|---|---|
| Controller | HTTP endpoint | Accepts the request, validates input, calls a Logic |
| Logic | Business aggregation | Hand-written; joins, transactions, caching, use cases |
| Table | Single-table CRUD | Generated by KSP: get / insert / update / destroy / query |
| Entity | Plain data class | A 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
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
| Annotation | Purpose | Parameters |
|---|---|---|
@Table("name") | Marks the entity and names the table | value: table name, defaulting to the lower-cased class name |
@Id | Marks the primary key | autoGenerate: default true |
@Column | Customises the column mapping | name, nullable, ignore |
@CreatedAt | Filled with the current time on insert (epoch millis, UTC) | — |
@UpdatedAt | Filled 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.
@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
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
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:
import neton.database.dsl.*Basics
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:
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
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 pagesSingle rows and counts
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.
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
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:
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.
@Serializable
data class UserWithRoles(
val user: User,
val roles: List<Role>
)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.
@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
| Dimension | Table | Logic |
|---|---|---|
| Origin | Generated by KSP | Written by hand |
| Scope | Single-table CRUD and the query DSL | Joins, transactions, business aggregation |
| SQL | None — the DSL generates it | Mostly the table DSL; raw SQL through DbContext as the escape hatch |
| Use for | Standard create/read/update/delete | Composite use cases, reports, related data |
Configuration
config/database.conf:
# config/database.conf
[default]
driver = "MEMORY"
uri = "sqlite::memory:"
debug = true| Option | Meaning | Example |
|---|---|---|
driver | One of POSTGRESQL, MYSQL, SQLITE, MEMORY; a missing or unknown value fails at startup | "MEMORY", "SQLITE", "POSTGRESQL" |
uri | Connection URI | "sqlite::memory:", "postgres://localhost/mydb" |
debug | Print SQL | true / 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:
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:
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:
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 upapplies everything pending. Each module owns its own SQL and history table, such asneton_schema_history_member. - SQL is compiled in — a Gradle task turns each module's
sql/postgresql/V*.sqlinto Kotlin constants, so nothing reads.sqlfiles 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 upfirst. - Modules declare
@Module(migrations = true); KSP checks that the flag matches the SQL directory.