Storage Examples — Real-World Data Models

Practical document models for common application scenarios — user profiles, product catalogs, orders, and activity logs — plus the query patterns and optimizations to search them at scale.

The first CRUD app tutorial showed the mechanics: mutations, composed queries, get-by-guid. This page goes into data modelling — how to structure documents for real applications, given that RUAL storage is schema-less JSON with no relationships between documents. Each example includes a sample document and the query blocks to search it. For the full storage reference, see Storage.

Manage Storages page in RUAL Studio showing the storages used in these examples

User Profiles

A profile is a classic read-heavy document: it is loaded on almost every request, so embed everything the profile page displays. Preferences rarely change and belong to the user, so they live inside the document as a nested object. The team the user belongs to is denormalized — team_name is embedded for display, team_guid is kept as the reference for when you need the full team document.

Profile Document
{
  "_meta": {},
  "display_name": "joe doe",
  "email": "joe@example.com",
  "preferences": {
    "language": "en",
    "notifications": {
      "email": true,
      "push": false
    },
    "theme": "dark"
  },
  "role": "editor",
  "team_guid": "98f7999521030bbb9c96cfaf15badf216e1adc287e2bb8b0b3414a0008fc58fa",
  "team_name": "content team",
  "user_guid": "a23575f49d0af385314c1f02280163374297e018a692e5e4ab85eb307ebf6ebc"
}

The most common lookup is by email — for example in a login flow. Since a profile is unique per email, use function_search_single_result instead of a regular search:

Block Purpose Connects To
value_default (email, lowercased) The email address to look up query_bool_term_fields (email field)
query_bool_term_fields Exact match on email query_bool_filter
query_bool_filter Efficient filter context, no scoring needed function_search_single_result (query pin)

Once the user is logged in, switch to function_get_document with the profile's guid — it is the fastest retrieval method and the easiest to cache.

Product Catalog

A product carries its variants and pricing inside the document. Variants are never displayed without their product, so splitting them into separate documents would only force extra lookups. Keep prices in cents to avoid floating-point rounding.

Product Document
{
  "_meta": {},
  "active": true,
  "brand": "rual wear",
  "category": "clothing/shirts",
  "description": "soft organic cotton tee with a regular fit",
  "name": "essential cotton t-shirt",
  "sku": "TSHIRT-BLK",
  "variants": [
    {
      "color": "black",
      "price": 2490,
      "size": "s",
      "sku": "TSHIRT-BLK-S",
      "stock": 14
    },
    {
      "color": "black",
      "price": 2490,
      "size": "m",
      "sku": "TSHIRT-BLK-M",
      "stock": 32
    }
  ]
}

Two query patterns cover most catalog pages. Category pages filter on an exact value; the search box uses full-text search on name and description. Both combine with a filter on active so drafts never leak into the shop:

Pattern Query Blocks
Category page query_bool_term_fields (category = clothing/shirts) + query_bool_term_fields (active = true) in a query_bool_filter, sorted with query_sort_field
Search box query_bool_simple_query_string_field (name) in query_bool_must, active-filter in query_bool_filter, combined with query_and
Autocomplete query_bool_prefix_string_field or query_bool_wildcard_string_field on name, with a small limit

Store name and description in lowercase and lowercase the user's input before connecting it to the query block — searches are case-sensitive. To change a variant's stock after a purchase, use function_update_document with mutations_increment_by_field — the transaction-based update system makes concurrent decrements safe.

Order Management

An order is the strongest case for denormalization. Line items are embedded — they are created once, never change independently, and are always shown with their order. The customer is embedded as a snapshot: name, email, and address at the moment of purchase. If the customer later updates their profile, the order must keep the original data for invoices and shipping labels, so do not sync these fields with a storage event.

Order Document
{
  "_meta": {},
  "customer_email": "joe@example.com",
  "customer_guid": "a23575f49d0af385314c1f02280163374297e018a692e5e4ab85eb307ebf6ebc",
  "customer_name": "joe doe",
  "items": [
    {
      "name": "essential cotton t-shirt",
      "price": 2490,
      "quantity": 2,
      "sku": "TSHIRT-BLK-M"
    }
  ],
  "order_number": "ORD-2024-0042",
  "status": "shipped",
  "status_history": [
    {
      "at": 1706396400,
      "status": "placed"
    },
    {
      "at": 1706396700,
      "status": "paid"
    },
    {
      "at": 1706482800,
      "status": "shipped"
    }
  ],
  "total": 4980
}

