Skip to content

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.

SituationUse
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 rowComponent.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.

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:

  1. Column-name prefixing. Every column declared inside the component is physically named <component-name>_<column-name> in the database. serverId above lands as postmark_server_id. The prefix is set by the constructor argument (name = "postmark"); see Column-prefix convention below.
  2. All-or-nothing nullability. requiring(col1, col2, ...) { ... } returns the built value if all listed columns are non-null and null if all listed columns are null. A partial fill (some columns set, others null) is a corrupt row and requiring returns null; the layer above turns that into a Result.failure(AppError.IncompatibleState).
  3. 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.

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") → columns postmark_server_id, postmark_signature_id, etc.
  • Derived via Component.Sub.forName(name) when the same component shape appears under different prefixes (e.g., dkim and return_path both wrap a DnsRecord component). 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.

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.

  • cards.arda.operations.shopaccess.email.persistence.EmailConfigurationPersistentComponents.kt — six Component.Sub declarations (POSTMARK_TABLE, DNS_RECORDS_TABLE, VERIFICATION_TABLE, RECENT_HEALTH_TABLE, RESTORE_AUDIT_TABLE, ADMINISTRATIVE_LOCK_TABLE), composed into EMAIL_CFG_TABLE.
  • cards.arda.operations.shopaccess.email.persistence.EmailConfigurationPersistence.ktfillPayload(row) with the per-subtype when and getOrThrow() bridge.
  • cards.arda.common.lib.component.Component — framework source.