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.
Class Hierarchy
Section titled “Class Hierarchy”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 Interface
Section titled “Universe Interface”Universe<EP, M> defines the contract for a collection of bitemporal entities:
EP: Entity payload type, must implementEntityPayloadM: Payload metadata type, must implementPayloadMetadata
Operations:
create(payload, metadata, asOf, author): Creates a new entityread(eId, asOf, includeRetired): Reads as-of a specific timereadRecord(rId): Reads a specific historical record by record IDfindOne(filter, asOf, includeDeleted): Finds a single entity matching a filterlist(query, asOf, includeRetired, withTotal): Lists with filtering/sorting/paginationcount(filter, asOf, includeRetired): Counts matching entitiesupdate(update): Creates a new version of an existing entitydelete(originEId, metadata, asOf, author): Logical delete (creates retired record)aggregate(query, asOf, aggregation, includeRetired, mapper): Aggregation operationshistory(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 singleinTransaction(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.
AbstractUniverse
Section titled “AbstractUniverse”Provides the skeletal implementation of Universe. Dependencies:
persistence: Persistence<EP, M, TBL, PR>: Database interactionvalidator: Validator<EP, M>: Payload and operation validationuniversalCondition: 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.
universalConditionmust beprotectedvisibility. Overriding withprivatetriggers Kotlin compilation errors.
AbstractScopedUniverse
Section titled “AbstractScopedUniverse”Extends AbstractUniverse for tenant-scoped data. Mandates:
ScopedMetadata(includestenantId)ScopedTable(includestenantIdcolumn)ScopedRecord(managestenantIdmapping)ScopingValidator(validatestenantIdagainst context)ScopedUniversalCondition(filters bytenantIdfrom context)
Validation Layering
Section titled “Validation Layering”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”Step 1: Define Entity Payload
Section titled “Step 1: Define Entity Payload”@Serializabledata 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) }}Step 2: Define Scoped Metadata
Section titled “Step 2: Define Scoped Metadata”@Serializabledata class YourScopedMetadata( @Serializable(with = UUIDSerializer::class) override val tenantId: UUID) : ScopedMetadataStep 3: Define Scoped Table
Section titled “Step 3: Define Scoped Table”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}Step 4: Define Scoped Record
Section titled “Step 4: Define Scoped Record”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 )}Step 5: Define Scoping Validator
Section titled “Step 5: Define Scoping Validator”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) } } }}Step 6: Define Universal Condition
Section titled “Step 6: Define Universal Condition”object YourUniversalCondition : ScopedUniversalCondition()Step 7: Implement the Universe
Section titled “Step 7: Implement the Universe”// Define EntityServiceConfiguration for structured locator resolutionval 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.
Step 8: Testing
Section titled “Step 8: Testing”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}Real-World Example: KanbanCardUniverse
Section titled “Real-World Example: KanbanCardUniverse”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”| Feature | AbstractUniverse | AbstractScopedUniverse |
|---|---|---|
| Scope | Generic (global or custom) | Tenant-based |
| Metadata | PayloadMetadata | ScopedMetadata (with tenantId) |
| Table | UniverseTable | ScopedTable (with tenantId column) |
| Record | BitemporalRecord | ScopedRecord (manages tenantId) |
| Validator | Validator<EP, M> | ScopingValidator<EP, M> |
| Universal Condition | UniversalCondition | ScopedUniversalCondition |
| Use Case | Entities not partitioned by tenant | Entities partitioned by tenant |
Choosing between them
Section titled “Choosing between them”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.
When one concept needs both
Section titled “When one concept needs both”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.
See Also
Section titled “See Also”- Information Model Design — design-time playbook for defining new entities, value objects, references, and persistence with the right shape and naming.
- Bitemporal Persistence — bitemporal time coordinates and table schema.
- Parent-Child Persistence — parent-child entity scoping and ordering.
- Cross-Child / Cross-Universe Queries — composing a parent universe’s tenant scope into a cross-parent child query, transaction-agnostically.
- Data Authority Module Pattern § Cross-Service Isolation — the binding rule for when two services share a component but not their universes.
Copyright: © Arda Systems 2025-2026, All rights reserved