Skip to content

Information Model Design

A design-time playbook for defining a new information model — entities, value objects, references, persistence, and the surrounding service surface — that fits cleanly into the existing Arda backend (operations and similar Kotlin / Ktor / Exposed components).

This guide is a synthesis playbook. The reference material it builds on:

When you’re designing a new entity, read those for the what and how of each capability; use this guide to stitch them together for the entity at hand.

Use this guide when a design pass produces any of:

  • A new domain entity that will be persisted (a new “noun” in the system).
  • A new value object that’s part of an entity’s payload, addressing, or reference shape.
  • A new cross-entity relationship — within or across service boundaries.
  • A change to an existing entity that adds fields, status values, or relationships substantial enough to revisit the model.

If you’re only sketching a transient request/response DTO that won’t be persisted, this guide is overkill — reach for the Data Authority API Guide instead.

Before you start, have:

  1. A use-case or scenario that motivates the entity (e.g., from a project’s goal.md or a use-case design). Without a concrete behavioural anchor, you’ll over-specify.
  2. A clear ownership decision — which service will own this entity? An entity belongs to exactly one service in exactly one Universe.
  3. The list of cross-service references the entity needs (entities in other services it must point to). These shape the reference value-object types.
  4. The bitemporal expectation — does the entity need full bitemporal versioning (almost always yes for business entities), or is it ephemeral state that doesn’t need a history?
  5. The validation rules that must run at write time (cross-field, format, external-state checks). Some live on the entity; some live in a Validator.

Two shapes are in current use. Pick by polymorphism need:

ShapeWhen to useExample
Simple @Serializable data class : EntityPayloadThe entity has one canonical shape; no polymorphic variants; no interface-level computed propertiesOrderHeader, BusinessAffiliate, KanbanCard (single-shape branches)
@Serializable sealed interface : EntityPayload with nested data class EntityThe entity needs interface-level computed properties (item.preferredSupply), or has multiple persisted variantsItem, OrderLine (extends OrderLineInformation)

Default to the simple data class. Promote to sealed interface only when the polymorphism is real (don’t prematurely add it for “future flexibility”).

Every entity needs these, in the same source file (<Entity>.kt under business/):

@Serializable(with = UUIDSerializer::class)
override val eId: EntityId

EntityId is a UUID type alias. No domain-specific value classes (ItemEId, OrderEId). eId is stable across all bitemporal versions of the same entity; the per-version id lives in the persistence layer.

See the Identity page for the broader identity model (binding, classifier, localId).

The entity implements EntityPayload. This contract requires:

  • eId: EntityId (above).
  • validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> — domain-rule validation. Cross-field invariants, format checks, status invariants. Stays inside the entity; external-state checks (DB lookups, S3 existence, third-party API state) live in a separate Validator (below).
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
val errors = mutableListOf<AppError>()
if (name.isBlank()) errors.add(AppError.ArgumentValidation("name", "Name is required"))
// ...further checks...
return if (errors.isEmpty()) Result.success(Unit)
else Result.failure(AppError.Composite(errors))
}
@Serializable
data class XMetadata(
@Serializable(with = UUIDSerializer::class)
override val tenantId: UUID,
) : ScopedMetadata

Tenant lives in metadata, not on the entity. The Universe is keyed by metadata; the payload (EntityPayload) is the business data. Use ScopedMetadata for top-level entities, ChildMetadata (with parentEId: UUID) for entities scoped to a parent — see Parent-Child Persistence.

Companion Reference family (when applicable)

Section titled “Companion Reference family (when applicable)”

If other entities will reference this one by ID + a small set of cached display fields, define a reference family:

@Serializable
sealed interface XReference : EntityPayload {
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId
@Serializable(with = UUIDSerializer::class)
val rId: UUID? // optional record ID for pinned reference
val name: String? // cached display field
@Serializable
data class Value(
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId,
@Serializable(with = UUIDSerializer::class)
override val rId: UUID? = null,
override val name: String? = null,
) : XReference {
override fun validate(ctx: ApplicationContext, mutation: Mutation) = Result.success(Unit)
companion object {
fun fromX(x: X, rId: UUID? = null): Value = Value(x.eId, rId, x.name)
}
}
}

