# Modeling data

URL: https://docs.domainruntime.dev/docs/data/modeling-data
Status: Verified
Reviewed: 2026-09-23



You describe your data in the domain with `withSchema`. Entities are collections of objects; links connect them.

```ts title="src/domain.ts"
import { domain, i } from "@domainruntime/domain";

export default domain("orders").withSchema({
  entities: {
    orders_customer: i.entity({
      name: i.string(),
      email: i.string().unique().indexed(),
    }),
    orders_order: i.entity({
      reference: i.string().unique().indexed(),
      status: i.string().indexed(),
      total: i.number(),
      placedAt: i.date().indexed(),
      notes: i.string().optional(),
    }),
  },
  links: {
    orders_orderCustomer: {
      forward: { on: "orders_order", has: "one", label: "customer" },
      reverse: { on: "orders_customer", has: "many", label: "orders" },
    },
  },
  rooms: {},
});
```

`entities`, `links` and `rooms` are all required today, even when empty.

## Attributes [#attributes]

| Type          | Stores                       |
| ------------- | ---------------------------- |
| `i.string()`  | text                         |
| `i.number()`  | numbers                      |
| `i.boolean()` | true / false                 |
| `i.date()`    | a point in time              |
| `i.json()`    | any JSON value, stored as-is |

Attributes are **required** unless you add `.optional()`. Every entity also has an `id`.

| Modifier      | Effect                                                                                         |
| ------------- | ---------------------------------------------------------------------------------------------- |
| `.optional()` | the value may be missing                                                                       |
| `.unique()`   | no two objects share the value; a write that would duplicate it fails with `record-not-unique` |
| `.indexed()`  | needed to filter with comparisons (`$gt`, `$like`…) and to `order` by it                       |

Index what you filter and sort on. Equality filters work without an index but get slower as the entity grows.

## Links [#links]

A link has two directions, each with a label and a cardinality:

```ts
orders_orderCustomer: {
  forward: { on: "orders_order", has: "one", label: "customer" },
  reverse: { on: "orders_customer", has: "many", label: "orders" },
}
```

An order now has `customer` and a customer has `orders`, and you can query either way. Many-to-many is `has: "many"` on both sides:

```ts
orders_orderTags: {
  forward: { on: "orders_order", has: "many", label: "tags" },
  reverse: { on: "orders_tag", has: "many", label: "orders" },
},
```

Every label must be unique on its entity: two links that both add an `orders` label to `orders_customer` collide. Name them by role (`placedOrders`, `approvedOrders`).

Add `onDelete: "cascade"` to the side that should disappear with its parent:

```ts
forward: { on: "orders_lineItem", has: "one", label: "order", onDelete: "cascade" },
```

## Composing domains [#composing-domains]

Large products are several small domains. A domain can include another and link to its entities:

```ts
import orders from "./orders";

export default domain("billing")
  .includes(orders)
  .withSchema({
    entities: {
      billing_invoice: i.entity({ number: i.string().unique().indexed(), amount: i.number() }),
    },
    links: {
      billing_invoiceOrder: {
        forward: { on: "billing_invoice", has: "one", label: "order" },
        reverse: { on: "orders_order", has: "many", label: "invoices" },
      },
    },
    rooms: {},
  });
```

Including a domain brings its entities into scope. It does **not** expose its actions: each domain decides which actions its callers can run. See [Actions](/docs/data/actions#exposing-actions).

**Why the prefix.** Entities share one namespace per environment and domains are composed, so `orders_order` and `billing_order` can live side by side, and anyone reading a query knows which domain owns each entity.

## Changing the schema [#changing-the-schema]

Publishing applies your schema to the environment's database, the same way in development and in production. Every change is checked against the data already stored **before** anything is written: either the whole schema applies, or nothing does and the previous release keeps serving.

| Change                                                           | What happens                                                                                |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| New entity, attribute or link                                    | applied                                                                                     |
| New index, or index removed                                      | applied                                                                                     |
| `.unique()` added                                                | applied if the stored values are unique; otherwise refused, listing the duplicates          |
| `.unique()` removed, attribute made optional                     | applied                                                                                     |
| Type changed                                                     | applied if every stored value fits the new type; otherwise refused with a count and samples |
| Attribute made required                                          | applied if every object has a value; otherwise refused                                      |
| New **required** attribute on an entity that already has objects | refused: add it with `.optional()` first, fill it, then make it required                    |
| Link cardinality narrowed (many → one)                           | applied if the data fits; otherwise refused                                                 |
| Entity, attribute or link removed                                | refused until you confirm it; see below                                                     |

A refusal answers `409 schema_change_refused` with one entry per problem, so the CLI can show each one:

```json
{ "error": "schema_change_refused",
  "details": { "issues": [
    { "where": "orders_order.reference", "code": "duplicates", "message": "unique has 1 duplicate value (A-1 ×2)" }
  ] } }
```

Each publish reports what it did, e.g. `+2 attrs, +1 index, +1 link`, or `no changes`.

### Removing and renaming [#removing-and-renaming]

Removing something from the schema is destructive, so it needs a confirmation, as in InstantDB's `instant-cli push`:

```bash
druntime push --yes                          # confirm every removal of something your domain declared
druntime push --delete orders_order.notes    # confirm one removal by name
druntime push --rename orders_order.ref:orders_order.reference   # same attribute, new name, data kept
```

Without a confirmation, the publish is refused with `409 removal_unconfirmed`, listing what would be deleted. Without `--rename`, a rename is a removal plus a new attribute.

A confirmed removal is a **soft delete**: the attribute and its data are hidden, not erased, for **2 days**. Within that time you can bring them back:

```bash
druntime schema deleted                          # what was removed, and until when it can come back
druntime schema restore orders_order.notes       # back, with its data (not indexed, not required)
```

After 2 days the data is purged for good.

Something in the database that no release ever declared (written directly, or left from an earlier setup) is kept and reported as a warning on every publish. It is deleted only when you name it with `--delete`; `--yes` never includes it.

## System entities [#system-entities]

Every environment has these. You can link to them and query them:

| Entity        | Holds                                                 | Default visibility                   |
| ------------- | ----------------------------------------------------- | ------------------------------------ |
| `$users`      | the users of your app. See [Users](/docs/auth/users). | each user sees their own row         |
| `$files`      | uploaded files. See [Files](/docs/data/files).        | hidden until your rules allow it     |
| `$streams`    | streams. See [Streams](/docs/data/streams).           | hidden until your rules allow it     |
| `$executions` | action runs                                           | each user sees the runs they started |

Link your entities to `$users` to model ownership; [Permissions](/docs/auth/permissions) shows how rules use it.
