Skip to content

Designing Data with Maryk

This guide helps you design data models that read and write efficiently in Maryk. You’ll learn how to decide when to embed data, when to split into separate models with references, how to use MultiType for polymorphic shapes, how keys influence locality, and how graphs let you fetch only what you need.

What you’ll learn

  • A simple mental model for Maryk data.
  • How to choose between embedding and referencing.
  • When to reach for MultiType or ValueDataModel.
  • How keys shape query performance.
  • How graphs keep payloads small.

Most designs begin with four decisions:

QuestionDefault answer
Is this a top-level thing users search, sync, or edit on its own?Make it a RootDataModel.
Is this data always read with its parent and bounded in size?Embed it.
Is this collection unbounded or independently updated?Split it into its own root and reference it.
Do screens only need a few fields?Use reference graphs instead of reshaping the model.

Keep the first model boring. Add references, secondary indexes, and MultiType only when the access pattern needs them.

  • DataModel: the schema for a record, with named properties that each have a stable UInt property index for fast, reflection‑free serialization. See Data Models.
  • RootDataModel: a top‑level model that can be stored and queried. Roots define keys and optional secondary ordered/search indexes. See Keys.
  • Secondary indexes: ordered tuple indexes and search indexes built from composable index operators. See Index Design.
  • Properties: rich types including embedded objects, lists/sets/maps, references, and MultiType.
  • Queries: filter using property references and select fields with reference graphs. See Property References and Reference Graphs.
  • Versioning: evolve safely with built‑in schema/data versioning. See Versioning.

Maryk rewards colocating related data, then using graphs to fetch only what you need.

If you are new to Maryk, read sections 2, 3, 7, and 8 first. The MultiType and ValueDataModel sections are useful once your model has variant shapes or compact value objects.

Embedding is practical because queries don’t have to return whole objects. A reference graph describes exactly which properties to read, including nested fields and type‑specific multiType selections.

Example — select a user’s name and only the city inside an embedded address:

val g = User.graph {
listOf(
name,
graph(address) { listOf(city) }
)
}

Keep this in mind as you make design choices below.

3) Embed or reference? A quick decision guide

Section titled “3) Embed or reference? A quick decision guide”

Embed (keep together) when:

  • You usually read/write the fields together.
  • The nested collection is small or bounded.
  • The nested values share the parent’s lifecycle and permissions.
  • You want atomic updates across parent + nested fields.

Split into a separate root + reference when:

  • The child collection can grow large/unbounded.
  • The child needs its own lifecycle, permissions, or global queries.
  • You anticipate independent write contention on the child.

Tip: Even when you embed, use graphs to keep reads lean.

object Address : DataModel<Address>() {
val street by string(index = 1u)
val city by string(index = 2u)
val postalCode by string(index = 3u)
}
object User : RootDataModel<User>() {
val name by string(index = 1u)
val address by embed(index = 2u, dataModel = { Address })
val tags by set(index = 3u, valueDefinition = StringDefinition())
}

Why this works well

  • One round‑trip to read the user and address.
  • Atomic updates and shared versioning.
  • With graphs, you don’t over‑fetch.

Graph example — pick only what matters:

val g = User.graph {
listOf(name, graph(address) { listOf(city) })
}

5) MultiType: one property, several shapes

Section titled “5) MultiType: one property, several shapes”

multiType is a typed union. A single property can hold one of several well‑defined variants; each variant has its own schema. Use it when the shape varies across a small set of alternatives and you want type‑safe selection at read time.

When to use

  • Variant depends on context (Email vs Phone contact).
  • Keep related data embedded while allowing shape differences.
  • Read variant‑specific fields via graphs.

When to avoid

  • Many variants that must be queried globally as first‑class objects → separate roots.
  • Unbounded/dynamic variants that need global indexing per type.

Use a keyed map for structured or multi-type collection entries; public list/set elements must have a collection-compatible scalar or fixed-value definition.

See MultiType design recipes for embedded contact variants, polymorphic attachment maps and variant-based keys.

See the full MultiType reference for API details.

Use ValueDataModel when the composite can be represented in fixed bytes and you need it as a compact value, map key, or index component. Use embed / EmbeddedValuesDefinition for richer stored sub-records. EmbeddedObjectDefinition is for Kotlin object models and request/response serialization.

  • ValueDataModel: great for ranges, periods, coordinates, composite keys.
  • Embedded object: great for readable sub‑records and larger nested data.
  • MultiType: great for compact polymorphic sub‑records.

7) Splitting into roots: references and indexes

