
SQL-first data access layer for PostgreSQL, with fluent query builders, automatic composite/enum/array type mapping, polymorphic dynamic DTOs, transaction plans, stored-procedure support, and LISTEN/NOTIFY.
An explicit, SQL-first data access layer for Kotlin & PostgreSQL
It's not an ORM. It's a ROME (Relational-Object Mapping Engine). Because all queries lead to ROME.
[!WARNING] This project is legacy and is no longer actively developed. It has been superseded by octavius-postgresql — a PostgreSQL driver that speaks Wire Protocol v3.2 directly, a client (data access layer) built on top of it, and a migrator, published as separate artifacts.
Much of what lives here exists to work around pgjdbc — text-protocol composites, enums the library had to be taught about, a stateful
ResultSet, no named parameters — and the new driver answers all of that natively. What was left over is the client, deliberately a much smaller thing than a port would have been.Nothing is being taken away: the released artifacts stay on Maven Central and the code below still works. But new work happens in
octavius-postgresql, and new projects should start there.
Nothing here was dropped for want of a home. Most of it was a workaround for pgjdbc and the new driver answers it natively; the rest moved, and a few things changed shape enough to be worth a sentence.
| Here | There |
|---|---|
SelectQueryBuilder and the other three — with(), recursive(), fromSubquery, page, forUpdate, onConflict, fromSelect, using, returning
|
The four builders, method for method |
QueryFragment, withParam, join
|
QueryFragment |
dynamic_dto |
dynamic_dto — same idea, a real composite instead of a text-protocol one |
dynamic_map |
plain ROW(...) — see below |
.options { } |
per-query converters — see below |
TypeHandler<T>, GlobalTypeHandler<T>
|
ResultConverter + ParameterConverter — see below |
@MapKey |
@PgName, and it applies in both directions |
@PgEnum, @PgComposite, @DynamicallyMappable
|
@PgEnumType, @PgCompositeType, @DynamicallyMappable, scanned by client-scanner
|
toDataObject(), toDataMap()
|
the same two |
DataMapper, .toListOf { map -> … }
|
a ResultConverter<Row, T> — see below |
TransactionPlan, TransactionStep, StepHandle
|
Transaction Plans, and every failure names its step on the exception's path
|
PgChannelListener |
LISTEN / NOTIFY |
| CALL with IN/OUT/INOUT | Functions and Procedures |
bulk unnest
|
Bulk Writes |
DataResult, quoteAsPgIdentifier, PgTyped/withPgType, QualifiedName, CaseConvention
|
the same names |
flyway-integration |
migrations, which is its own migrator rather than an adapter |
spring-integration |
driver-spring-integration |
api and core, split by platform |
pg-model for what is multiplatform, driver and client for the rest |
An anonymous record did come back — over the text protocol, which is the whole of the problem. What
arrived was strings, the per-field OIDs having been dropped on the way, and a map with no target class has
nothing else to infer a type from. So dynamic_map carried the OID as data:
CREATE TYPE public.dynamic_map_entry AS (type_oid oid, key text, raw_value text);One entry per key, each stating its own type, which is what let a TypeHandler decode a timestamptz or a
custom enum instead of handing back the text. And it is exactly why the type could never be stored: those OIDs
sit in the rows, and a user-defined type's OID is not the same one after a dump and restore.
Registering types on the connection was the other road, and technically open — but it would have had to be wired into every physical connection a pool hands out, which with HikariCP in front is not something to look forward to maintaining.
The new driver speaks the binary protocol, so a record's fields arrive with their OIDs in the row description
and there is nothing left to work around. A ROW(...) in the SELECT clause is read as a map with its types
intact — no type to install, no ~> operator, and no warning to attach, an anonymous record having nowhere to
rot:
session.createNativeQuery(
"""
SELECT ROW(
'id', c.id,
'tributes', ARRAY(SELECT ROW('amount', t.amount) FROM tributes t WHERE t.citizen_id = c.id)
) AS r
FROM citizens c WHERE c.id = 1
"""
).fetchFieldStrict<Map<String, Any?>>()
// {id=1, tributes=[{amount=40}, {amount=15}]}The 1:N aggregation dynamic_map was built for works the same way, and so does the part that mattered: a
date comes back a LocalDate, a uuid a Uuid, a timestamptz an Instant. See
the raw forms.
.options { } carried five different things. Four of them are one thing now — a converter registered on a
query, ahead of the session's and discarded with it:
QueryOptions field |
Now |
|---|---|
typeHandlers |
registerResultConverter / registerParameterConverter on the query |
json |
dynamicTypes.resultConverter(json), registered the same way |
customCompositeMappers |
a converter narrowed on sourceType.name
|
returnCompositeAsMap(name) |
compositesAsMaps(name) |
returnAllCompositesAsMaps() |
compositesAsMaps() |
The last two are one function
with an overload that takes several names at once. One difference worth knowing: it claims a value only where
the caller asked for Any or Map, so naming a class in the same query still answers with the class, and a
data class is untouched below its own surface. Here the option won over everything, including a declared
property type.
toListOf(params, mapper) handed the library a function and got a List<T> back. The same shape is a
ResultConverter whose source class is Row: fetchObjects<T>() hands the whole row to the converters, so
one that claims Row is called once per row and is exactly where the mapper stood.
object CitizenFromRow : ResultConverter<Row, Citizen> {
override val supportedSourceClass = Row::class
override fun canConvert(sourceClass: KClass<*>, expectedType: KType, sourceType: PgType, context: DeserializationContext) =
expectedType.classifier == Citizen::class
override fun convert(source: Row, expectedType: KType, sourceType: PgType, context: DeserializationContext) =
Citizen(source.get("name"), source.get("province"))
}
session.createNativeQuery("SELECT name, province FROM citizens")
.registerResultConverter(CitizenFromRow)
.fetchObjects<Citizen>()Two things it gains on the way. forEachObject runs through the same converter, so the mapping streams rather
than only filling a list. And the registration can go on the session's typeManager instead of on the query —
then fetchObjects<Citizen>() maps that way everywhere, which is not something a per-call mapper could say.
The reflective mapping is still there for the ordinary case: a data class whose properties match the columns
needs no converter at all, which is what most DataMappers were written to do by hand.
One interface doing both directions became two — ResultConverter and ParameterConverter — each deciding for
itself what it claims through canConvert, which is what lets one narrow on the PostgreSQL type as well as on
the Kotlin one. Register on the session's typeManager for the old GlobalTypeHandler, or on a single query
for the old per-query one.
The one thing that does not carry over: client-scanner does not pick them up off the classpath. A
TypeHandler here was resolved from a map keyed by OID and class — one per type, no overlap, no order to get
wrong — which made scanning them safe. There, converters are consulted newest-first and a later one wins, so
registration order is the only override mechanism there is, and a classpath scan has no defined order. They are
registered by hand, in one place, on purpose:
What It Does Not Scan.
? escaping for JSONB operators. Parameters are @name and reach the wire as $1, so a ? in the SQL
is never anything but a ?.disableCoreTypeInitialization. Nothing is created behind your back to begin with — dynamic_dto goes
in a migration, or in an explicit install().showBanner. There is no startup the library owns: you hand it a DataSource, install() is a call you
make, and the migrator is a library rather than a command. Nowhere left to print one. 🫡It's not an ORM. It's a ROME. Written here, and far too good to leave behind.
Just as Augustus brought order to a republic torn apart by the chaos of unchecked power, Octavius brings order to the chaotic republic of database interactions. The Senate of abstraction is dissolved. SQL rules supreme.
Octavius was built to bring order to the chaotic republic of database interactions. It rejects the unpredictable "magic" of traditional ORMs and returns the power to the rightful ruler: SQL.
| Principle | Description |
|---|---|
| Query is Imperator | Your SQL query dictates the shape of data — not the framework. |
| Object is a Vessel | A data class is simply a type-safe container for query results. |
| Explicitness over Magic | No lazy-loading, no session management, no dirty checking. |
COMPOSITE, ENUM, ARRAY and Custom Type Handlers (Global & Per-Query) ↔ Kotlin typesdynamic_dto and dynamic_map
WHERE clauses with QueryFragment
Every design choice in Octavius is intentional. The reasoning behind them is laid out in the Design Philosophy.
// Define your data class — it maps directly to query results
data class Legionnaire(val id: Int, val name: String, val rank: String)
// Query with named parameters
val legionnaires = dataAccess.select("id", "name", "rank")
.from("legions")
.where("enlisted_year > @year")
.orderBy("name")
.toListOf<Legionnaire>("year" to 24)// SELECT with pagination
val senators = dataAccess.select("id", "name", "province")
.from("senate")
.where("active = true")
.orderBy("appointed_at DESC")
.limit(10)
.offset(20)
.toListOf<Senator>()
// INSERT with RETURNING
val newId = dataAccess.insertInto("citizens")
.value("name")
.value("tribe")
.returning("id")
.toField<Int>(mapOf("name" to "Marcus Aurelius", "tribe" to "Cornelia"))
// UPDATE with expressions
dataAccess.update("legion_supplies")
.setExpression("quantity", "quantity - 1")
.where("id = @id")
.execute("id" to supplyId)
// DELETE
dataAccess.deleteFrom("expired_mandates")
.where("expires_at < NOW()")
.execute()Automatic conversion between PostgreSQL and Kotlin types.
| PostgreSQL | Kotlin | Notes |
|---|---|---|
int2, smallserial
|
Short |
|
int4, serial
|
Int |
|
int8, bigserial
|
Long |
|
float4 |
Float |
|
float8 |
Double |
|
numeric |
BigDecimal |
|
text, varchar, char
|
String |
|
bool |
Boolean |
|
uuid |
Uuid |
kotlin.uuid.Uuid |
bytea |
ByteArray |
|
json, jsonb
|
JsonElement |
kotlinx.serialization.json |
void |
Unit |
Return type of void functions (e.g. pg_notify) |
date |
LocalDate |
kotlinx.datetime *
|
time |
LocalTime |
kotlinx.datetime |
timestamp |
LocalDateTime |
kotlinx.datetime *
|
timestamptz |
Instant |
kotlin.time *
|
interval |
Duration |
kotlin.time *
|
* Supports PostgreSQL infinity values (infinity, -infinity). See Type System for details.
Arrays of all standard types are supported and map to List<T>.
// PostgreSQL COMPOSITE TYPE → Kotlin data class
@PgComposite
data class Province(val name: String, val capital: String, val governor: String)
// PostgreSQL ENUM → Kotlin enum
@PgEnum(schema = "cursus_honorum")
enum class Magistrature { Quaestor, Aedile, Praetor, Consul, Censor }
// Works seamlessly in queries
data class Senator(val id: Int, val rank: Magistrature, val homeProvince: Province)
val senators = dataAccess.select("id", "rank", "home_province")
.from("senate")
.toListOf<Senator>() // Types converted automaticallyExtend the type system for any PostgreSQL type (e.g., circle, ltree) by implementing GlobalTypeHandler<T>. Handlers are automatically discovered via classpath scanning.
object PgCircleHandler : GlobalTypeHandler<PgCircle> {
override val pgTypeName = "circle"
override val kotlinClass = PgCircle::class
override val fromPgString = { s: String -> /* parse <(x,y),r> */ }
override val toPgString = { c: PgCircle -> "<(${c.x},${c.y}),${c.radius}>" }
}Need to change a mapping, bypass reflection, or return a composite as a Map for just one specific query? Use the .options() block without affecting global state:
val results = dataAccess.select("*").from("classified_reports")
.options {
registerTypeHandler(LegacyDateHandler)
returnCompositeAsMap("metadata")
}
.toListOf<Report>()See Type System: Per-Query Configuration for full details.
Octavius provides a powerful bridge between PostgreSQL and Kotlin's type system using the dynamic_dto (JSONB-based storage) and dynamic_map (ad-hoc projections) composite types.
They allow you to map complex, nested, or polymorphic data on the fly without creating strict database schema types for every nested object.
These types are automatically initialized in the public schema on startup.
Construct Kotlin objects directly in SQL using jsonb_build_object — no need to define PostgreSQL COMPOSITE types. Perfect for JOINs and projections where you want nested results without schema changes.
@DynamicallyMappable(typeName = "citizen_profile")
@Serializable
data class CitizenProfile(val tribe: String, val rights: List<String>)
data class CitizenWithProfile(val id: Int, val name: String, val profile: CitizenProfile)
// The database packages the nested object, Octavius unpacks it. Zero boilerplate.
val citizens = dataAccess.rawQuery("""
SELECT
c.id,
c.name,
dynamic_dto(
'citizen_profile',
jsonb_build_object('tribe', p.tribe, 'rights', p.rights)
) AS profile
FROM citizens c
JOIN citizen_profiles p ON p.citizen_id = c.id
""").toListOf<CitizenWithProfile>()Why use this? Usually, to get a citizen with their profile in one query, you'd fetch flat columns (
citizen_id,citizen_name,profile_tribe...) and manually map them, create a database VIEW or COMPOSITE. With ad-hoc mapping, you construct the nested structure directly in SQL. The database does the packaging, Octavius does the unpacking — zero boilerplate.
Store different entity types in a single table and query them safely as a list of Kotlin interfaces.
// 1. Define a sealed interface
sealed interface MonumentRecord
@DynamicallyMappable(typeName = "inscription")
@Serializable
data class Inscription(val text: String, val lang: String) : MonumentRecord
@DynamicallyMappable(typeName = "relief")
@Serializable
data class Relief(val subject: String) : MonumentRecord
// Database: CREATE TABLE monument_records (id INT, record dynamic_dto);
// 2. Fetch directly to a list of your interface
val records = dataAccess.select("record")
.from("monument_records")
.toColumn<MonumentRecord>()
// Returns: [Inscription(...), Relief(...), Inscription(...)]Octavius stays true to its SQL-first philosophy. Invoke functions and procedures directly using native PostgreSQL syntax:
// Functions (SELECT * FROM func)
val result = dataAccess.select("*").from("calculate_tribute(@province, @year)")
.toField<Int>("province" to "Britannia", "year" to 43)
// Procedures (CALL proc)
val result = dataAccess.rawQuery("CALL register_conscript(@legion_id, @new_rank)")
.toSingleStrict(
"legion_id" to 7,
"new_rank" to null.withPgType("text")
)Build complex WHERE clauses without SQL injection risks:
fun buildFilters(name: String?, minRank: Int?, province: Province?) = listOfNotNull(
name?.let { "name ILIKE @name" withParam ("name" to "%$it%") },
minRank?.let { "rank_order >= @minRank" withParam ("minRank" to it) },
province?.let { "home_province = @province" withParam ("province" to it) }
).join(" AND ")
val filter = buildFilters(name = "Julius", minRank = 3, province = null)
val senators = dataAccess.select("*")
.from("senate")
.where(filter.sql)
.toListOf<Senator>(filter.params)Octavius supports two powerful interaction patterns for atomic operations.
The simplest way to execute multiple operations. Transactions follow a fail-fast policy: they are automatically rolled back if the block returns DataResult.Failure or throws an exception.
val result = dataAccess.transaction {
val citizenId = insertInto("citizens")
.value("name")
.returning("id")
.toField<Int>("name" to "Marcus Aurelius")
.getOrElse { return@transaction it }
insertInto("citizen_profiles")
.values(listOf("citizen_id", "bio"))
.execute("citizen_id" to citizenId, "bio" to "Stoic philosopher")
.getOrElse { return@transaction it }
DataResult.Success(citizenId)
}Execute multi-step operations with complex dependencies between steps. Results from previous steps can be referenced in subsequent steps without nested callbacks or manual state management.
val plan = TransactionPlan()
// Step 1: Record the edict, get handle to future ID
val edictIdHandle = plan.add(
dataAccess.insertInto("edicts")
.values(listOf("issuer_id", "total_tribute"))
.returning("id")
.asStep()
.toField<Int>(mapOf("issuer_id" to consulId, "total_tribute" to tribute))
)
// Step 2: Assign levy items using the handle
for (item in levyItems) {
val levyItem: Map<String, Any?> = mapOf(
"edict_id" to edictIdHandle.field(), // Reference future value
"province_id" to item.provinceId,
"amount" to item.amount
)
plan.add(
dataAccess.insertInto("edict_items")
.values(levyItem)
.asStep()
.execute(levyItem)
)
}
// Execute all steps in single transaction
dataAccess.executeTransactionPlan(plan)Subscribe to PostgreSQL channels and receive real-time notifications as a Kotlin Flow:
// Send a notification
dataAccess.notify("legion_dispatch", "legion_id:VII")
// Listen on a dedicated connection (outside the HikariCP pool)
dataAccess.createChannelListener().use { listener ->
listener.listen("legion_dispatch", "senate_decrees")
listener.notifications()
.collect { notification ->
when (notification.channel) {
"legion_dispatch" -> handleDispatch(notification.payload)
"senate_decrees" -> handleDecree(notification.payload)
}
}
}Each PgChannelListener holds its own dedicated JDBC connection, separate from the query pool. Notifications sent inside a transaction are only delivered after commit.
Octavius distinguishes between Database Execution Errors (returned safely) and Fatal Developer Errors (thrown).
DataResult.Failure(error) instead of throwing. This forces explicit handling of expected database errors like constraint violations, lock timeouts, or missing records.WHERE clause in a DELETE, or a Kotlin type mapping mismatch), Octavius throws a standard exception (FatalDatabaseException). It fails fast because these errors represent broken code that should be caught and fixed during development.QueryContext that provides a clean visualization of the high-level SQL, the low-level JDBC query, and the exact parameters involved (great for logging!).val result = dataAccess.insertInto("citizens")
.value("name")
.returning("id")
.toField<Int>("name" to "Marcus Aurelius")
result
.onSuccess { id -> println("New citizen ID: $id") }
.onFailure { error ->
when (error) {
is ConstraintViolationException -> println("Conflict in: ${error.constraintName}")
is DataOperationException -> println("Operation failed: ${error.messageEnum}")
is TransactionException -> println("Transient error: ${error.errorType}")
else -> println("Database error: $error")
}
}See Error Handling for the full exception hierarchy and debugging tips.
Create a database.properties file in src/main/resources:
db.url=jdbc:postgresql://localhost:5432/roma
db.username=augustus
db.password=spqr
db.schemas=public,cursus_honorum
db.packagesToScan=com.roma.domain,com.roma.dto
# Custom HikariCP settings
db.hikari.maximumPoolSize=20
db.hikari.minimumIdle=5
# Optional settings
db.setSearchPath=true
db.dynamicDtoStrategy=AUTOMATIC_WHEN_UNAMBIGUOUS
db.disableCoreTypeInitialization=falseLoad it in your application:
// From properties file
val dataAccess = OctaviusDatabase.fromConfig(
DatabaseConfig.loadFromFile("database.properties")
)val dataAccess = OctaviusDatabase.fromConfig(
DatabaseConfig(
dbUrl = "jdbc:postgresql://localhost:5432/roma",
dbUsername = "augustus",
dbPassword = "spqr",
dbSchemas = listOf("public"),
packagesToScan = listOf("com.roma.domain"),
hikariProperties = mapOf("maximumPoolSize" to "20")
)
)
// From existing DataSource
val dataAccess = OctaviusDatabase.fromDataSource(existingDataSource, ...)Octavius provides an optional integration with Flyway for schema migrations via the :flyway-integration module.
val dataAccess = OctaviusDatabase.fromConfig(
config = config,
migrationRunner = FlywayMigrationRunner.create(
schemas = config.dbSchemas,
baselineVersion = "1"
)
)See Flyway Migrations in the configuration guide for details.
For detailed guides and examples, see the full documentation:
.options() and builder modes| Module | Platform | Description |
|---|---|---|
api |
Multiplatform | Common: Annotations & DTOs (JVM/JS). JVM-only: Query & Transaction interfaces. |
core |
JVM | Zero-dependency core engine. Pure JDBC & HikariCP. |
spring-integration |
JVM | Optional integration for Spring Boot (@Transactional support). |
flyway-integration |
JVM | Optional migration runner integration. |
This library is legacy — superseded by octavius-postgresql.
An explicit, SQL-first data access layer for Kotlin & PostgreSQL
It's not an ORM. It's a ROME (Relational-Object Mapping Engine). Because all queries lead to ROME.
[!WARNING] This project is legacy and is no longer actively developed. It has been superseded by octavius-postgresql — a PostgreSQL driver that speaks Wire Protocol v3.2 directly, a client (data access layer) built on top of it, and a migrator, published as separate artifacts.
Much of what lives here exists to work around pgjdbc — text-protocol composites, enums the library had to be taught about, a stateful
ResultSet, no named parameters — and the new driver answers all of that natively. What was left over is the client, deliberately a much smaller thing than a port would have been.Nothing is being taken away: the released artifacts stay on Maven Central and the code below still works. But new work happens in
octavius-postgresql, and new projects should start there.
Nothing here was dropped for want of a home. Most of it was a workaround for pgjdbc and the new driver answers it natively; the rest moved, and a few things changed shape enough to be worth a sentence.
| Here | There |
|---|---|
SelectQueryBuilder and the other three — with(), recursive(), fromSubquery, page, forUpdate, onConflict, fromSelect, using, returning
|
The four builders, method for method |
QueryFragment, withParam, join
|
QueryFragment |
dynamic_dto |
dynamic_dto — same idea, a real composite instead of a text-protocol one |
dynamic_map |
plain ROW(...) — see below |
.options { } |
per-query converters — see below |
TypeHandler<T>, GlobalTypeHandler<T>
|
ResultConverter + ParameterConverter — see below |
@MapKey |
@PgName, and it applies in both directions |
@PgEnum, @PgComposite, @DynamicallyMappable
|
@PgEnumType, @PgCompositeType, @DynamicallyMappable, scanned by client-scanner
|
toDataObject(), toDataMap()
|
the same two |
DataMapper, .toListOf { map -> … }
|
a ResultConverter<Row, T> — see below |
TransactionPlan, TransactionStep, StepHandle
|
Transaction Plans, and every failure names its step on the exception's path
|
PgChannelListener |
LISTEN / NOTIFY |
| CALL with IN/OUT/INOUT | Functions and Procedures |
bulk unnest
|
Bulk Writes |
DataResult, quoteAsPgIdentifier, PgTyped/withPgType, QualifiedName, CaseConvention
|
the same names |
flyway-integration |
migrations, which is its own migrator rather than an adapter |
spring-integration |
driver-spring-integration |
api and core, split by platform |
pg-model for what is multiplatform, driver and client for the rest |
An anonymous record did come back — over the text protocol, which is the whole of the problem. What
arrived was strings, the per-field OIDs having been dropped on the way, and a map with no target class has
nothing else to infer a type from. So dynamic_map carried the OID as data:
CREATE TYPE public.dynamic_map_entry AS (type_oid oid, key text, raw_value text);One entry per key, each stating its own type, which is what let a TypeHandler decode a timestamptz or a
custom enum instead of handing back the text. And it is exactly why the type could never be stored: those OIDs
sit in the rows, and a user-defined type's OID is not the same one after a dump and restore.
Registering types on the connection was the other road, and technically open — but it would have had to be wired into every physical connection a pool hands out, which with HikariCP in front is not something to look forward to maintaining.
The new driver speaks the binary protocol, so a record's fields arrive with their OIDs in the row description
and there is nothing left to work around. A ROW(...) in the SELECT clause is read as a map with its types
intact — no type to install, no ~> operator, and no warning to attach, an anonymous record having nowhere to
rot:
session.createNativeQuery(
"""
SELECT ROW(
'id', c.id,
'tributes', ARRAY(SELECT ROW('amount', t.amount) FROM tributes t WHERE t.citizen_id = c.id)
) AS r
FROM citizens c WHERE c.id = 1
"""
).fetchFieldStrict<Map<String, Any?>>()
// {id=1, tributes=[{amount=40}, {amount=15}]}The 1:N aggregation dynamic_map was built for works the same way, and so does the part that mattered: a
date comes back a LocalDate, a uuid a Uuid, a timestamptz an Instant. See
the raw forms.
.options { } carried five different things. Four of them are one thing now — a converter registered on a
query, ahead of the session's and discarded with it:
QueryOptions field |
Now |
|---|---|
typeHandlers |
registerResultConverter / registerParameterConverter on the query |
json |
dynamicTypes.resultConverter(json), registered the same way |
customCompositeMappers |
a converter narrowed on sourceType.name
|
returnCompositeAsMap(name) |
compositesAsMaps(name) |
returnAllCompositesAsMaps() |
compositesAsMaps() |
The last two are one function
with an overload that takes several names at once. One difference worth knowing: it claims a value only where
the caller asked for Any or Map, so naming a class in the same query still answers with the class, and a
data class is untouched below its own surface. Here the option won over everything, including a declared
property type.
toListOf(params, mapper) handed the library a function and got a List<T> back. The same shape is a
ResultConverter whose source class is Row: fetchObjects<T>() hands the whole row to the converters, so
one that claims Row is called once per row and is exactly where the mapper stood.
object CitizenFromRow : ResultConverter<Row, Citizen> {
override val supportedSourceClass = Row::class
override fun canConvert(sourceClass: KClass<*>, expectedType: KType, sourceType: PgType, context: DeserializationContext) =
expectedType.classifier == Citizen::class
override fun convert(source: Row, expectedType: KType, sourceType: PgType, context: DeserializationContext) =
Citizen(source.get("name"), source.get("province"))
}
session.createNativeQuery("SELECT name, province FROM citizens")
.registerResultConverter(CitizenFromRow)
.fetchObjects<Citizen>()Two things it gains on the way. forEachObject runs through the same converter, so the mapping streams rather
than only filling a list. And the registration can go on the session's typeManager instead of on the query —
then fetchObjects<Citizen>() maps that way everywhere, which is not something a per-call mapper could say.
The reflective mapping is still there for the ordinary case: a data class whose properties match the columns
needs no converter at all, which is what most DataMappers were written to do by hand.
One interface doing both directions became two — ResultConverter and ParameterConverter — each deciding for
itself what it claims through canConvert, which is what lets one narrow on the PostgreSQL type as well as on
the Kotlin one. Register on the session's typeManager for the old GlobalTypeHandler, or on a single query
for the old per-query one.
The one thing that does not carry over: client-scanner does not pick them up off the classpath. A
TypeHandler here was resolved from a map keyed by OID and class — one per type, no overlap, no order to get
wrong — which made scanning them safe. There, converters are consulted newest-first and a later one wins, so
registration order is the only override mechanism there is, and a classpath scan has no defined order. They are
registered by hand, in one place, on purpose:
What It Does Not Scan.
? escaping for JSONB operators. Parameters are @name and reach the wire as $1, so a ? in the SQL
is never anything but a ?.disableCoreTypeInitialization. Nothing is created behind your back to begin with — dynamic_dto goes
in a migration, or in an explicit install().showBanner. There is no startup the library owns: you hand it a DataSource, install() is a call you
make, and the migrator is a library rather than a command. Nowhere left to print one. 🫡It's not an ORM. It's a ROME. Written here, and far too good to leave behind.
Just as Augustus brought order to a republic torn apart by the chaos of unchecked power, Octavius brings order to the chaotic republic of database interactions. The Senate of abstraction is dissolved. SQL rules supreme.
Octavius was built to bring order to the chaotic republic of database interactions. It rejects the unpredictable "magic" of traditional ORMs and returns the power to the rightful ruler: SQL.
| Principle | Description |
|---|---|
| Query is Imperator | Your SQL query dictates the shape of data — not the framework. |
| Object is a Vessel | A data class is simply a type-safe container for query results. |
| Explicitness over Magic | No lazy-loading, no session management, no dirty checking. |
COMPOSITE, ENUM, ARRAY and Custom Type Handlers (Global & Per-Query) ↔ Kotlin typesdynamic_dto and dynamic_map
WHERE clauses with QueryFragment
Every design choice in Octavius is intentional. The reasoning behind them is laid out in the Design Philosophy.
// Define your data class — it maps directly to query results
data class Legionnaire(val id: Int, val name: String, val rank: String)
// Query with named parameters
val legionnaires = dataAccess.select("id", "name", "rank")
.from("legions")
.where("enlisted_year > @year")
.orderBy("name")
.toListOf<Legionnaire>("year" to 24)// SELECT with pagination
val senators = dataAccess.select("id", "name", "province")
.from("senate")
.where("active = true")
.orderBy("appointed_at DESC")
.limit(10)
.offset(20)
.toListOf<Senator>()
// INSERT with RETURNING
val newId = dataAccess.insertInto("citizens")
.value("name")
.value("tribe")
.returning("id")
.toField<Int>(mapOf("name" to "Marcus Aurelius", "tribe" to "Cornelia"))
// UPDATE with expressions
dataAccess.update("legion_supplies")
.setExpression("quantity", "quantity - 1")
.where("id = @id")
.execute("id" to supplyId)
// DELETE
dataAccess.deleteFrom("expired_mandates")
.where("expires_at < NOW()")
.execute()Automatic conversion between PostgreSQL and Kotlin types.
| PostgreSQL | Kotlin | Notes |
|---|---|---|
int2, smallserial
|
Short |
|
int4, serial
|
Int |
|
int8, bigserial
|
Long |
|
float4 |
Float |
|
float8 |
Double |
|
numeric |
BigDecimal |
|
text, varchar, char
|
String |
|
bool |
Boolean |
|
uuid |
Uuid |
kotlin.uuid.Uuid |
bytea |
ByteArray |
|
json, jsonb
|
JsonElement |
kotlinx.serialization.json |
void |
Unit |
Return type of void functions (e.g. pg_notify) |
date |
LocalDate |
kotlinx.datetime *
|
time |
LocalTime |
kotlinx.datetime |
timestamp |
LocalDateTime |
kotlinx.datetime *
|
timestamptz |
Instant |
kotlin.time *
|
interval |
Duration |
kotlin.time *
|
* Supports PostgreSQL infinity values (infinity, -infinity). See Type System for details.
Arrays of all standard types are supported and map to List<T>.
// PostgreSQL COMPOSITE TYPE → Kotlin data class
@PgComposite
data class Province(val name: String, val capital: String, val governor: String)
// PostgreSQL ENUM → Kotlin enum
@PgEnum(schema = "cursus_honorum")
enum class Magistrature { Quaestor, Aedile, Praetor, Consul, Censor }
// Works seamlessly in queries
data class Senator(val id: Int, val rank: Magistrature, val homeProvince: Province)
val senators = dataAccess.select("id", "rank", "home_province")
.from("senate")
.toListOf<Senator>() // Types converted automaticallyExtend the type system for any PostgreSQL type (e.g., circle, ltree) by implementing GlobalTypeHandler<T>. Handlers are automatically discovered via classpath scanning.
object PgCircleHandler : GlobalTypeHandler<PgCircle> {
override val pgTypeName = "circle"
override val kotlinClass = PgCircle::class
override val fromPgString = { s: String -> /* parse <(x,y),r> */ }
override val toPgString = { c: PgCircle -> "<(${c.x},${c.y}),${c.radius}>" }
}Need to change a mapping, bypass reflection, or return a composite as a Map for just one specific query? Use the .options() block without affecting global state:
val results = dataAccess.select("*").from("classified_reports")
.options {
registerTypeHandler(LegacyDateHandler)
returnCompositeAsMap("metadata")
}
.toListOf<Report>()See Type System: Per-Query Configuration for full details.
Octavius provides a powerful bridge between PostgreSQL and Kotlin's type system using the dynamic_dto (JSONB-based storage) and dynamic_map (ad-hoc projections) composite types.
They allow you to map complex, nested, or polymorphic data on the fly without creating strict database schema types for every nested object.
These types are automatically initialized in the public schema on startup.
Construct Kotlin objects directly in SQL using jsonb_build_object — no need to define PostgreSQL COMPOSITE types. Perfect for JOINs and projections where you want nested results without schema changes.
@DynamicallyMappable(typeName = "citizen_profile")
@Serializable
data class CitizenProfile(val tribe: String, val rights: List<String>)
data class CitizenWithProfile(val id: Int, val name: String, val profile: CitizenProfile)
// The database packages the nested object, Octavius unpacks it. Zero boilerplate.
val citizens = dataAccess.rawQuery("""
SELECT
c.id,
c.name,
dynamic_dto(
'citizen_profile',
jsonb_build_object('tribe', p.tribe, 'rights', p.rights)
) AS profile
FROM citizens c
JOIN citizen_profiles p ON p.citizen_id = c.id
""").toListOf<CitizenWithProfile>()Why use this? Usually, to get a citizen with their profile in one query, you'd fetch flat columns (
citizen_id,citizen_name,profile_tribe...) and manually map them, create a database VIEW or COMPOSITE. With ad-hoc mapping, you construct the nested structure directly in SQL. The database does the packaging, Octavius does the unpacking — zero boilerplate.
Store different entity types in a single table and query them safely as a list of Kotlin interfaces.
// 1. Define a sealed interface
sealed interface MonumentRecord
@DynamicallyMappable(typeName = "inscription")
@Serializable
data class Inscription(val text: String, val lang: String) : MonumentRecord
@DynamicallyMappable(typeName = "relief")
@Serializable
data class Relief(val subject: String) : MonumentRecord
// Database: CREATE TABLE monument_records (id INT, record dynamic_dto);
// 2. Fetch directly to a list of your interface
val records = dataAccess.select("record")
.from("monument_records")
.toColumn<MonumentRecord>()
// Returns: [Inscription(...), Relief(...), Inscription(...)]Octavius stays true to its SQL-first philosophy. Invoke functions and procedures directly using native PostgreSQL syntax:
// Functions (SELECT * FROM func)
val result = dataAccess.select("*").from("calculate_tribute(@province, @year)")
.toField<Int>("province" to "Britannia", "year" to 43)
// Procedures (CALL proc)
val result = dataAccess.rawQuery("CALL register_conscript(@legion_id, @new_rank)")
.toSingleStrict(
"legion_id" to 7,
"new_rank" to null.withPgType("text")
)Build complex WHERE clauses without SQL injection risks:
fun buildFilters(name: String?, minRank: Int?, province: Province?) = listOfNotNull(
name?.let { "name ILIKE @name" withParam ("name" to "%$it%") },
minRank?.let { "rank_order >= @minRank" withParam ("minRank" to it) },
province?.let { "home_province = @province" withParam ("province" to it) }
).join(" AND ")
val filter = buildFilters(name = "Julius", minRank = 3, province = null)
val senators = dataAccess.select("*")
.from("senate")
.where(filter.sql)
.toListOf<Senator>(filter.params)Octavius supports two powerful interaction patterns for atomic operations.
The simplest way to execute multiple operations. Transactions follow a fail-fast policy: they are automatically rolled back if the block returns DataResult.Failure or throws an exception.
val result = dataAccess.transaction {
val citizenId = insertInto("citizens")
.value("name")
.returning("id")
.toField<Int>("name" to "Marcus Aurelius")
.getOrElse { return@transaction it }
insertInto("citizen_profiles")
.values(listOf("citizen_id", "bio"))
.execute("citizen_id" to citizenId, "bio" to "Stoic philosopher")
.getOrElse { return@transaction it }
DataResult.Success(citizenId)
}Execute multi-step operations with complex dependencies between steps. Results from previous steps can be referenced in subsequent steps without nested callbacks or manual state management.
val plan = TransactionPlan()
// Step 1: Record the edict, get handle to future ID
val edictIdHandle = plan.add(
dataAccess.insertInto("edicts")
.values(listOf("issuer_id", "total_tribute"))
.returning("id")
.asStep()
.toField<Int>(mapOf("issuer_id" to consulId, "total_tribute" to tribute))
)
// Step 2: Assign levy items using the handle
for (item in levyItems) {
val levyItem: Map<String, Any?> = mapOf(
"edict_id" to edictIdHandle.field(), // Reference future value
"province_id" to item.provinceId,
"amount" to item.amount
)
plan.add(
dataAccess.insertInto("edict_items")
.values(levyItem)
.asStep()
.execute(levyItem)
)
}
// Execute all steps in single transaction
dataAccess.executeTransactionPlan(plan)Subscribe to PostgreSQL channels and receive real-time notifications as a Kotlin Flow:
// Send a notification
dataAccess.notify("legion_dispatch", "legion_id:VII")
// Listen on a dedicated connection (outside the HikariCP pool)
dataAccess.createChannelListener().use { listener ->
listener.listen("legion_dispatch", "senate_decrees")
listener.notifications()
.collect { notification ->
when (notification.channel) {
"legion_dispatch" -> handleDispatch(notification.payload)
"senate_decrees" -> handleDecree(notification.payload)
}
}
}Each PgChannelListener holds its own dedicated JDBC connection, separate from the query pool. Notifications sent inside a transaction are only delivered after commit.
Octavius distinguishes between Database Execution Errors (returned safely) and Fatal Developer Errors (thrown).
DataResult.Failure(error) instead of throwing. This forces explicit handling of expected database errors like constraint violations, lock timeouts, or missing records.WHERE clause in a DELETE, or a Kotlin type mapping mismatch), Octavius throws a standard exception (FatalDatabaseException). It fails fast because these errors represent broken code that should be caught and fixed during development.QueryContext that provides a clean visualization of the high-level SQL, the low-level JDBC query, and the exact parameters involved (great for logging!).val result = dataAccess.insertInto("citizens")
.value("name")
.returning("id")
.toField<Int>("name" to "Marcus Aurelius")
result
.onSuccess { id -> println("New citizen ID: $id") }
.onFailure { error ->
when (error) {
is ConstraintViolationException -> println("Conflict in: ${error.constraintName}")
is DataOperationException -> println("Operation failed: ${error.messageEnum}")
is TransactionException -> println("Transient error: ${error.errorType}")
else -> println("Database error: $error")
}
}See Error Handling for the full exception hierarchy and debugging tips.
Create a database.properties file in src/main/resources:
db.url=jdbc:postgresql://localhost:5432/roma
db.username=augustus
db.password=spqr
db.schemas=public,cursus_honorum
db.packagesToScan=com.roma.domain,com.roma.dto
# Custom HikariCP settings
db.hikari.maximumPoolSize=20
db.hikari.minimumIdle=5
# Optional settings
db.setSearchPath=true
db.dynamicDtoStrategy=AUTOMATIC_WHEN_UNAMBIGUOUS
db.disableCoreTypeInitialization=falseLoad it in your application:
// From properties file
val dataAccess = OctaviusDatabase.fromConfig(
DatabaseConfig.loadFromFile("database.properties")
)val dataAccess = OctaviusDatabase.fromConfig(
DatabaseConfig(
dbUrl = "jdbc:postgresql://localhost:5432/roma",
dbUsername = "augustus",
dbPassword = "spqr",
dbSchemas = listOf("public"),
packagesToScan = listOf("com.roma.domain"),
hikariProperties = mapOf("maximumPoolSize" to "20")
)
)
// From existing DataSource
val dataAccess = OctaviusDatabase.fromDataSource(existingDataSource, ...)Octavius provides an optional integration with Flyway for schema migrations via the :flyway-integration module.
val dataAccess = OctaviusDatabase.fromConfig(
config = config,
migrationRunner = FlywayMigrationRunner.create(
schemas = config.dbSchemas,
baselineVersion = "1"
)
)See Flyway Migrations in the configuration guide for details.
For detailed guides and examples, see the full documentation:
.options() and builder modes| Module | Platform | Description |
|---|---|---|
api |
Multiplatform | Common: Annotations & DTOs (JVM/JS). JVM-only: Query & Transaction interfaces. |
core |
JVM | Zero-dependency core engine. Pure JDBC & HikariCP. |
spring-integration |
JVM | Optional integration for Spring Boot (@Transactional support). |
flyway-integration |
JVM | Optional migration runner integration. |
This library is legacy — superseded by octavius-postgresql.