Skip to content

Universe Design

The Universe framework provides a structured way to manage bitemporal entities, with support for global and scoped (e.g., tenant-based) data segregation.

Universe<EP, M> (interface)
└── AbstractUniverse<EP, M, TBL, PR> (abstract)
└── AbstractScopedUniverse<EP, M, TBL, PR> (abstract)
Validator<EP, M> (interface)
└── ScopingValidator<EP, M>
UniversalCondition (interface)
└── ScopedUniversalCondition
UniverseTable (abstract)
└── ScopedTable
BitemporalRecord<EP, M, TBL, SELF> (abstract)
└── ScopedRecord<EP, M, TBL, SELF>
Persistence<EP, M, TBL, PR> (abstract)

Enforced by UniverseReturnsDBIO (tree).

Universe<EP, M> defines the contract for a collection of bitemporal entities:

  • EP: Entity payload type, must implement EntityPayload
  • M: Payload metadata type, must implement PayloadMetadata

Operations:

  • create(payload, metadata, asOf, author): Creates a new entity
  • read(eId, asOf, includeRetired): Reads as-of a specific time
  • readRecord(rId): Reads a specific historical record by record ID
  • findOne(filter, asOf, includeDeleted): Finds a single entity matching a filter
  • list(query, asOf, includeRetired, withTotal): Lists with filtering/sorting/pagination
  • count(filter, asOf, includeRetired): Counts matching entities
  • update(update): Creates a new version of an existing entity
  • delete(originEId, metadata, asOf, author): Logical delete (creates retired record)
  • aggregate(query, asOf, aggregation, includeRetired, mapper): Aggregation operations
  • history(eId, from, to, page): Historical records within a time range

All operations return DBIO<T> and are suspend functions.

Transaction-agnostic by contract. A universe operation returns a composable DBIO<T> and must not open its own transaction. The calling service composes universe actions and invokes them within a single inTransaction(db) { ... } — the service owns the transaction boundary. This keeps universe operations reusable inside larger transaction scripts (including cross-universe coordination). See Cross-Child / Cross-Universe Queries for an applied case, and Functional Programming § DBIO for the underlying monad.

Provides the skeletal implementation of Universe. Dependencies:

  • persistence: Persistence<EP, M, TBL, PR>: Database interaction
  • validator: Validator<EP, M>: Payload and operation validation
  • universalCondition: UniversalCondition: Global filters (scoping)

The universalCondition is applied to all read, list, count, update, and delete operations. The validator is invoked before create, update, and delete operations.

universalCondition must be protected visibility. Overriding with private triggers Kotlin compilation errors.

Extends AbstractUniverse for tenant-scoped data. Mandates:

  • ScopedMetadata (includes tenantId)
  • ScopedTable (includes tenantId column)
  • ScopedRecord (manages tenantId mapping)
  • ScopingValidator (validates tenantId against context)
  • ScopedUniversalCondition (filters by tenantId from context)

A UniversalCondition answers two questions, and keeping them apart is what makes it correct for every shape of read:

interface UniversalCondition {
fun filter(ctx: ApplicationContext): Result<Filter> // which entities may this caller see?
fun versionSelection(ctx: ApplicationContext): Result<Filter> // which rows may be an entity's current version?
}

Most conditions answer only the first. ScopedUniversalCondition restricts to the caller’s tenant, which is a fact about the row that is returned, and leaves version selection at “every row is a version” — the default, and correct for every subject whose rows are all states.

The second exists for subjects whose tables hold rows that are not versions of the entity: an occurrence log records attempts, and a refused attempt is a fact about a request rather than a new state of the thing.

Expressing that as a row filter is the trap, and it fails in a way that is easy to miss:

  • Reading one entity by id takes the newest row satisfying the condition — so a row filter works.
  • Listing, counting, or finding by filter takes the newest row of any kind and then tests it — so a row filter does not work. A non-version row is chosen, fails the test, and the entity disappears from the result entirely until a later ordinary row lands.

That asymmetry is why the two questions cannot share a predicate, and why the read that is easiest to test is the one that cannot reveal the bug.

Both questions are about reading. Neither may decide which row a new version supersedes.

That sounds obvious and was not. update resolved one candidate through the caller’s condition and used it for everything, including the previous link on the row it wrote. For an occurrence log — whose condition hides refusals — a refusal linked to the last committed version, and so did the commitment after it, and so did every further refusal. One version acquired several successors, and the log stopped being a chain.

The rule is that lineage is built over the rows that are there, retired ones included, while the condition governs what a reader is shown, the guard’s input, and the state idempotency is judged against. For a condition that hides nothing the two resolve to the same row, which is why the defect only ever appeared for occurrence logs.

Conditions compose by wrapping. OccurrenceUniversalCondition(ScopedUniversalCondition()) — see The Occurrence Pattern — is tenant-scoped and occurrence-aware, in either order, with any future condition — UniversalCondition is closed under wrapping and a base class is not. A subject that needs two traits would have only one inheritance slot.

Where a condition needs a value the subject also uses when writing — the code an occurrence log writes for a committed attempt, for instance — derive the condition from the subject rather than declaring the value twice.

Two declarations asked to “agree” are a defect waiting for the first implementor who overrides one and not the other. The failure mode is silent: every write carries a code that version selection rejects, and the entity vanishes from every list-shaped read — the very defect the separation above exists to prevent, reintroduced one layer up.

Two tiers of validation:

1. Payload-Level Validation (EntityPayload.validate)

Section titled “1. Payload-Level Validation (EntityPayload.validate)”

On the entity payload itself. Validates:

  • Format checks (email, URL)
  • Range checks
  • Required field checks
  • Internal consistency between payload fields
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
if (name.isBlank())
return Result.failure(AppError.ArgumentValidation("name", "Name cannot be blank"))
return Result.success(Unit)
}

2. Universe-Level Validation (Validator<EP, M>)

Section titled “2. Universe-Level Validation (Validator<EP, M>)”

Handles checks requiring broader context:

  • Cross-entity validation (uniqueness, referential integrity)
  • State transition validation
  • Authorization-related checks
  • Tenant/parent scoping verification

Methods:

  • validateForCreate(ctx, payload, metadata, asOf, author): DBIO<Unit>
  • validateForUpdate(ctx, payload, metadata, asOf, author, idempotency, previous): DBIO<Unit>
  • validateForDelete(ctx, candidate, metadata, asOf, author): DBIO<Unit>

Step-by-Step: Building a New Scoped Universe

Section titled “Step-by-Step: Building a New Scoped Universe”
@Serializable
data class YourEntityPayload(
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId,
val name: String,
) : EntityPayload {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
if (name.isBlank())
return Result.failure(AppError.ArgumentValidation("name", "Name cannot be blank"))
return Result.success(Unit)
}
}
@Serializable
data class YourScopedMetadata(
@Serializable(with = UUIDSerializer::class)
override val tenantId: UUID
) : ScopedMetadata
object YourScopedTable : ScopedTable(TableConfiguration("YOUR_ENTITY_TABLE_NAME")) {
val name = varchar("item_name", 255)
// Bitemporal and scoped columns (eId, rId, tenantId, effectiveAsOf, etc.) are inherited
}
class YourScopedRecord(rId: EntityID<UUID>) :
ScopedRecord<YourEntityPayload, YourScopedMetadata, YourScopedTable, YourScopedRecord>(rId, YourScopedTable) {
var name by YourScopedTable.name
override fun fillPayload(p: YourEntityPayload) {
payload = p
this.name = p.name
}
override fun fillMetadata(m: YourScopedMetadata) {
metadata = m
this.tenantId = m.tenantId
}
companion object : Persistence<YourEntityPayload, YourScopedMetadata, YourScopedTable, YourScopedRecord>(
YourScopedTable,
YourScopedRecord::class.java,
::YourScopedRecord
)
}
fun validatorFor(universe: YourUniverse): ScopingValidator<YourEntityPayload, YourScopedMetadata> {
return object : ScopingValidator<YourEntityPayload, YourScopedMetadata>() {
override suspend fun validateForCreate(
ctx: ApplicationContext,
payload: YourEntityPayload,
metadata: YourScopedMetadata,
asOf: TimeCoordinates,
author: String
): DBIO<Unit> = suspend {
super.validateForCreate(ctx, payload, metadata, asOf, author)().flatMap {
if (payload.name.isBlank())
Result.failure(AppError.ArgumentValidation("name", "Entity name cannot be blank"))
else
Result.success(Unit)
}
}
}
}
object YourUniversalCondition : ScopedUniversalCondition()
// Define EntityServiceConfiguration for structured locator resolution
val yourQueryConfig = EntityServiceConfiguration.create(YourEntityPayload::class) {
opaque("settings") // exclude non-filterable fields
}.also { it.freeze() }
class YourUniverse : AbstractScopedUniverse<
YourEntityPayload, YourScopedMetadata, YourScopedTable, YourScopedRecord
>(), LogEnabled by LogProvider(YourUniverse::class) {
override val persistence = YourScopedRecord.Companion
override val validator = validatorFor(this)
override val universalCondition = YourUniversalCondition
override val translator by lazy { yourQueryConfig.bindToTable(persistence.bt) }
// Custom business methods
suspend fun customMethod(filter: Filter, asOf: TimeCoordinates): DBIO<List<YourBusinessObject>> {
return aggregate(Query(filter), asOf, Aggregation(...), includeRetired = false) { row ->
// row mapping
}
}
}

The translator enables API clients to use JSON field names (camelCase) as query locators, while remaining backward-compatible with raw column names. See Query DSL: EntityServiceConfiguration for details.

Use AbstractUniverseTestTemplate for comprehensive CRUD/query/history coverage:

