Skip to content

Parent-Child Persistence

The pattern of an entity (child) existing in the context of another entity (parent) is common in enterprise systems: Documents with their lines, Items with Units of Measure, Companies with Roles they play.

The lifecycle and mutations of Child entities are always done through the Parent Entity Service.

Children are scoped to their parent through three complementary mechanisms:

  1. A metadata column holding the parent identifier (typically the parent eId)
  2. A UniversalCondition that automatically applies the parent filter to all operations
  3. A Validator that rejects operations when the provided metadata parent does not match the Universe’s parent

Security: Every Child Read Must Be Parent-Scoped

Section titled “Security: Every Child Read Must Be Parent-Scoped”

A child eId is not globally unique across tenants, and the child row carries no tenant_id of its own — its tenant scope is inherited from the parent. Therefore every child-entity read must be parent-scoped.

Never call the record companion with an unscoped constraint for a child:

// WRONG — cross-tenant data-exposure hole: a child eId from another tenant can match.
recordCompanion.read(childEId, asOf, constraint = null, includeRetired)

Instead, compose the universe’s universalCondition (the parent_eid scope) into the lookup, mirroring the framework’s lineAsOf / withFilter pattern, so a child id belonging to a different parent (and therefore potentially a different tenant) can never match.

BusinessRoleUniverse.readIncludingRetired is the worked example: it reads a role by eId including retired records, but composes universalCondition (the parent_eid = parentEId constraint) with the role-id match before hitting findOne:

suspend fun readIncludingRetired(
roleEId: UUID,
asOf: TimeCoordinates
): DBIO<BitemporalEntity<BusinessRole, BusinessRoleMetadata>?> =
universalCondition.withFilter { constraintFilter ->
persistence.recordCompanion.findOne(
qCompiler.filter(
Filter.And(listOf(Filter.Eq(persistence.bt.eId.name, roleEId), constraintFilter))
),
asOf,
null,
true
)
}

When the parent eId is not known up front and you must query the child across many parents, do not drop the scope — resolve the parent universe’s scope into a correlated sub-query instead. See Cross-Child / Cross-Universe Queries.

Use ordered (ranked) parent-child when:

  • The child collection must be displayed in a stable order
  • Users can insert at a position and/or reorder items
  • You need ordering semantics independent of timestamps

Use unordered parent-child when:

  • The child collection has no ordering requirements
  • You only need “all children for this parent” semantics
  • You want the simplest parent-child scoping implementation
  • The child table includes: parent_eid, line_rank
  • The child metadata contains: parentEid, rank
  • The child Universe is parametrized by parentEid and implements ordered operations: add/insert, reorder, list ordered by rank
// Child table columns
abstract class ChildTable(cfg: TableConfiguration): UniverseTable(cfg) {
val parentEid = uuid("parent_eid")
val rank = long("line_rank")
}
// Child metadata
data class OrderLineMetadata(
override val parentEid: UUID,
override val rank: Long,
): LineMetadata

Universe constraint: Filter all operations by parent_eid == parentEid, list operations order by rank ascending, maintain rank gaps to support insertion without rebalancing.

Example: ../operations/src/main/kotlin/cards/arda/operations/procurement/orders/persistence/OrderLineUniverse.kt

  • The child table includes: a parent reference column (e.g., item_eid)
  • The child metadata contains: the parent eId (e.g., itemEid)
  • The child Universe is parametrized by the parent eId and applies a Universe-level constraint
// Metadata includes parent reference
data class ItemSupplyMetadata(
override val tenantId: UUID,
val itemEid: UUID,
) : ScopedMetadata {
companion object {
const val COLUMN_ITEM_EID = "item_eid"
}
}
// Table maps metadata column + payload
object ITEM_SUPPLY_TABLE : ScopedTable(ItemSupplyTableConfiguration) {
val itemEid = uuid(ItemSupplyMetadata.COLUMN_ITEM_EID)
val supplier = varchar("supplier", 255)
// ...
}
// Universe-level constraint
private class ItemSupplyUniversalCondition(private val itemEid: UUID) : ScopedUniversalCondition() {
override fun filter(ctx: ApplicationContext): Result<Filter> =
super.filter(ctx).map { tenantFilter ->
Filter.And(listOf(
tenantFilter,
Filter.Eq(ItemSupplyMetadata.COLUMN_ITEM_EID, itemEid)
))
}
}
// Validator rejects mismatched parent
private fun checkParent(metadata: ItemSupplyMetadata): Result<Unit> =
when (metadata.itemEid) {
u.itemEid -> Result.success(Unit)
else -> Result.failure(AppError.ArgumentValidation("item_eid", "Parent mismatch"))
}

Example: ../operations/src/main/kotlin/cards/arda/operations/reference/item/persistence/ItemSupplyUniverse.kt

  1. Decide ordered vs unordered up front:

    • If you need “insert at index” or “reorder”: use the ordered pattern with rank
    • Otherwise: use the unordered pattern (simpler and cheaper)
  2. Always scope children in two places:

    • UniversalCondition (enforced on every query/mutation)
    • Validator (enforced on metadata consistency)
  3. Add the right indexes:

    • Always index eid, tenant_id, effective_as_of, recorded_as_of
    • Always index the parent column (e.g., parent_eid / item_eid)
    • If ordered: consider (parent_eid, line_rank) for list performance
  4. Keep parent references in metadata, not payload:

    • Makes scoping a structural concern rather than business data
  5. Consider uniqueness constraints:

    • For unordered children where you need “one supply per supplier per item”, add a unique index like (tenant_id, item_eid, supplier) (with optional partial index for retired=false)

Structured Locator Translators for Child Universes

Section titled “Structured Locator Translators for Child Universes”

Child universes should define an EntityServiceConfiguration and bind it to the ChildTable to enable JSON field-name locators. Due to invariant generics on ExposedLocatorTranslator, the persistence.bt reference must be cast to ChildTable with a runtime guard:

val childQueryConfig = EntityServiceConfiguration.create(ChildPayload::class).also { it.freeze() }
// Module-level lazy QueryCompiler (reuse in validators and services)
internal val childQCompiler by lazy {
QueryCompiler(CHILD_TABLE, childQueryConfig.bindToTable(CHILD_TABLE))
}
class ChildUniverse(...) : SimpleChildUniverse.Impl<...>(...) {
override val translator by lazy {
check(persistence.bt is ChildTable) {
"ChildUniverse requires a ChildTable, got ${persistence.bt::class}"
}
childQueryConfig.bindToTable(persistence.bt as ChildTable)
}
}

See Query DSL: Child Universe Translator Pattern.

When deleting child entities within a parent’s delete operation, using ChildUniverse.delete() can fail due to parent lookup timing mismatches within the same transaction. Use low-level recordCompanion.delete() to bypass parent lookup.

Note: ChildUniverse.delete returns Pair<BitemporalEntity<PP, PM>, BitemporalEntity<EP, EM>> — parent and child entities, not just the child.

  • Information Model Design — design-time playbook for defining new entities (including parent-child) with the right shape, naming, and reference patterns.
  • Universe DesignChildMetadata and the parent-scoped Universe types.
  • Bitemporal Persistence — the bitemporal column conventions that apply equally to child entities.