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.

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 CaseFoundation for any universeEntities partitioned by tenant