Object Sets and Interfaces

Querying the ontology: filter, aggregate, paginate, traverse links. Then: interfaces — cross-cutting contracts that let multiple object types share behavior.

⚡ intermediate
⏱️ 75 minutes
👤 SuperML Team

· Ontology · 7 min read

📋 Prerequisites

  • Lesson 8: Functions on the Ontology

🎯 What You'll Learn

  • Build object sets using filter, aggregate, and link traversal
  • Paginate and order results correctly
  • Define interfaces that span multiple object types
  • Choose between an object type and an interface for shared behavior

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

Related Tutorials

⚡intermediate ⏱️ 90 minutes

Action Types: Writing to the Ontology

Actions are the only safe way to mutate ontology state. Learn how to design them: parameters, validations, side effects, idempotency, and audit.

Ontology7 min read
ontologyaction typesmutations +1
⚡intermediate ⏱️ 60 minutes

Ontology Architecture

How object types, link types, action types, functions, datasources, and the security layer compose into a working ontology — and how data and writes actually flow through them.

Ontology6 min read
ontologyarchitecturedata architecture
⚡intermediate ⏱️ 75 minutes

Best Practices and Production Patterns

What separates an ontology that thrives over years from one that collapses under its own weight. Patterns for granularity, idempotency, observability, and ontology hygiene.

Ontology7 min read
ontologybest practicesproduction +1
⚡intermediate ⏱️ 300 minutes

Capstone: A Complete Operational Ontology

Bring it all together. Design and ship a complete logistics ontology — objects, links, actions, functions, security, and the test suite to prove it works.

Ontology8 min read
ontologycapstoneproject +1