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:
- Universe Design — class hierarchy:
AbstractUniverse,AbstractScopedUniverse,BitemporalRecord, validators, table types. - Bitemporal Persistence —
TimeCoordinates, valid time vs transaction time, the bitemporal table schema. - Parent-Child Persistence — entities scoped to a parent (lines on a header, roles on a company).
- Data Authority Module Pattern — the four-layer module structure.
- Entity References — pinned vs floating, URI structure.
- Identity —
EntityId,Binding. - Data Authority API Guide — calling REST endpoints.
- Data Authority Endpoint Guide — implementing endpoints.
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.
When to use this guide
Section titled “When to use this guide”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.
Inputs to the design pass
Section titled “Inputs to the design pass”Before you start, have:
- A use-case or scenario that motivates the entity (e.g., from a project’s
goal.mdor a use-case design). Without a concrete behavioural anchor, you’ll over-specify. - A clear ownership decision — which service will own this entity? An entity belongs to exactly one service in exactly one
Universe. - The list of cross-service references the entity needs (entities in other services it must point to). These shape the reference value-object types.
- 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?
- 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.
Decision: entity type
Section titled “Decision: entity type”Two shapes are in current use. Pick by polymorphism need:
| Shape | When to use | Example |
|---|---|---|
Simple @Serializable data class : EntityPayload | The entity has one canonical shape; no polymorphic variants; no interface-level computed properties | OrderHeader, BusinessAffiliate, KanbanCard (single-shape branches) |
@Serializable sealed interface : EntityPayload with nested per-state subtypes | The entity has lifecycle states that carry different fields, has interface-level computed properties, or has multiple persisted variants | Item, 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”).
Antipattern: the “variant record”
Section titled “Antipattern: the “variant record””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 recorddata 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:
- State invariants live in
validate()instead of the type system. The compiler cannot prove that anOPERATIONALrow has a non-nullverifiedAt; every reader writes?: error(...)instead. - Construction is permissive.
EmailConfiguration(status = DRAFT, postmark = ..., verifiedAt = ...)compiles even though the combination is impossible. - State transitions become field-by-field mutations. Moving from
AWAITING_VERIFICATIONtoOPERATIONALrequires nullable bookkeeping; nothing forces you to clear obsolete fields or set newly-required ones. - Adding a new state means editing one giant data class. Every reader picks up the new fields whether they need them or not.
The sealed-per-state pattern
Section titled “The sealed-per-state pattern”When the entity has lifecycle states that carry different fields, model each state as a sealed subtype:
@Serializablesealed 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: RecentHealthis 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
whenover the sealed interface flags every site that needs to handle the new state.
Persistence implications
Section titled “Persistence implications”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.
Required parts of an entity definition
Section titled “Required parts of an entity definition”Every entity needs these, in the same source file (<Entity>.kt under business/):
Identity field
Section titled “Identity field”@Serializable(with = UUIDSerializer::class)override val eId: EntityIdEntityId 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).
EntityPayload interface
Section titled “EntityPayload interface”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 separateValidator(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))}Companion Metadata type
Section titled “Companion Metadata type”@Serializabledata class XMetadata( @Serializable(with = UUIDSerializer::class) override val tenantId: UUID,) : ScopedMetadataTenant 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:
@Serializablesealed 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, withnullmeaning 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 constructor — operator 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):
| Mode | Design intent |
|---|---|
STRICT | The named target must already resolve to a live entity; reject the write otherwise. Use when the caller asserts the target exists. |
PROPAGATE | Find-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. |
LAX | Leave 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 —
PROPAGATEmust not re-create or revive a tombstoned entity, andSTRICT/LAXdo 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 = trueand pinrIdto 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).
Value objects: typealias vs data class
Section titled “Value objects: typealias vs data class”Apply mechanically:
| Composite? | Use | Examples |
|---|---|---|
| Single-typed (one underlying primitive or built-in) | typealias | typealias TenantSlug = String, typealias EncryptedToken = String, typealias PostmarkServerId = Long |
| Multi-field | @Serializable data class | Money.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.
Cross-Universe references
Section titled “Cross-Universe references”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:
- Each service owns its own
Universeinstance. No two services share or expose each other’s universes. - Each service interface method establishes its own transaction boundary via
inTransaction(db) { ... }. Cross-service interface calls cross transaction boundaries. - No SQL
FOREIGN KEYconstraints across service boundaries. Cross-service references are stored as UUIDs only. - 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.
Persistence: bitemporal table convention
Section titled “Persistence: bitemporal table convention”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
idandpreviouspointing at the priorid. Theeidis preserved across versions. retiredis the soft-delete marker; physical deletion is rare.tenant_id(orparent_eidfor 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
UNIQUEconstraints because they would block valid bitemporal writes. Enforce them at write time inside the service’s transaction via theValidator(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 flattening
Section titled “Composite value-object flattening”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:
Universe declaration
Section titled “Universe declaration”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 { ... }Transaction boundary
Section titled “Transaction boundary”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 { }.
Validator
Section titled “Validator”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:
- Entity’s
validate()— instant, no IO. - Validator’s
validateForCreate/validateForUpdate/validateForDelete— runs DB queries inside the same transaction.
Both must succeed before the persistence operation proceeds.
Naming conventions
Section titled “Naming conventions”| Element | Convention | Example |
|---|---|---|
| Entity type | Singular noun, no suffix | Item, OrderHeader, KanbanCard, BusinessAffiliate |
| Nested entity variant | Entity | Item.Entity (when sealed-interface shape is used) |
| Metadata | <Entity>Metadata | ItemMetadata, KanbanCardMetadata |
| Reference family | <Entity>Reference, with nested Value | ItemReference.Value, ItemSupplyReference.Value |
| Status / lifecycle enum | <Entity>Status | OrderStatus, KanbanCardStatus |
| Persistence Record | <Entity>Record | ItemRecord, OrderHeaderRecord |
| Persistence Table object | <UPPERCASE_ENTITY>_TABLE (Kotlin) / snake_case table name (SQL) | ITEM_TABLE ↔ item |
| Universe | <Entity>Universe | ItemUniverse, OrderHeaderUniverse |
| Validator | <Entity>Validator | ItemValidator |
| Service | <Entity>Service interface; <Entity>Service.Impl for the production implementation | ItemService, ItemService.Impl |
| Mutation qualifier | <Entity>MutationQualifier enum | ItemMutationQualifier { STRICT, LAX, PROPAGATE } |
| Persistence preparer | <Entity>ServiceHelpers.kt (file) containing internal object PersistencePreparer | ItemServiceHelpers.kt |
Package layout per entity
Section titled “Package layout per entity”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.ktMultiple 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).
Common pitfalls
Section titled “Common pitfalls”| Pitfall | What goes wrong | How to avoid |
|---|---|---|
Putting tenantId on the entity instead of the metadata | The Universe’s scoping breaks; the entity becomes payload + scoping conflated | Always: tenantId in the *Metadata companion |
| Wrapping single primitives in data classes for “type safety” | Boxing, boilerplate, no real safety win | Use typealias for single-typed value objects |
Adding a SQL UNIQUE constraint on (tenantId, slug) directly | Blocks 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 universes | Couples the lifecycles; deletion in the target service breaks audit | Use XReference.Value in Kotlin; UUID column with no FK in SQL |
| Sharing a transaction across two service interface calls | Blurs ownership; one service’s failure rolls back the other’s writes | Each service’s interface method wraps its own inTransaction(db) { ... } |
Using PRIMARY KEY on eid instead of id | Bitemporal writes can’t add new versions of the same entity | id is the per-version primary key; eid is indexed but not unique |
Letting validate() do DB queries | The method becomes async-unfriendly and slower for trivial cases | Keep validate() purely in-memory; push DB checks into the Validator |
Skipping the previous self-FK | Bitemporal traversal breaks; auditing becomes guesswork | Always add fk_<table>_previous__id |
Storing Reference.Value only as raw UUID | Loses display fields needed for audit-as-of-then-shown rendering | Store eId + cached display columns (and rId if pinning); flatten the value object into the table |
Worked references
Section titled “Worked references”For a concrete walk-through, study these entity definitions and their tables in the operations repo:
| Entity | Source files | What it illustrates |
|---|---|---|
Item | reference/item/business/Item.kt; reference/item/persistence/ItemRecord.kt; reference/item/database/migrations/V001__item.sql | Sealed interface with nested Entity; ItemReference family; composite value-object flattening with mixed-case prefixes |
OrderHeader + OrderLine | procurement/orders/business/Order.kt, OrderLine.kt; procurement/orders/database/migrations/V001__order.sql | Simple data classes with status enums; cross-Universe reference (OrderLine.itemEId) without FK; intra-Universe FK on parent_eid (header-line); previous chain |
KanbanCard | resources/kanban/business/KanbanCard.kt; resources/kanban/persistence/KanbanCardRecord.kt; resources/kanban/database/migrations/V001__kanban.sql | Sealed interface with Entity; multiple related enums; nested event value objects with author/timestamp; ITEM_REFERENCE_* flattening |
BusinessAffiliate + BusinessRole | reference/businessaffiliates/business/BusinessAffiliate.kt; BusinessRole.kt | Parent-child with ChildMetadata (parent-scoped); minimal validation; embedded value objects |
When in doubt, copy the closest analog and adapt.
Cross-references at a glance
Section titled “Cross-references at a glance”| Concern | Reference document |
|---|---|
| Universe class hierarchy and types | Universe Design |
| Bitemporal time coordinates and queries | Bitemporal Persistence |
| Parent-child entities and ordering | Parent-Child Persistence |
| Four-layer module structure | Data Authority Module Pattern |
| Limitations of the query system | Data Authority Limitations |
| Pinned vs floating references; URI structure | Entity References |
EntityId, Binding, identity types | Identity |
| Calling Data Authority REST endpoints | Data Authority API Guide |
| Implementing Data Authority endpoints | Data Authority Endpoint Guide |
| Kotlin coding conventions for the implementation | Kotlin Coding Conventions |
Copyright: © Arda Systems 2025-2026, All rights reserved