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 data class Entity | The entity needs interface-level computed properties (item.preferredSupply), or has multiple persisted variants | Item, 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”).
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.
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