The pattern: a sealed interface that defines the reference contract, with at least one nested Value data class. The nested Value is what consumers actually use; the seal allows future variants without breaking call sites.

The optional rId lets a reference pin to a specific bitemporal version (per Entity References) — useful for audit (e.g., a closed PO captures the supplier reference as of the close).

Reference.Value instances are the typed face of UUID-only soft references — see § “Cross-Universe references” below.

Apply mechanically:

Composite?UseExamples
Single-typed (one underlying primitive or built-in)typealiastypealias TenantSlug = String, typealias EncryptedToken = String, typealias PostmarkServerId = Long
Multi-field@Serializable data classMoney.Value(value, currency), Quantity.Value(amount, unit), Attachment(name, contentType, content)

Avoid wrapping single primitives in data classes for “type safety” — typealiases give you readable signatures without the boxing or boilerplate.

This is the rule that’s easy to miss and hard to undo: two entities owned by different services, even within the same component, must not share persistence. Concretely:

  1. Each service owns its own Universe instance. No two services share or expose each other’s universes.
  2. Each service interface method establishes its own transaction boundary via inTransaction(db) { ... }. Cross-service interface calls cross transaction boundaries.
  3. No SQL FOREIGN KEY constraints across service boundaries. Cross-service references are stored as UUIDs only.
  4. Resolution at runtime goes through the owning service’s interface. The calling service does not query the other service’s tables or universe directly.

Concrete examples in the codebase: OrderLine.itemEId: UUID (no FK to item); KanbanCard.ITEM_REFERENCE_* columns referencing items by eId only; OrderLine migration explicitly omits the FK to the item table.

In Kotlin, the cross-Universe reference is typed as XReference.Value (the value-object pattern above). The Value.eId is the UUID stored in the column; the rId and cached display fields are stored alongside but are not foreign keys.

Within the same Universe (e.g., a self-referential resend chain, or the bitemporal previous pointer), DB foreign keys are fine and encouraged.

Every persisted entity gets one table. The canonical column structure (from V001__item.sql in operations):

CREATE TABLE IF NOT EXISTS x (
-- Bitemporal columns (Arda standard)
id uuid PRIMARY KEY, -- record (version) ID
effective_as_of TIMESTAMP NOT NULL, -- valid time
recorded_as_of TIMESTAMP NOT NULL, -- transaction time
author VARCHAR(244) NOT NULL, -- who recorded the change
eid uuid NOT NULL, -- stable entity ID
previous uuid NULL, -- FK to id of prior version; null on first
retired BOOLEAN DEFAULT FALSE NOT NULL, -- soft-delete flag
tenant_id uuid NOT NULL, -- from ScopedMetadata
-- ...business columns...
);
ALTER TABLE x ADD CONSTRAINT fk_x_previous__id
FOREIGN KEY (previous) REFERENCES x(id) ON DELETE RESTRICT ON UPDATE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_x_eid ON x (eid);
CREATE INDEX IF NOT EXISTS idx_x_effective_as_of ON x (effective_as_of);
CREATE INDEX IF NOT EXISTS idx_x_recorded_as_of ON x (recorded_as_of);

Notes:

  • Every state-changing operation produces a new row with a new id and previous pointing at the prior id. The eid is preserved across versions.
  • retired is the soft-delete marker; physical deletion is rare.
  • tenant_id (or parent_eid for child entities) comes from the metadata.
  • Add business-specific indices as needed (e.g., on a status column, or on a frequently filtered field).
  • Unique business invariants (e.g., “name unique within tenant”) generally cannot be expressed as SQL UNIQUE constraints because they would block valid bitemporal writes. Enforce them at write time inside the service’s transaction via the Validator (see below) plus a pre-flight DB read.