Do not split orders and line items into two storages. Without joins, an order page would need one search for the order plus a function_get_documents call per line item set, and every status filter would have to match across storages. Embedding keeps the whole lifecycle in one document that one query can find.

Status transitions are mutations on the order document: mutations_set_bp_field_multiple sets the new status, and mutations_add_to_array_by_field appends to status_history. Both go through function_update_document, so transitions are processed sequentially — a warehouse scan and a customer cancellation can't overwrite each other. Query open work with query_bool_term_fields on status; query a customer's orders with a term match on customer_guid, sorted by _meta.created descending.

The same shape works for any status-style field: one term block per allowed value in a query_bool_must for an OR filter.

Activity Logs

Logs are append-only: documents are created, never updated, and only ever read as filtered time ranges. Model each event as a flat document with the actor denormalized onto it — log views always show who did what, and historical logs should not change when a user renames their account.

Activity Log Document
{
  "_meta": {
    "created": 1706396400,
    "expiry": 1737759600,
    "guid": "b1fd2ecf516b6b225a3fb4cc729932892a45abb2a5ee5fcb40c11ef6b1b80bd6"
  },
  "actor_email": "joe@example.com",
  "actor_guid": "a23575f49d0af385314c1f02280163374297e018a692e5e4ab85eb307ebf6ebc",
  "context": {
    "ip": "203.0.113.10",
    "user_agent": "mozilla/5.0"
  },
  "event": "user.login",
  "severity": "info"
}

Use _meta.expiry for retention instead of building a cleanup job: pass an expiry date to the expiry in-pin of function_create_document — for example now plus 90 days — and storage removes the document automatically. Note that expired documents are permanently deleted.

Time-based queries range over _meta.created, a Unix timestamp. Combine a range with a term filter on event or actor_guid:

Pattern Query Blocks
Last 24 hours query_bool_range_field on _meta.created (greater than now − 86400) in query_bool_filter
Events of one user query_bool_term_fields (actor_guid) + range on _meta.created, sorted descending with query_sort_field
Errors only query_bool_term_fields (severity = error) + range, combined with query_and

Search Optimization

  • Store searchable values in lowercase. Searches are case-sensitive — Joe will never match joe. Lowercase values on write, and lowercase user input before connecting it to a query block.
  • Reuse cache keys. function_search has a cache key in-pin. Identical queries with the same key — a category page, a profile lookup — are served from cache instead of hitting the index on every request.
  • Paginate with limit and offset. Connect a number block to the limit in-pin (or use query_limit in the query) and multiply offset by the page number. Always set a limit; smaller result sets are faster to return and render.
  • Select only the fields you render with query_source_by_field for list pages that need two or three fields per document.
  • Be deliberate with query_disabled_request_cache. It forces the search to wait until all pending inserts are indexed — the right tool for heavy aggregation-style queries that must see every write, but a source of noticeable delay in high-insert storages like activity logs. For counters and statistics, prefer maintaining a counter field with mutations_increment_by_field or aggregating periodically into Redis; see Common Pitfalls.

Denormalize vs Reference by GUID

Denormalize (embed) when… Reference by guid when…
You display the data in every list or detail view (customer_name on an order). You only need the full document on a detail view (team_guid on a profile).
The data is a historical snapshot that must not change (order customer, log actor). The related data changes often and staleness is not acceptable.
You filter or sort on the value in search queries. The embedded payload would be large or deeply nested.

Even when you embed, always keep the related guid on the document — it is your way back to the source with function_get_document, and the key a storage event uses to find and re-sync embedded copies when syncing is what you want.

Storage The full storage reference: meta data, query types, lifecycle, expiry, and caching. Storage Events Keep denormalized copies in sync when a source document changes. First CRUD App New to storage? Build the task manager tutorial first, then return here.