Press ESC to exit fullscreen
📖 Lesson ⏱️ 75 minutes

Object Sets and Interfaces

Query the ontology: filter, aggregate, traverse links, and define interface contracts

What an object set is

An object set is the ontology’s first-class concept for “a collection of objects of one type, possibly filtered, joined, aggregated, or sorted.”

It is not just a query — it is a value in the type system. You can:

  • Pass an object set around your code.
  • Compose it (filter further, traverse a link, aggregate).
  • Materialize it (fetch a page, count, hash).
  • Bind it to a UI element that re-renders when it changes.

Think of it as a lazy, typed, named query that knows what kind of object it produces.

Building object sets

A typical SDK lets you build object sets fluently:

// All shipments
const all = await Shipment.all();

// Filtered
const inFlight = await Shipment.where(s => s.status == "in_transit");

// Filtered and paginated
const page = await Shipment
  .where(s => s.destinationCountry == "DE")
  .orderBy(s => s.createdAt, "desc")
  .page({ limit: 50 });

// Traversed via a link
const customer = await Customer.byId("cust_a1b2");
const orders   = await customer.placedOrders
                               .where(o => o.status == "completed");

// Aggregated
const revenueByRegion = await Order
  .where(o => o.status == "completed")
  .groupBy(o => o.region)
  .aggregate({ revenue: sum(o => o.totalAmount) });

The exact syntax varies by platform; the shape does not. You compose object sets, the platform plans the query against indexes, and you get typed results back.

Filters

Filters are the most-used composition. A few rules that age well:

1. Filter on indexed properties. Filtering on status = 'in_transit' is O(index); filtering on a derived property without an index is O(full scan). The platform usually tells you which properties are indexed — design queries around them.

2. Compose filters. Each .where(...) narrows the previous set. Shipment.where(a).where(b) is equivalent to Shipment.where(a && b).

3. Use the link traversal, not a separate query. customer.placedOrders.where(...) is better than Order.where(o => o.customerId == customer.id).where(...) — the link is the natural index.

4. Prefer typed filters over string-built ones. where(s => s.status == "in_transit") over where("status = 'in_transit'"). The first is checked by the type system; the second is a string nobody will refactor when status is renamed.

Pagination — non-negotiable

Never materialize an unbounded object set. Even “small” collections grow.

Two common pagination styles:

Offset pagination:

.page({ limit: 50, offset: 100 })

Easy to reason about; terrible at scale. After a few thousand offset, the query slows.

Cursor pagination:

const first  = await Shipment.orderBy(s => s.id).page({ limit: 50 });
const second = await Shipment.orderBy(s => s.id).pageAfter(first.cursor, 50);

Stable, fast at any scale, but the cursor is opaque — you cannot jump to “page 47.”

For UIs that show a list with infinite scroll: cursor. For UIs that need page numbers: offset, with a small max page count.

Ordering

Always order paginated queries. Unordered + paginated = nondeterministic — you might see the same row twice or skip rows depending on physical layout.

Shipment
  .where(s => s.status == "in_transit")
  .orderBy(s => s.createdAt, "desc")
  .orderBy(s => s.shipmentId, "asc")   // tie-breaker
  .page({ limit: 50 });

A tie-breaking second sort on a unique field guarantees a deterministic order.

Aggregations

Aggregations turn an object set into one (or a few) rows of summary:

// Counts
const inTransitCount = await Shipment
  .where(s => s.status == "in_transit")
  .count();

// Group + aggregate
const byRegion = await Order
  .where(o => o.status == "completed")
  .groupBy(o => o.shippingRegion)
  .aggregate({
    revenue:  sum(o => o.totalAmount),
    orders:   count(),
    avgValue: avg(o => o.totalAmount),
  });

Aggregations are evaluated on the index layer; they should be fast even over large object sets. If yours is not, you are aggregating over an unindexed dimension — talk to your platform team about adding an index.

Object sets as values

A subtle but powerful idea: object sets are first-class values. You can pass them to functions and actions.

function: cohortRetentionRate
parameters:
  - name: cohort
    type: ObjectSet<Customer>
  - name: asOf
    type: timestamp