The full bitemporal model — query semantics, time coordinates, “as of” lookups — is in Bitemporal Persistence.

Composite value-object fields are flattened into the parent’s table with prefix-based column naming. Two prefix styles in use:

-- snake_case prefix (most common):
primary_supply_unit_cost_value DOUBLE PRECISION,
primary_supply_unit_cost_currency VARCHAR(5),
primary_supply_average_lead_time_length INT,
primary_supply_average_lead_time_time_unit VARCHAR(10),
-- mixed-case quoted (legacy / strongly-grouped):
"PRIMARY_SUPPLY_supplier" VARCHAR(255),
"PRIMARY_SUPPLY_sku" VARCHAR(255),
"ITEM_REFERENCE_entity_id" uuid,
"ITEM_REFERENCE_item_name" VARCHAR(255),

Use the snake_case prefix for new tables unless you have a strong reason to follow the mixed-case pattern (e.g., you’re following the convention of an existing reference-type field that already uses it). The persistence layer (<Entity>Persistence.kt / <Entity>Record) handles the round-trip between flattened columns and the in-memory composite value object.

Service surrounding: Universe, transaction, validator

Section titled “Service surrounding: Universe, transaction, validator”

The model lives inside a service. Three companions are typically required:

class XUniverse(
override val validator: XValidator,
) : AbstractScopedUniverse<X, XMetadata, X_TABLE, XRecord>()

Owned exclusively by the service. The service’s Impl class:

class XService.Impl(
override val universe: XUniverse,
private val db: Database,
// ...other collaborators...
) : XService { ... }

Every public service interface method wraps its DB-bearing logic in inTransaction(db) { ... }:

override suspend fun create(...): Result<EntityRecord<X, XMetadata>> = inTransaction(db) {
// pre-flight read
// universe.create(...)
// post-process
}

Cross-service interface calls cross transaction boundaries: the caller’s inTransaction { } block does not extend across the call. The called service starts its own inTransaction { }.

class XValidator(...) : ScopingValidator<X, XMetadata>() {
override suspend fun validateForCreate(...): DBIO<Unit> = ...
override suspend fun validateForUpdate(...): DBIO<Unit> = ...
override suspend fun validateForDelete(...): DBIO<Unit> = ...
}

The validator is for write-time domain rules that need external context — e.g., “the referenced item must exist”, “this slug must be unique among the latest non-retired versions”. The entity’s own validate() method is for purely intrinsic rules (format, cross-field consistency).

The two layers run in this order:

  1. Entity’s validate() — instant, no IO.
  2. Validator’s validateForCreate / validateForUpdate / validateForDelete — runs DB queries inside the same transaction.

Both must succeed before the persistence operation proceeds.

ElementConventionExample
Entity typeSingular noun, no suffixItem, OrderHeader, KanbanCard, BusinessAffiliate
Nested entity variantEntityItem.Entity (when sealed-interface shape is used)
Metadata<Entity>MetadataItemMetadata, KanbanCardMetadata
Reference family<Entity>Reference, with nested ValueItemReference.Value, ItemSupplyReference.Value
Status / lifecycle enum<Entity>StatusOrderStatus, KanbanCardStatus
Persistence Record<Entity>RecordItemRecord, OrderHeaderRecord
Persistence Table object<UPPERCASE_ENTITY>_TABLE (Kotlin) / snake_case table name (SQL)ITEM_TABLEitem
Universe<Entity>UniverseItemUniverse, OrderHeaderUniverse
Validator<Entity>ValidatorItemValidator
Service<Entity>Service interface; <Entity>Service.Impl for the production implementationItemService, ItemService.Impl
Mutation qualifier<Entity>MutationQualifier enumItemMutationQualifier { STRICT, LAX, PROPAGATE }
Persistence preparer<Entity>ServiceHelpers.kt (file) containing internal object PersistencePreparerItemServiceHelpers.kt

The conventional layout, illustrated for an entity X in module <area>:

