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 per-state subtypesThe entity has lifecycle states that carry different fields, has interface-level computed properties, or has multiple persisted variantsItem, OrderLine (extends OrderLineInformation), EmailConfiguration (sealed across Draft / Provisioning / AwaitingVerification / Operational / VerificationFailed)

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

A common antipattern is to model a lifecycle-bearing entity as a single flat data class with a status: Enum field plus nullable per-state fields:

// WRONG — variant record
data class EmailConfiguration(
override val eId: EntityId,
val status: EmailConfigurationStatus, // DRAFT, PROVISIONING, AWAITING_VERIFICATION, ...
val identity: EmailConfigurationIdentity,
val postmark: PostmarkResources?, // present after PROVISIONING, null before
val dns: SignatureDnsRecords?, // ditto
val verification: VerificationState?, // present from AWAITING_VERIFICATION onward
val verifiedAt: Instant?, // null before verification completes
val failure: PermanentFailure?, // present in PROVISIONING_FAILED / VERIFICATION_FAILED
val operationalFailure: OperationalFailureDetails?,
val recentHealth: RecentHealth?,
val restoreAudit: RestoreAudit?,
) : EntityPayload {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
// Enormous when/if matrix: every nullable field cross-checked against status.
// Easy to miss a combination. New states require touching this method.
}
}

This shape has four problems:

  1. State invariants live in validate() instead of the type system. The compiler cannot prove that an OPERATIONAL row has a non-null verifiedAt; every reader writes ?: error(...) instead.
  2. Construction is permissive. EmailConfiguration(status = DRAFT, postmark = ..., verifiedAt = ...) compiles even though the combination is impossible.
  3. State transitions become field-by-field mutations. Moving from AWAITING_VERIFICATION to OPERATIONAL requires nullable bookkeeping; nothing forces you to clear obsolete fields or set newly-required ones.
  4. Adding a new state means editing one giant data class. Every reader picks up the new fields whether they need them or not.

When the entity has lifecycle states that carry different fields, model each state as a sealed subtype:

@Serializable
sealed interface EmailConfiguration : EntityPayload {
val identity: EmailConfigurationIdentity
val administrative: AdministrativeRegion
@Serializable data class Draft(
override val eId: EntityId,
override val identity: EmailConfigurationIdentity,
override val administrative: AdministrativeRegion,
) : EmailConfiguration {
override fun validate(ctx: ApplicationContext, mutation: Mutation) = Result.success(Unit)
}
@Serializable data class AwaitingVerification(
override val eId: EntityId,
override val identity: EmailConfigurationIdentity,
override val administrative: AdministrativeRegion,
val postmark: PostmarkResources,
val dns: SignatureDnsRecords,
val verification: VerificationState,
) : EmailConfiguration {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> = when {
verification.verifiedAt != null -> Result.failure(
AppError.IncompatibleState("AwaitingVerification.verification.verifiedAt must be null")
)
else -> Result.success(Unit)
}
}
@Serializable data class Operational(
override val eId: EntityId,
override val identity: EmailConfigurationIdentity,
override val administrative: AdministrativeRegion,
val postmark: PostmarkResources,
val dns: SignatureDnsRecords,
val verification: VerificationState,
val recentHealth: RecentHealth,
val restoreAudit: RestoreAudit? = null,
val failure: OperationalFailureDetails? = null,
) : EmailConfiguration {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> = when {
verification.verifiedAt == null -> Result.failure(
AppError.IncompatibleState("Operational.verification.verifiedAt must be non-null")
)
else -> Result.success(Unit)
}
}
// ...VerificationFailed, ProvisioningFailed, Provisioning...
}

Properties of this shape:

  • State-specific fields are non-nullable on their own subtype. Operational.recentHealth: RecentHealth is always present; the compiler enforces it.
  • Each subtype overrides validate() with the invariants for its own state, not the cross-product of all states. The body of each override fits on a screen.
  • Transitions are constructor calls on the target subtype, surfacing all newly-required fields and dropping fields that no longer apply.
  • Adding a new state adds one subtype, leaving existing subtypes untouched. Exhaustive when over the sealed interface flags every site that needs to handle the new state.

The DB schema stays a single flat table (one row per bitemporal version) with a kind discriminator column and one set of columns per per-state field block. The Component framework’s all-or-nothing nullability (see Persistent Components) handles the optional component blocks; fillPayload(row) switches on kind and constructs the right subtype.

The sealed-per-state pattern at the domain layer and the flat-table-with-kind pattern at the persistence layer are complementary, not contradictory: domain modelling enforces invariants in types; persistence flattens for query performance.

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.

Descriptor references and create-on-the-fly resolution

Section titled “Descriptor references and create-on-the-fly resolution”

The reference family above is the strict form: identity (eId, plus rId when pinned) is mandatory and the cached display fields are a convenience. Sometimes the design needs the inverse — a descriptor, where the cached name is the authoritative value and identity is allowed to be absent. Decide between the two at design time:

  • Strict reference — choose when the target entity is guaranteed to exist whenever the reference does, so the name can always be re-fetched and identity is never null.
  • Descriptor reference — choose when the human-meaningful name must survive even while the target is unlinked or legacy data has no id yet. The name becomes required and authoritative; eId (and the parent eId, when the target is a child entity) become nullable, with null meaning unlinked.

SupplierReference.Value is the worked example: name is required (the single home for the vendor name), while eId (the VENDOR BusinessRole) and affiliateEId (the parent BusinessAffiliate, carried because the role is a child entity that resolves through BusinessRoleUniverse(affiliateEId)) are nullable. Enforce the descriptor’s invariants through a smart constructoroperator fun invoke(): Result<Value> on the companion — so every construction site upholds them: non-blank name, and if eId is present then affiliateEId must be present.

Treat the nullable identity as transitional. It exists to carry legacy or not-yet-linked rows through a migration window or a lazy re-link (each row re-links when next edited). Plan the follow-up that tightens identity back to non-null once the legacy rows are re-linked, returning the type to the canonical strict shape (the connect-supply-to-supplier instance is tracked as PDEV-860). For the modeling shape and field-by-field rationale, see Entity References § Descriptor variant.

When a descriptor can arrive with a name but no id, the owning service needs a resolution mode that says what to do when the named target does not resolve. Model these as a mutation qualifier on the create/update path (<Entity>MutationQualifier, per Naming conventions below):

ModeDesign intent
STRICTThe named target must already resolve to a live entity; reject the write otherwise. Use when the caller asserts the target exists.
PROPAGATEFind-or-create: the owning service finds the target (and its parent, for a child entity) or creates it, then links the descriptor — instead of rejecting. Use as the default for caller-driven creation flows where the supplier/target may be new.
LAXLeave the descriptor unlinked (name-only); no lookup, no creation. Use for bulk imports or drafts where linking is deferred.

Two rules hold regardless of mode, and the design must call them out explicitly:

  • Never resurrect a removed target. Resolving against a retired/removed target is rejected — PROPAGATE must not re-create or revive a tombstoned entity, and STRICT/LAX do not see it (it is filtered from active bitemporal queries). A descriptor may not start out linked to a dead target.
  • On target removal, mark the descriptor stale — don’t delete it. When the linked target is removed later, set retired = true and pin rId to the target’s tombstone record so the holder still renders its last-known state. This mirrors the Kanban-reacts-to-Item-deletion pattern (PDEV-808); the reaction is wired through the owning service’s notification handler, not a SQL cascade (cross-Universe references have no FK).

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