Section titled “7) Splitting into roots: references and indexes”

When collections grow or the child needs independence, split into a root model and relate via a reference.

Start with a bounded map of embedded lines, keyed by line number. Embedded records are valid map values, not list/set elements. Choose the split alternative when lines need an independent lifecycle. The following definitions are alternatives, so do not declare both Order and OrderLine pairs in the same file:

object OrderLine : DataModel<OrderLine>() {
val sku by string(index = 1u)
val quantity by number(index = 2u, type = SInt32)
val priceCents by number(index = 3u, type = SInt32)
}
object Order : RootDataModel<Order>() {
val orderId by string(index = 1u)
val customerId by string(index = 2u)
val lines by map(
index = 3u,
keyDefinition = NumberDefinition(type = UInt32),
valueDefinition = EmbeddedValuesDefinition(dataModel = { OrderLine }),
)
}
// Choose this alternative when lines have an independent lifecycle.
// Split version
object Order : RootDataModel<Order>(keyDefinition = { UUIDv7Key }) {
val orderId by string(index = 1u)
val customerId by string(index = 2u)
}
object OrderLine : RootDataModel<OrderLine>(
keyDefinition = { Multiple(OrderLine.order.ref(), OrderLine.lineNo.ref()) },
indexes = { listOf(Multiple(OrderLine.sku.ref())) }
) {
val order by reference(index = 1u, dataModel = { Order }, final = true)
val lineNo by number(index = 2u, type = SInt32, final = true)
val sku by string(index = 3u)
val quantity by number(index = 4u, type = SInt32)
val priceCents by number(index = 5u, type = SInt32)
}

Guidelines

  • Design composite keys for your dominant scans (by parent, by time, etc.).
  • Add indexes only for real secondary lookups (e.g., by sku).
  • Keep reads efficient using graphs; for enrichment across models, see Collect & Inject.

Keys decide how data is clustered and thus how range scans perform.

Common patterns

  • Owner + reversed timestamp: Multiple(user.ref(), Reversed(date.ref())) → latest first per user.
  • Include Model.ref &#123; property.type &#125; to cluster polymorphic values by variant.

Feed example:

object FeedItem : RootDataModel<FeedItem>(
keyDefinition = { Multiple(FeedItem.user.ref(), Reversed(FeedItem.postedAt.ref()), FeedItem.ref { content.type }) }
) {
val user by reference(index = 1u, dataModel = { User }, final = true)
val postedAt by dateTime(index = 2u, final = true)
// MultiType enum for feed content
val content by multiType(index = 3u, typeEnum = FeedContentMT)
}
// Define the MultiType enum used in FeedItem
sealed class FeedContentMT<T: Any>(index: UInt, override val definition: IsUsableInMultiType<T, IsPropertyContext>?) : IndexedEnumImpl<FeedContentMT<*>>(index), MultiTypeEnum<T> {
object PostType: FeedContentMT<Values<Post>>(1u, EmbeddedValuesDefinition(dataModel = { Post }))
object EventType: FeedContentMT<Values<Event>>(2u, EmbeddedValuesDefinition(dataModel = { Event }))
companion object : MultiTypeEnumDefinition<FeedContentMT<out Any>>(FeedContentMT::class, values = { listOf(PostType, EventType) })
}

Keep an authoritative source and copy a few stable, display‑oriented fields to avoid joins during reads. Pair with a reference back to the source.

object Product : RootDataModel<Product>() {
val id by string(index = 1u)
val name by string(index = 2u)
val priceCents by number(index = 3u, type = SInt32)
}
object OrderLine : RootDataModel<OrderLine>() {
val product by reference(index = 1u, dataModel = { Product })
val productName by string(index = 2u) // snapshot for display
val productPriceCents by number(index = 3u, type = SInt32)
}

Prefer denormalizing stable fields (names, slugs). Avoid duplicating highly volatile fields unless you control updates.

  • Start embedded for simplicity; split into roots as scale/query needs grow.
  • Use Versioning to add properties and indexes, backfill, then remove obsolete fields.
  • When splitting, keep both paths during migration; write to both until backfill completes.
  • What is the dominant read path? Design keys for it.
  • Do I always read this nested data with the parent? If yes, embed; otherwise consider a separate root.
  • Could the collection grow unbounded? If yes, separate root.
  • Do variants fit a small, known set? Consider MultiType.
  • What secondary lookups matter? Add only those indexes.
  • Can I shrink payloads with graphs? Always define select graphs for hot paths.

Further reading