Course Content
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: LocatableOr 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. (
currentLocationmeans the same thing on aVehicleand aShipment.)
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:
- An interface for shared behavior.
- A struct for shared shape that is not behavior.
- A separate object type entirely.
Reach for inheritance only when the IS-A relationship is real and stable.
Interfaces and links
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. 🔎