---
title: "Storage Examples · RUAL Documentation"
description: "Worked document designs for profiles, catalogs and orders."
canonical: https://docs.rual.nl/blueprints/storage-examples
language: en
---

# 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](https://docs.rual.nl/getting-started/first-crud-app) 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](https://docs.rual.nl/blueprints/storage).

### 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.

```
{
  "_meta": {
    "cms": 1706396400362,
    "created": 1706396400,
    "entity": 1,
    "expiry": -1,
    "guid": "a23575f49d0af385314c1f02280163374297e018a692e5e4ab85eb307ebf6ebc",
    "removed": 0,
    "ums": 1708393100132,
    "update_hash": "60e27209fa93063b5605e41605c2722ed428cae0",
    "updated": 1708393100
  },
  "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`](https://docs.rual.nl/block-types/globals%2Cstorage/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`](https://docs.rual.nl/block-types/query/query_bool_term_fields) (email field) |
| [`query_bool_term_fields`](https://docs.rual.nl/block-types/query/query_bool_term_fields) | Exact match on `email` | [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter) |
| [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter) | Efficient filter context, no scoring needed | [`function_search_single_result`](https://docs.rual.nl/block-types/storage/function_search_single_result) (query pin) |

Once the user is logged in, switch to [`function_get_document`](https://docs.rual.nl/block-types/storage/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.

```
{
  "_meta": {
    "cms": 1780531200118,
    "created": 1780531200,
    "entity": 1,
    "expiry": -1,
    "guid": "c45797061f2c15a75361042244385e96419d230ab804d7fa08cd52981fd78192",
    "removed": 0,
    "ums": 1782166695204,
    "update_hash": "f1a9c2d47b6035e8419dc0a7532be96481cd30fa",
    "updated": 1782166695
  },
  "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`](https://docs.rual.nl/block-types/query/query_bool_term_fields) (category = clothing/shirts) + [`query_bool_term_fields`](https://docs.rual.nl/block-types/query/query_bool_term_fields) (active = true) in a [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter), sorted with [`query_sort_field`](https://docs.rual.nl/block-types/query/query_sort_field) |
| Search box | [`query_bool_simple_query_string_field`](https://docs.rual.nl/block-types/query/query_bool_simple_query_string_field) (name) in [`query_bool_must`](https://docs.rual.nl/block-types/query/query_bool_must), active-filter in [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter), combined with `query_and` |
| Autocomplete | [`query_bool_prefix_string_field`](https://docs.rual.nl/block-types/query/query_bool_prefix_string_field) or [`query_bool_wildcard_string_field`](https://docs.rual.nl/block-types/query/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`](https://docs.rual.nl/block-types/storage/function_update_document_mutations) with [`mutations_increment_by_field`](https://docs.rual.nl/block-types/mutations/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.

```
{
  "_meta": {
    "cms": 1782166695403,
    "created": 1782166695,
    "entity": 1,
    "expiry": -1,
    "guid": "d56808172a3d26b86472153355496fa752ae341bc915e80b19de63a920e89203",
    "removed": 0,
    "ums": 1782166695761,
    "update_hash": "3d82f70b5ce1946a208bd4f6019e7c5a86bb42d1",
    "updated": 1782166695
  },
  "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`](https://docs.rual.nl/block-types/storage/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`](https://docs.rual.nl/block-types/mutations/mutations_set_bp_field_multiple) sets the new `status`, and [`mutations_add_to_array_by_field`](https://docs.rual.nl/block-types/mutations/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`](https://docs.rual.nl/block-types/query/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`](https://docs.rual.nl/block-types/query/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.

```
{
  "_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`](https://docs.rual.nl/block-types/storage/function_create_document_from_mutations): 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`](https://docs.rual.nl/block-types/query/query_bool_range_field) on `_meta.created` (greater than now − 86400) in [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter) |
| Events of one user | [`query_bool_term_fields`](https://docs.rual.nl/block-types/query/query_bool_term_fields) (actor_guid) + range on `_meta.created`, sorted descending with [`query_sort_field`](https://docs.rual.nl/block-types/query/query_sort_field) |
| Errors only | [`query_bool_term_fields`](https://docs.rual.nl/block-types/query/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`](https://docs.rual.nl/block-types/query/query_source_by_field) for list pages that need two or three fields per document.

- **Be deliberate with [`query_disabled_request_cache`](https://docs.rual.nl/block-types/query/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`](https://docs.rual.nl/block-types/mutations/mutations_increment_by_field) or aggregating periodically into Redis; see [Common Pitfalls](https://docs.rual.nl/blueprints/common-pitfals).

#### 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`](https://docs.rual.nl/block-types/storage/function_get_document), and the key a [storage event](https://docs.rual.nl/blueprints/storage-events) uses to find and re-sync embedded copies when syncing *is* what you want.
