Persistence and Transactions
Database Mappings
Section titled “Database Mappings”Arda uses Exposed for database access.
-
Column names must be specified in lowercase snake_case.
-
Use column types defined under
EntityTable. When those types are insufficient, use native Exposed column types. -
When using
Filter.Eqand similar, use<TBL>.<COLUMN>.namefor the locator parameter instead of hardcoded strings, to eliminate the risk of mismatched column names. -
JSON columns with custom serializers: The reified
json<T>(name, format)overload callsserializer<T>()at runtime, which ignores@Serializable(with=...)and@Contextualannotations on type arguments. WhenTcontains a non-@Serializableclass (e.g.,java.net.URI), use the three-argument overload with an explicitKSerializer:// WRONG — fails at runtime: serializer<Map<String, URI>>() cannot find URI serializerval sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson)// CORRECT — explicit serializerval sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson, MapSerializer(String.serializer(), URISerializer))EntityTablealso providesstandardJson<T>(name), which resolves contextual serializers fromJsonConfig.standardJson.serializersModule. Prefer it overjson<T>(name, JsonConfig.standardJson)when contextual serializers are sufficient. -
Unchecked casts to
ChildTable: When castingEntityTabletoChildTable(required due to invariant generics onExposedLocatorTranslator), always guard withcheck()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)} -
QueryCompilerreuse: Never constructQueryCompiler(table)inline inside service methods. Define a module-levellazy valin the persistence package, or expose aninternalaccessor 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 universeinternal 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 — theAbstractScopedUniverse/ScopedTable/ScopedRecord/Persistencestack 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 viaScopedUniversalCondition, andcreate/read/findOne(Filter, asOf)/list(Query, asOf)/update/deleteoperations 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
ScopedTablecolumn property namedsource(or any name that collides with an ExposedColumnSetmember) 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.
Related
Section titled “Related”- Universe Design — building a Universe once you have chosen one.
- Service-Layer Architecture — who owns the transaction boundary.
- Bitemporal Persistence — the substrate.
- Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved