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)

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)

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.