returns: double
body: |
  let active = cohort.filter(c => c.lastActiveAt >= asOf - 30d)
  return active.count() / cohort.count()

The caller composes whatever cohort they want and passes it in:

const enterpriseGerman = Customer
  .where(c => c.tier == "platinum")
  .where(c => c.region == "DE");

const retention = await cohortRetentionRate(enterpriseGerman, now());

This is the level of expressiveness the type system buys you.

Interfaces

Now: a different question. Suppose Shipment, Vehicle, and Hub all have a geographic location. Today each defines its own location property. Tomorrow you want to write a function “find the nearest one of any of these to a given point” — without three near-duplicate implementations.

Interfaces are the answer.

An interface is a typed contract that multiple object types can implement. Like an interface in Java or a protocol in Python.

interface: Locatable
description: Anything that has a current physical location.
properties:
  - { name: currentLocation, type: GeoPoint }
  - { name: locationUpdatedAt, type: timestamp, nullable: true }

Then object types declare implementation:

objectType: Vehicle
implements: [Locatable]
properties:
  - { name: vehicleId, type: string, primaryKey: true }
  - { name: currentLocation, type: GeoPoint }
  - { name: locationUpdatedAt, type: timestamp, nullable: true }
  - ...

objectType: Shipment
implements: [Locatable]
properties:
  - ...

Now functions and queries can target the interface:

function: nearestLocatable
parameters:
  - { name: from, type: GeoPoint }
  - { name: candidates, type: ObjectSet<Locatable> }
returns: Locatable

Or in queries:

const nearby = await Locatable
  .within(currentPos, 5_km)
  .limit(20);
// Returns a mixed set of Vehicles, Shipments, Hubs — anything Locatable.

When to use an interface

Use an interface when:

  • Multiple object types share behavior (a function should operate on any of them).
  • You will query across the union of these types as if they were one collection.
  • The shared properties have the same meaning in each type. (currentLocation means the same thing on a Vehicle and a Shipment.)

Do not use an interface when:

  • The “shared” property has different meaning per type — that is duplication, not abstraction.
  • You only need to share the data shape, not the behavior. (Use a struct.)
  • The polymorphism is for storage convenience, not modeling. (Just use two object types.)

A useful test: write the function signature that motivated the interface. If it makes sense to call it on instances of any of those types — interface. If not — keep them separate.

Interface vs. inheritance

Some platforms also support inheritance between object types (PremiumCustomer extends Customer). Inheritance shares both shape and identity — a PremiumCustomer is a Customer, with the same primary-key namespace.

Inheritance is more constraining than interfaces — and often less useful. Most “I want to share fields” needs are better served by:

  1. An interface for shared behavior.
  2. A struct for shared shape that is not behavior.
  3. A separate object type entirely.

Reach for inheritance only when the IS-A relationship is real and stable.

Interfaces can also be the target of a link type. A Notification might link to aboutEntity: Locatable — and the locatable could be a Vehicle, Shipment, or Hub depending on the notification.

This is genuinely useful — but harder for queries. Make sure the platform you are on supports polymorphic links well before you depend on them.

Putting it together — a small example

// Find all Locatables within 5km of a service center,
// grouped by their object type.
const incidents = await Locatable
  .within(serviceCenter.location, 5_km)
  .where(l => l.locationUpdatedAt > now() - 1h)
  .groupBy(l => l.__type)
  .aggregate({ count: count() });

// → [
//     { __type: "Vehicle", count: 12 },
//     { __type: "Shipment", count: 47 },
//     { __type: "Driver", count: 8 },
//   ]

One query, polymorphic across types, typed throughout.

Key takeaways

  • An object set is a typed, lazy, composable query over an object type.
  • Always paginate, always order — unordered + paginated is broken.
  • Object sets are first-class values; you can pass them to functions and actions.
  • Interfaces let multiple object types share behavior under one contract — use them when behavior is genuinely shared.

What’s next

We have covered the entire conceptual surface — objects, links, actions, functions, queries, interfaces. The remaining concept lesson covers where the data behind the ontology comes from: datasource integration.


Read everything. Update nothing without an action. 🔎