class YourUniverseTest : AbstractUniverseTestTemplate<
YourEntityPayload, YourScopedMetadata, YourScopedTable, YourScopedRecord, YourUniverse
>(
testName = "YourUniverse",
appConfigPath = "test-application.conf",
newUniverse = { YourUniverse() },
serviceScope = { ServiceScope.Tenant(testTenantId) },
newPayload = { eId, testCtx, order -> YourEntityPayload(eId, "test-$testCtx-$order") },
newMetadata = { _, tenantIdStr, _ -> YourScopedMetadata(UUID.fromString(tenantIdStr)) }
) {
// Custom tests here
}

The KanbanCardUniverse extends AbstractScopedUniverse with a custom aggregation method for status summaries:

class KanbanCardUniverse :
AbstractScopedUniverse<KanbanCard, KanbanCardMetadata, KANBAN_CARD_TABLE, KanbanCardRecord>(),
LogEnabled by LogProvider(KanbanCardUniverse::class) {
override val persistence = KanbanCardRecord.Companion
override val universalCondition = KanbanCardUniversalCondition
override val validator = validatorFor(this)
suspend fun summaryByStatus(
condition: Filter, asOf: TimeCoordinates, includeRetired: Boolean,
vararg forStatus: KanbanCardStatus,
): DBIO<List<KanbanCardSummary>> = aggregate(
Query(
Filter.And(listOf(condition, Filter.In(persistence.bt.status.name, forStatus.toList()))),
Sort(listOf(SortEntry(persistence.bt.status.name, SortDirection.ASC))),
paginate = Pagination(0, 500)
),
asOf,
Aggregation(
listOf(GroupBy.Classifier(persistence.bt.status.name)),
listOf(GroupBy.Aggregator(persistence.bt.cardQuantity.amount.name, AggregationType.SUM, "quantityAmount"))
),
includeRetired = includeRetired,
rowMapper(asOf)
).map { /* post-processing */ }
}

AbstractUniverse vs AbstractScopedUniverse

Section titled “AbstractUniverse vs AbstractScopedUniverse”
FeatureAbstractUniverseAbstractScopedUniverse
ScopeGeneric (global or custom)Tenant-based
MetadataPayloadMetadataScopedMetadata (with tenantId)
TableUniverseTableScopedTable (with tenantId column)
RecordBitemporalRecordScopedRecord (manages tenantId)
ValidatorValidator<EP, M>ScopingValidator<EP, M>
Universal ConditionUniversalConditionScopedUniversalCondition
Use CaseEntities not partitioned by tenantEntities partitioned by tenant

AbstractScopedUniverse is the default, because almost every Arda entity belongs to a tenant, and the scoped base gives structural tenant isolation through ScopedUniversalCondition rather than leaving it to query discipline.

AbstractUniverse is the right choice — not a fallback — when the entity genuinely has no tenant. A ScopedTable requires a tenant_id on every row, so an entity that is global, or that is keyed by a user across all of that user’s tenants, cannot live in one. Reaching for a hand-rolled Exposed table in that situation is the wrong move: you lose identity, the version chain, audit columns, and the “latest non-retired version” semantics, and you take on re-implementing them. Use the non-scoped base and supply your own UniversalCondition.

Note the isolation argument changes shape rather than disappearing. A non-scoped universe has no structural tenant filter, so isolation has to come from the addressing discipline of the layer above it — a resolver that only ever addresses a key derived from the verified ApplicationContext gives the same guarantee, provided that is genuinely the only way in. State that invariant explicitly in the service’s KDoc and cover it with a test; it is doing the job ScopedUniversalCondition would otherwise do for free.

A concept whose instances are sometimes tenant-partitioned and sometimes not — settings values are the canonical case, where a tenant-scoped value and a user-global value are the same shape stored against different scopes — is served by two universes, one on each base, sharing their common behavior through a delegate:

// The shared behavior, written once and unaware of scoping.
class SettingsDocumentOps<TBL : UniverseTable, PR>(private val universe: Universe<SettingsPayload, *>) {
fun loadDocument(key: ScopeKey, asOf: TimeCoordinates): DBIO<Map<String, JsonElement>> = …
fun saveDocument(key: ScopeKey, document: Map<String, JsonElement>): DBIO<Unit> = …
}
class TenantSettingsUniverse(…) :
AbstractScopedUniverse<SettingsPayload, ScopedMetadata, …>(…),
SettingsDocumentApi by SettingsDocumentOps(…)
class UserSettingsUniverse(…) :
AbstractUniverse<SettingsPayload, PayloadMetadata, …>(…),
SettingsDocumentApi by SettingsDocumentOps(…)

Kotlin’s by delegation keeps the shared logic in one place while each universe inherits the base that matches its scoping. The extension over each base is usually small — the interesting behavior is the document read/write, which does not care about scoping at all.

Prefer this over a single non-scoped universe that carries an optional tenant column: an optional tenant_id is a tenant filter you have to remember to apply, which is the guarantee AbstractScopedUniverse exists to remove.