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

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.