Skip to content

Persistence and Transactions

Arda uses Exposed for database access.

  1. Column names must be specified in lowercase snake_case.

  2. Use column types defined under EntityTable. When those types are insufficient, use native Exposed column types.

  3. When using Filter.Eq and similar, use <TBL>.<COLUMN>.name for the locator parameter instead of hardcoded strings, to eliminate the risk of mismatched column names.

  4. JSON columns with custom serializers: The reified json<T>(name, format) overload calls serializer<T>() at runtime, which ignores @Serializable(with=...) and @Contextual annotations on type arguments. When T contains a non-@Serializable class (e.g., java.net.URI), use the three-argument overload with an explicit KSerializer:

    // WRONG — fails at runtime: serializer<Map<String, URI>>() cannot find URI serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson)
    // CORRECT — explicit serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson, MapSerializer(String.serializer(), URISerializer))

    EntityTable also provides standardJson<T>(name), which resolves contextual serializers from JsonConfig.standardJson.serializersModule. Prefer it over json<T>(name, JsonConfig.standardJson) when contextual serializers are sufficient.

  5. Unchecked casts to ChildTable: When casting EntityTable to ChildTable (required due to invariant generics on ExposedLocatorTranslator), always guard with check() before the cast:

    override val translator by lazy {
    check(persistence.bt is ChildTable) {
    "MyUniverse requires a ChildTable, got ${persistence.bt::class}"
    }
    queryConfig.bindToTable(persistence.bt as ChildTable)
    }
  6. QueryCompiler reuse: Never construct QueryCompiler(table) inline inside service methods. Define a module-level lazy val in the persistence package, or expose an internal accessor on the universe:

    // Module-level lazy val (preferred for child universes)
    internal val myQCompiler by lazy {
    QueryCompiler(MY_TABLE, myQueryConfig.bindToTable(MY_TABLE))
    }
    // Internal accessor on parent universe
    internal val queryCompiler get() = qCompiler

Persistence shape: Universe vs. plain table

Section titled “Persistence shape: Universe vs. plain table”

Choose how to persist an entity by whether it has a lifecycle:

  • No lifecycle — standalone, immutable records that are never updated (an audit / event log). A plain Exposed table is fine; there are no “versions of one entity” to interpret, so the Universe machinery buys nothing. Deduplication, if needed, is a unique-index concern.
  • Has a lifecycle — created, mutated, soft-deleted, or otherwise evolving over time (multiple rows are versions of one logical entity). Default to a bitemporal ScopedUniverse. Do not hand-roll soft-delete, version history, or tenant scoping on a plain table — the AbstractScopedUniverse / ScopedTable / ScopedRecord / Persistence stack already provides them, tested and composable: retired-flag soft-delete, a row per change (full audit history), scoped metadata (tenant_id, author, created / updated timestamps), structural tenant isolation via ScopedUniversalCondition, and create / read / findOne(Filter, asOf) / list(Query, asOf) / update / delete operations that honor the “latest non-retired version” semantics.
  • Special cases (e.g. a Draft Store, or another non-standard store) — consult the user / operator before deviating from the Universe default; do not pick a bespoke persistence shape unilaterally.

“No tenant” is not a reason to leave the Universe. AbstractScopedUniverse is the default because nearly every entity belongs to a tenant, but an entity that is global — or that is keyed by a user across all of that user’s tenants — belongs on the non-scoped AbstractUniverse, not on a hand-rolled table. A ScopedTable requires a tenant_id on every row, so it genuinely cannot hold such an entity; that constraint rules out the scoped base, not the Universe stack. Choosing a bespoke store there gives up identity, the version chain, audit columns, and latest-non-retired semantics in exchange for solving a problem the non-scoped base already solves.

A concept that needs both — the same payload stored sometimes tenant-partitioned and sometimes not — is served by two universes, one on each base, sharing their behavior through a by delegate rather than by one universe with an optional tenant column. See Universe Design.

A business-key uniqueness constraint on a bitemporal table (e.g. “at most one active row per (tenant, key)”) is enforced in the application layer — check for a live entry before inserting — not by a DB partial-unique index: a row-version write inserts the new version before retiring the old one and would otherwise trip a non-deferrable unique constraint.

Naming pitfall: a ScopedTable column property named source (or any name that collides with an Exposed ColumnSet member) fails to compile — “‘source’ hides member of supertype ‘ColumnSet’ and needs an ‘override’ modifier.” Name the Kotlin property non-collidingly and keep the DB column name via the string argument, e.g. enumerated<…>("source").

Deferred DB actions carry the transaction requirement (DBIO)

Section titled “Deferred DB actions carry the transaction requirement (DBIO)”

A database action is a DBIO<T>suspend () -> Result<T> — that reads the ambient Exposed transaction from the coroutine context when it runs. Invoking one (dbio()) outside an active transaction is a runtime error. So the transaction requirement is real, and it should live in the type, not in a comment.

When a method produces a DB action whose execution must be controlled by the caller’s transaction, return the un-invoked DBIO<T> — do not call () internally. Persistence methods already follow this (Universe.create/update/… return DBIO<…>); a method that composes one for a caller to run must not collapse it back to a value.

This matters most for a deferred write that must be atomic with something else — e.g. a domain write that has to commit in the same transaction as an idempotency settlement, an outbox row, or a sibling aggregate. Such a write must run inside the caller’s outer transaction, so it must be handed back as a DBIO the caller runs — never opened in its own nested inTransaction { … }. (Nested inTransaction cannot yet be relied on to join the outer transaction; explicit propagation semantics are tracked in PDEV-955. Until it lands, atomicity-bound deferred writes must be DBIO, run by the caller.)

// WRONG — invokes the DBIO (`()`) inside a lambda and re-wraps it as an untyped closure,
// so the "needs a transaction" guarantee is only a convention + comment.
FreshAttemptResult.Commit(job) {
universe.create(job, metadata, asOf)().unitify() // runs the insert here; throws if no tx
}
// CORRECT — hold the un-invoked DBIO<Unit>; the caller runs it inside its transaction.
FreshAttemptResult.Commit(
result = job,
persist = universe.create(job, metadata, asOf).map { }, // DBIO<Unit>, not yet run
)

The carrier type states the contract: val persist: DBIO<Unit> (not suspend Transaction.() -> Result<Unit> invoked eagerly). The caller does inTransaction(db) { … attempt.persist() … }, and the type makes it impossible to forget that a transaction is required.

Exception — a deferred action that may legitimately do no persistence. If a carrier’s action is sometimes a DB write and sometimes a pure computation, do not force a DBIO; route the DB case through a service method that owns its own inTransaction (which, post-PDEV-955, joins the caller’s transaction when present). Choose this only when non-persistence implementations are a real, foreseen case — not to avoid typing a write as DBIO.

Trade-off to note: a DBIO built from create(payload, metadata, asOf) captures asOf at build time, not run time. When the exact recorded/effective instant must be the settlement time rather than the build time, evaluate it inside the DBIO body; otherwise the build-time value (usually the more meaningful business instant) is fine.