cards.arda.operations.<area>.<entity>/
├── business/ <Entity>.kt, <Entity>Metadata, <Entity>Reference family, <Entity>Status enum
├── domain/ shared value objects (typealiases, composite data classes)
├── persistence/ <ENTITY>_TABLE, <Entity>Record, <Entity>Validator, <Entity>Universe,
│ <Entity>Persistence.kt
├── service/ <Entity>Service interface + Impl, <Entity>MutationQualifier,
│ <Entity>ServiceHelpers.kt (PersistencePreparer)
└── api/rest/ <Entity>Endpoint.kt

Multiple closely-related types per file is the convention (e.g., Item.kt contains the sealed interface, the nested Entity, the metadata, and the reference family).

PitfallWhat goes wrongHow to avoid
Putting tenantId on the entity instead of the metadataThe Universe’s scoping breaks; the entity becomes payload + scoping conflatedAlways: tenantId in the *Metadata companion
Wrapping single primitives in data classes for “type safety”Boxing, boilerplate, no real safety winUse typealias for single-typed value objects
Adding a SQL UNIQUE constraint on (tenantId, slug) directlyBlocks valid bitemporal writes (every new version trips the constraint)Enforce uniqueness in the Validator plus a pre-flight read inside the same transaction
Adding a SQL FOREIGN KEY across service universesCouples the lifecycles; deletion in the target service breaks auditUse XReference.Value in Kotlin; UUID column with no FK in SQL
Sharing a transaction across two service interface callsBlurs ownership; one service’s failure rolls back the other’s writesEach service’s interface method wraps its own inTransaction(db) { ... }
Using PRIMARY KEY on eid instead of idBitemporal writes can’t add new versions of the same entityid is the per-version primary key; eid is indexed but not unique
Letting validate() do DB queriesThe method becomes async-unfriendly and slower for trivial casesKeep validate() purely in-memory; push DB checks into the Validator
Skipping the previous self-FKBitemporal traversal breaks; auditing becomes guessworkAlways add fk_<table>_previous__id
Storing Reference.Value only as raw UUIDLoses display fields needed for audit-as-of-then-shown renderingStore eId + cached display columns (and rId if pinning); flatten the value object into the table

For a concrete walk-through, study these entity definitions and their tables in the operations repo:

EntitySource filesWhat it illustrates
Itemreference/item/business/Item.kt; reference/item/persistence/ItemRecord.kt; reference/item/database/migrations/V001__item.sqlSealed interface with nested Entity; ItemReference family; composite value-object flattening with mixed-case prefixes
OrderHeader + OrderLineprocurement/orders/business/Order.kt, OrderLine.kt; procurement/orders/database/migrations/V001__order.sqlSimple data classes with status enums; cross-Universe reference (OrderLine.itemEId) without FK; intra-Universe FK on parent_eid (header-line); previous chain
KanbanCardresources/kanban/business/KanbanCard.kt; resources/kanban/persistence/KanbanCardRecord.kt; resources/kanban/database/migrations/V001__kanban.sqlSealed interface with Entity; multiple related enums; nested event value objects with author/timestamp; ITEM_REFERENCE_* flattening
BusinessAffiliate + BusinessRolereference/businessaffiliates/business/BusinessAffiliate.kt; BusinessRole.ktParent-child with ChildMetadata (parent-scoped); minimal validation; embedded value objects

When in doubt, copy the closest analog and adapt.

ConcernReference document
Universe class hierarchy and typesUniverse Design
Bitemporal time coordinates and queriesBitemporal Persistence
Parent-child entities and orderingParent-Child Persistence
Four-layer module structureData Authority Module Pattern
Limitations of the query systemData Authority Limitations
Pinned vs floating references; URI structureEntity References
EntityId, Binding, identity typesIdentity
Calling Data Authority REST endpointsData Authority API Guide
Implementing Data Authority endpointsData Authority Endpoint Guide
Kotlin coding conventions for the implementationKotlin Coding Conventions