Persistent Components
A persistent component is a composite value-object field of an entity that is flattened into a contiguous set of columns on the entity’s table. The Component framework in common-module (cards.arda.common.lib.component.*) provides the column-prefix, fill, and read plumbing so a data class field can round-trip cleanly through Exposed without writing one-off setComponentValue / getComponentValue boilerplate.
This page is the how-to. For the framework’s class hierarchy and types, see Universe Design. For the bitemporal column structure components sit inside, see Bitemporal Persistence.
When to reach for a component
Section titled “When to reach for a component”| Situation | Use |
|---|---|
| One scalar field on the entity (a slug, a status, an amount) | Plain Exposed column |
A composite with a stable shape that always appears together (Money(value, currency), Quantity(amount, unit)) | Component.Sub |
A composite whose presence is optional but whose internal fields are all-present or all-absent (a PostmarkResources block on AwaitingVerification / Operational but not on Draft) | Component.Sub.requiring(...) |
| A composite whose presence is mandatory on every row | Component.Sub without requiring |
| Polymorphic shape (different subtypes carry different fields) | Multiple sibling components + a discriminator column; choose one set to fill per row |
The framework is not appropriate for one-off pairs of related columns that don’t share lifecycle; those stay as adjacent Exposed columns.
Anatomy of a component
Section titled “Anatomy of a component”A component is declared once on the table and re-used wherever the composite appears in the payload.
class POSTMARK_TABLE(name: String = "postmark") : Component.Sub<PostmarkResources>(name) { val serverId = long("server_id") val signatureId = long("signature_id") val webhookId = long("webhook_id") val token = varchar("server_token_encrypted", 4096)
override fun build(): PostmarkResources? = requiring(serverId, signatureId, webhookId, token) { PostmarkResources( serverId = PostmarkServerId(it[serverId]).getOrThrow(), signatureId = PostmarkSignatureId(it[signatureId]).getOrThrow(), webhookId = PostmarkWebhookId(it[webhookId]).getOrThrow(), serverTokenEncrypted = TokenCipherEnvelope(it[token]).getOrThrow(), ) }
override fun fill(value: PostmarkResources) { serverId .set(value.serverId.value) signatureId .set(value.signatureId.value) webhookId .set(value.webhookId.value) token .set(value.serverTokenEncrypted.value) }}The framework owns three behaviours:
- Column-name prefixing. Every column declared inside the component is physically named
<component-name>_<column-name>in the database.serverIdabove lands aspostmark_server_id. The prefix is set by the constructor argument (name = "postmark"); see Column-prefix convention below. - All-or-nothing nullability.
requiring(col1, col2, ...) { ... }returns the built value if all listed columns are non-null andnullif all listed columns are null. A partial fill (some columns set, others null) is a corrupt row andrequiringreturnsnull; the layer above turns that into aResult.failure(AppError.IncompatibleState). - Bidirectional fill.
build()reads columns into the in-memory composite;fill(value)writes the composite back to columns. The two methods together form the round-trip contract.
Column-prefix convention
Section titled “Column-prefix convention”Every component instance carries a name that prefixes its physical columns. The name comes from one of two places:
- Hard-coded in the table declaration:
POSTMARK_TABLE("postmark")→ columnspostmark_server_id,postmark_signature_id, etc. - Derived via
Component.Sub.forName(name)when the same component shape appears under different prefixes (e.g.,dkimandreturn_pathboth wrap aDnsRecordcomponent). The framework always prepends<name>_to the inner column name.
class DNS_RECORDS_TABLE : Component.Sub<SignatureDnsRecords>("dns") { val dkim = component(DNS_RECORD_TABLE.forName("dkim")) // → dns_dkim_name, dns_dkim_value val returnPath = component(DNS_RECORD_TABLE.forName("return_path")) // → dns_return_path_name, dns_return_path_value // ...}The same mechanism backs the small reusable value-object components shared across modules — moneyComponent, quantityComponent, durationComponent, and provenanceComponent. The last maps a who/when Provenance(updatedBy, updatedAt) stamp to <prefix>_updated_by / <prefix>_updated_at (all-or-nothing via requiring). For example ItemReferenceComponent nests provenanceComponent(forName("provenance")), so a card’s item reference persists item_reference_provenance_updated_by / _updated_at.
Migration columns must match the prefixed name exactly. A V001 migration that names a column server_id when the framework expects postmark_server_id produces a startup-time mapping error. Pull the expected name from the framework’s prefix rule, not from the inner column declaration.
The Component.Root declaration
Section titled “The Component.Root declaration”A Component.Sub is the reusable inner shape. A table declares the root (with the standard bitemporal columns) and composes subs into it:
object EMAIL_CFG_TABLE : Component.Root<EMAIL_CFG_TABLE, EmailConfigurationRecord>("email_cfg") { // bitemporal columns (id, effective_as_of, recorded_as_of, eid, previous, retired, ...) // declared via the bitemporal base
val kind = enumeration("kind", EmailConfigurationKind::class) val slug = varchar("sending_domain_slug", 64) val signature = component(SIGNATURE_PROFILE_TABLE()) // mandatory val administrative = component(ADMINISTRATIVE_LOCK_TABLE()) // mandatory val postmark = component(POSTMARK_TABLE()) // optional val dns = component(DNS_RECORDS_TABLE()) // optional val verification = component(VERIFICATION_TABLE()) // optional val recentHealth = component(RECENT_HEALTH_TABLE()) // optional}Optional vs mandatory at the root level is enforced by the sub’s own build() — a sub that returns null for “absent” is optional; one that returns a non-null default (or that has no requiring) is mandatory.
The EntityPayload.validate(ctx, mutation) hook
Section titled “The EntityPayload.validate(ctx, mutation) hook”Component.build() round-trips columns to the composite value object. It does not validate cross-component invariants — e.g., “if kind == AWAITING_VERIFICATION then postmark must be present and verification.verifiedAt must be null”. That logic belongs to EntityPayload.validate(ctx, mutation): Result<Unit> on the entity itself, called by the universe before write.
sealed interface EmailConfiguration : EntityPayload { data class AwaitingVerification( override val eId: EntityId, val identity: EmailConfigurationIdentity, 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) } } // ...other subtypes override validate similarly}The split is:
- Component framework — pure structural projection. “These columns exist; here is the in-memory shape.”
validate(ctx, mutation)— cross-component invariants in business terms. “This combination of components is incompatible with this subtype.”
Keep the two layers separate. Pulling validation into Component.build() couples persistence to business state; pulling structural projection into validate(...) makes the universe layer rebuild the components by hand.
The fillPayload boundary — controlled throw
Section titled “The fillPayload boundary — controlled throw”The bitemporal framework’s fillPayload(row): EntityPayload is declared to throw on corrupt rows (it predates the Result convention). Smart-constructors inside Component.build() return Result; bridge with .getOrThrow() inside fillPayload only:
fun fillPayload(row: ResultRow): EmailConfiguration { val kind = row[EMAIL_CFG_TABLE.kind] val identity = buildIdentity(row).getOrThrow() // throws if row is corrupt val administrative = buildAdministrative(row).getOrThrow() return when (kind) { DRAFT -> EmailConfiguration.Draft(row[EMAIL_CFG_TABLE.eid], identity, administrative) AWAITING_VERIFICATION -> EmailConfiguration.AwaitingVerification( eId = row[EMAIL_CFG_TABLE.eid], identity = identity, administrative = administrative, postmark = EMAIL_CFG_TABLE.postmark.build() ?: error("AwaitingVerification row missing postmark component"), // ... ) // ... }}fillPayload is the only place in module code where getOrThrow() is acceptable — and only when bridging into framework contracts that demand throw. Every other place uses Result chains; see Kotlin Coding § Result Handling.
Worked references
Section titled “Worked references”cards.arda.operations.shopaccess.email.persistence.EmailConfigurationPersistentComponents.kt— sixComponent.Subdeclarations (POSTMARK_TABLE,DNS_RECORDS_TABLE,VERIFICATION_TABLE,RECENT_HEALTH_TABLE,RESTORE_AUDIT_TABLE,ADMINISTRATIVE_LOCK_TABLE), composed intoEMAIL_CFG_TABLE.cards.arda.operations.shopaccess.email.persistence.EmailConfigurationPersistence.kt—fillPayload(row)with the per-subtypewhenandgetOrThrow()bridge.cards.arda.common.lib.component.Component— framework source.
Related pages
Section titled “Related pages”- Universe Design —
AbstractUniverse, validators, table types. - Bitemporal Persistence — bitemporal column structure that components live inside.
- Table Mappings — broader mapping guide.
- Exposed Patterns — column-naming and JSON-column gotchas.
- Information Model Design —
EntityPayload.validate(ctx, mutation)contract.
Copyright: © Arda Systems 2025-2026, All rights reserved