surf-database-r2dbc is an R2DBC provider for JetBrains Exposed, enabling non-blocking database
operations in JVM applications.
It provides:
- Integration of Exposed DSL with R2DBC
- Non-blocking, reactive database operations
- Kotlin coroutines support
- Configuration-based connection pool management
- MariaDB/MySQL support via R2DBC
The library is built on top of JetBrains Exposed, R2DBC, and Kotlin coroutines.
DatabaseApi is the central entry point for database operations.
It manages the R2DBC connection pool and exposes the underlying
Exposed R2dbcDatabase.
A typical application creates exactly one DatabaseApi instance and shares it across the
system.
Lifecycle:
- Create the API from a plugin path (loads configuration)
- Initialize tables
- Execute queries using Exposed
- Shutdown on application termination
val databaseApi = DatabaseApi.create(pluginPath)
// Access the underlying Exposed database
databaseApi.databaseIn most applications, DatabaseApi is wrapped inside a service that manages its lifecycle and
provides a global access point.
abstract class DatabaseService {
val databaseApi = DatabaseApi.create(
pluginPath = dataFolder.toPath(),
poolName = "my-app-pool"
)
suspend fun connect() {
initializeTables()
}
@MustBeInvokedByOverriders
@ApiStatus.OverrideOnly
protected open suspend fun initializeTables() {
// Initialize database schema using Exposed
// See: https://www.jetbrains.com/help/exposed/working-with-tables.html#dsl-create-table
}
fun disconnect() {
databaseApi.shutdown()
}
companion object {
val instance = requiredService<DatabaseService>()
fun get() = instance
}
}
val databaseApi get() = DatabaseService.get().databaseApi
@AutoService(DatabaseService::class)
class MyDatabaseService : DatabaseService() {
override suspend fun initializeTables() {
super.initializeTables()
// suspendTransaction {
// SchemaUtils.create(UsersTable, ItemsTable)
// }
}
}The DatabaseApi.create(pluginPath) method loads configuration from a database.yml file located
relative to the provided path.
Example database.yml:
credentials:
host: localhost
port: 3306
username: myuser
password: mypassword
database: mydb
pool:
sizing:
initialSize: 5
minIdle: 5
maxSize: 20
timeouts:
maxAcquireTimeMillis: 30000
maxCreateConnectionTimeMillis: 10000
maxIdleTimeMillis: 600000
maxLifeTimeMillis: 1800000
maxValidationTimeMillis: 5000
logLevel: DEBUGDatabaseApi.create(pluginPath) first loads database.yml, then applies environment-variable
overrides before it creates the connection factory and pool:
environment > database.yml > built-in default
A missing variable preserves its YAML/default value. A present but malformed value fails startup and
identifies the variable. Database types and log levels are parsed case-insensitively. Pool timeout
values retain the existing millisecond semantics, including -1 where R2DBC Pool uses it to disable
a timeout.
| Environment variable | YAML option | Built-in default |
|---|---|---|
SURF_DATABASE_LOG_LEVEL |
logLevel |
DEBUG |
SURF_DATABASE_TYPE |
credentials.databaseType |
MARIADB |
SURF_DATABASE_SCHEMA |
credentials.schema |
public (PostgreSQL only) |
SURF_DATABASE_HOST |
credentials.host |
localhost |
SURF_DATABASE_PORT |
credentials.port |
3306; valid range 1..65535 |
SURF_DATABASE_NAME |
credentials.database |
database |
SURF_DATABASE_USERNAME |
credentials.username |
root |
SURF_DATABASE_PASSWORD |
credentials.password |
existing YAML/default value; configure as a secret |
SURF_DATABASE_POOL_INITIAL_SIZE |
pool.sizing.initialSize |
10 |
SURF_DATABASE_POOL_MIN_IDLE |
pool.sizing.minIdle |
0 |
SURF_DATABASE_POOL_MAX_SIZE |
pool.sizing.maxSize |
10 |
SURF_DATABASE_POOL_MAX_ACQUIRE_TIME_MILLIS |
pool.timeouts.maxAcquireTimeMillis |
10000 |
SURF_DATABASE_POOL_MAX_CREATE_CONNECTION_TIME_MILLIS |
pool.timeouts.maxCreateConnectionTimeMillis |
30000 |
SURF_DATABASE_POOL_MAX_VALIDATION_TIME_MILLIS |
pool.timeouts.maxValidationTimeMillis |
-1 |
SURF_DATABASE_POOL_MAX_IDLE_TIME_MILLIS |
pool.timeouts.maxIdleTimeMillis |
60000 |
SURF_DATABASE_POOL_MAX_LIFE_TIME_MILLIS |
pool.timeouts.maxLifeTimeMillis |
1800000 |
MariaDB example:
SURF_DATABASE_TYPE=mariadb
SURF_DATABASE_HOST=mariadb.internal
SURF_DATABASE_PORT=3306
SURF_DATABASE_NAME=surf
SURF_DATABASE_USERNAME=surf-service
SURF_DATABASE_PASSWORD=
SURF_DATABASE_POOL_MAX_SIZE=20PostgreSQL example:
SURF_DATABASE_TYPE=postgresql
SURF_DATABASE_SCHEMA=application
SURF_DATABASE_HOST=postgres.internal
SURF_DATABASE_PORT=5432
SURF_DATABASE_NAME=surf
SURF_DATABASE_USERNAME=surf-service
SURF_DATABASE_PASSWORD=
SURF_DATABASE_POOL_MAX_SIZE=20For Coolify, add these variables to the application service and point the host at the external
database service. Mark SURF_DATABASE_PASSWORD as a secret. Do not commit real database credentials
to database.yml, Dockerfiles, .env files, or source control. The application image does not
install or run the database server.
The optional poolName parameter helps identify the connection pool in logs and monitoring:
DatabaseApi.create(
pluginPath = dataFolder.toPath(),
poolName = "my-plugin-pool"
)If not specified, a pool name is auto-generated based on the caller class.
surf-database-r2dbc is a provider, not a query API. All database operations are performed
using JetBrains Exposed.
Refer to the official Exposed documentation:
suspend fun findUserById(id: UUID): User? = suspendTransaction {
UsersTable
.select { UsersTable.id eq id }
.singleOrNull()
?.toUser()
}For tests, you can bypass configuration loading and provide a ConnectionFactory directly:
@OptIn(TestOnlyDatabaseApi::class)
val databaseApi = DatabaseApi.create(
connectionFactory = myTestConnectionFactory
)This is useful with Testcontainers or in-memory databases.
@Testcontainers
class DatabaseTest {
companion object {
@Container
val mariaDb = MariaDBContainer("mariadb")
.withDatabaseName("testdb")
}
lateinit var databaseApi: DatabaseApi
@BeforeEach
fun setup() {
val options = ConnectionFactoryOptions.builder()
.option(DRIVER, "mariadb")
.option(HOST, mariaDb.host)
.option(PORT, mariaDb.firstMappedPort)
.option(USER, mariaDb.username)
.option(PASSWORD, mariaDb.password)
.option(DATABASE, mariaDb.databaseName)
.build()
val pool = ConnectionPool(
ConnectionPoolConfiguration.builder()
.connectionFactory(ConnectionFactories.get(options))
.build()
)
databaseApi = DatabaseApi.create(pool)
}
@AfterEach
fun teardown() {
databaseApi.shutdown()
}
}The configuration-based entry point supports MariaDB and PostgreSQL through their R2DBC
drivers. Set credentials.databaseType or SURF_DATABASE_TYPE to select the driver.
credentials.schema and SURF_DATABASE_SCHEMA apply only to PostgreSQL and are safely ignored for
MariaDB.
Guaranteed:
- Non-blocking database operations
- Connection pooling with configurable sizing and timeouts
- Integration with Exposed's DSL and type-safe queries
- Proper resource cleanup via
shutdown()
Not guaranteed:
- Support for databases other than MariaDB and PostgreSQL without custom setup
- Automatic schema migrations
- Cross-database transactions
The connection pool must be closed explicitly:
Runtime.getRuntime().addShutdownHook(Thread {
DatabaseService.get().disconnect()
})Failing to do so may leave connections open and cause resource leaks.
Table initialization requires a database connection. Always create DatabaseApi before calling
initializeTables():
// BAD
suspend fun connect() {
initializeTables() // database not ready yet
databaseApi = DatabaseApi.create(pluginPath)
}// GOOD
suspend fun connect() {
databaseApi = DatabaseApi.create(pluginPath)
initializeTables()
}Or better: initialize databaseApi as a class property, then call initializeTables() in
connect().
Exposed supports both JDBC and R2DBC. This library provides R2DBC only.
Always use:
suspendTransaction {
// queries here
}Do not use:
transaction {
// This is blocking JDBC, not R2DBC
}The initializeTables() method must be suspend to allow safe schema initialization:
// BAD
protected open fun initializeTables() {
// Cannot use suspending functions here
}// GOOD
protected open suspend fun initializeTables() {
suspendTransaction {
SchemaUtils.create(UsersTable)
}
}The connection pool has a maximum size defined in database.yml.
If all connections are in use, new transactions will wait up to maxAcquireTimeMillis before
failing.
Monitor connection usage and adjust pool sizing if needed:
pool:
sizing:
maxSize: 50 # Increase if neededThe config-based DatabaseApi.create(pluginPath) overload is for production.
The ConnectionFactory-based overload is for tests.
Do not mix them:
// BAD
val databaseApi = DatabaseApi.create(pluginPath)
// then later try to create another with manual factoryChoose one approach per application lifecycle.
Some APIs are annotated with @TestOnlyDatabaseApi.
These APIs:
- are primarily intended for tests
- bypass config-based setup
- should be avoided in production code
The recommended production entry point is:
DatabaseApi.create(pluginPath, poolName)This project is licensed under the GNU General Public License v3.0.