Parent-Child Persistence Patterns (Ordered and Unordered)
Goal¶
The pattern of an entity (child) existing in the context of another entity (parent) is very common in enterprise systems, like Documents with their lines, Items with Units of Measure, Companies with Roles they play, etc.
In UML, these are commonly represented as a composition relationship as the lifecycle of the Child is tied to the Parent.
Alternatively, following the RDBMS practice of foreign keys, it is sometimes represented as an association relationship with a foreign key from Child to Parent with the cardinality indicating that Child instances cannot exist without a Parent.
The collection of children can be either ordered or not, and the parent may be aware of the number of children or not.
The lifecycle and mutations of the Child entities are always done through the Parent Entity Service.
These utilities and patterns support the implementation of Parent-Child relationships in the context of Arda’s bitemporal persistence framework.
Design Outline¶
The key idea is that children are scoped to their parent through:
- A metadata column holding the parent identifier (typically the parent
eId). - A
UniversalCondition(Universe-level constraint) that automatically applies the parent filter. - A
Validatorthat rejects operations when the provided metadata parent does not match the Universe’s parent.
Ordered (Ranked) Parent-Child¶
Use this variant 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.
Pattern summary¶
- The child table includes:
parent_eid(required)line_rank(required)
- The child metadata contains:
parentEidrank
- The child Universe is parametrized by
parentEidand implements operations that respect ordering:- add/insert
- reorder
- list ordered by rank
Example (Orders)¶
- Business:
../operations/src/main/kotlin/cards/arda/operations/procurement/orders/business/OrderLine.kt - Persistence table/record:
../operations/src/main/kotlin/cards/arda/operations/procurement/orders/persistence/OrderLinePersistence.kt - Universe:
../operations/src/main/kotlin/cards/arda/operations/procurement/orders/persistence/OrderLineUniverse.kt
Key implementation details:
- 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 + ordering
- Filter all operations by
parent_eid == parentEid - List operations order by rank ascending
- Maintain rank gaps to support insertion without rebalancing in the common case
Unordered Parent-Child¶
Use this variant 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.
Pattern summary¶
- The child table includes:
- a parent reference column (e.g.
item_eid)
- a parent reference column (e.g.
- The child metadata contains:
- the parent
eId(e.g.itemEid)
- the parent
- The child Universe is parametrized by the parent
eIdand applies a Universe-level constraint that enforces parent scoping.
Example (Items / ItemSupply)¶
- Business + metadata:
../operations/src/main/kotlin/cards/arda/operations/reference/item/business/ItemSupply.kt - Persistence table/record:
../operations/src/main/kotlin/cards/arda/operations/reference/item/persistence/ItemSupplyPersistence.kt - Universe:
../operations/src/main/kotlin/cards/arda/operations/reference/item/persistence/ItemSupplyUniverse.kt - Migration DDL:
../operations/src/main/resources/reference/item/database/migrations/V009__item_supply.sql
Key implementation details:
- 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
Implement UniversalCondition.filter to AND the tenant constraint with the parent 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 ensures parent mismatch fails
private fun checkParent(metadata: ItemSupplyMetadata): Result<Unit> =
when (metadata.itemEid) {
u.itemEid -> Result.success(Unit)
else -> Result.failure(AppError.ArgumentValidation("item_eid", "..."))
}
Guidelines for New Parent-Child Entities¶
- 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).
- If you need “insert at index” or “reorder”, use the ordered pattern with
- Always scope children to parent in two places:
- UniversalCondition (enforced on every query/mutation)
- Validator (enforced on metadata consistency)
- Add the right indexes:
- Always index
eid,tenant_id,effective_as_of,recorded_as_of(consistent with other bitemporal tables) - Always index the parent column (e.g.
parent_eid/item_eid) - If ordered: consider
(parent_eid, line_rank)for list performance
- Always index
- Keep parent references in metadata, not payload:
- This makes scoping a structural concern rather than business data.
- Consider uniqueness constraints (future work):
- For unordered children, if you need “one supply per supplier per item”, add a unique index like:
(tenant_id, item_eid, supplier)(and optionallyretired=falseif supported via partial indexes)
- For unordered children, if you need “one supply per supplier per item”, add a unique index like: