Skip to content

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.

uml diagram

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.

uml diagram

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:

  1. A metadata column holding the parent identifier (typically the parent eId).
  2. A UniversalCondition (Universe-level constraint) that automatically applies the parent filter.
  3. A Validator that 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:
    • parentEid
    • rank
  • The child Universe is parametrized by parentEid and 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:

  1. Child table columns
abstract class ChildTable(cfg: TableConfiguration): UniverseTable(cfg) {
  val parentEid = uuid("parent_eid")
  val rank = long("line_rank")
}
  1. Child metadata
data class OrderLineMetadata(
  override val parentEid: UUID,
  override val rank: Long,
): LineMetadata
  1. 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)
  • 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 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:

  1. Metadata includes parent reference
data class ItemSupplyMetadata(
  override val tenantId: UUID,
  val itemEid: UUID,
) : ScopedMetadata {
  companion object {
    const val COLUMN_ITEM_EID = "item_eid"
  }
}
  1. Table maps metadata column + payload
object ITEM_SUPPLY_TABLE : ScopedTable(ItemSupplyTableConfiguration) {
  val itemEid = uuid(ItemSupplyMetadata.COLUMN_ITEM_EID)
  val supplier = varchar("supplier", 255)
  // ...
}
  1. 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)
      ))
    }
}
  1. 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

  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 to parent 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 (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
  4. Keep parent references in metadata, not payload:
    • This makes scoping a structural concern rather than business data.
  5. 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 optionally retired=false if supported via partial indexes)

Comments