Discovery as a Domain
Asi Farran · Principal Engineer ·
At ResortPass we sell day access to hotels. A guest books a pool pass, spa treatment, cabana or day room at a property near them and enjoys it for the day.
On one side of the marketplace, hotel partners configure inventory: what they offer, at what price, on which days, and with how much capacity. On the other, guests are looking for an experience they might want to book.
There are a lot of ways that can happen. They can search. They can browse a city page, category or collection. A merchandising team can place products in a campaign or editorial row. We may recommend something based on what they looked at, or show similar products at the bottom of a detail page.
And there are less visible versions of this: deciding which pages have enough inventory behind them to belong in a sitemap, or which suggestions typeahead should surface.
These capabilities didn't arrive all at once. Search, merchandising, recommendations, sitemap generation: each emerged at a different point, in response to a different need, and was built in the way that made sense at the time.
They looked like different problems, but as they accumulated a pattern became visible. Despite their different purposes and origins, structurally they kept doing much the same thing over roughly the same data:
Constrain a set. Rank it. Project a shape out of it. Page through it. Decide what to do when the result comes back thin.

What changes is the intent: which constraints, which ranking, which projection, how many results.
Taken together, these aren't unrelated features that happen to retrieve overlapping data. They're different expressions of the same domain: discovery.
The domain had emerged organically across the application. The architecture, however, still reflected the individual use cases and the systems that had grown up around them.
When Abstractions Grow Sideways
The obvious way to keep data-access details out of application code is to put an abstraction in front of them.
A repository exposes useful operations rather than tables. A search service hides the search index. A purpose-built query hides the shape of a materialized view.
Those are good abstractions, and for an individual use case they work well, but discovery has an awkward property: its concerns combine.
Suppose you have available_in_city and under_price. Now you need both, so you add available_in_city_under_price. Then one consumer wants that sorted by rating. Another wants family-friendly products. Another needs the same thing but only for day passes.
Repository methods typically answer one request, and two methods don't combine.
The number of domain concerns can grow fairly slowly while the number of useful combinations grows much faster. Eventually the abstraction starts acquiring operations like:
available_in_city_under_price_sorted_by_rating
The problem isn't bad naming. The name is just telling you what happened: the unit of reuse is an operation, while the thing we're trying to express is a composition of concerns.

You can respond by making the repository API more generic: accept filters, sorting and pagination, perhaps grouping and facets. If you're using an ORM that exposes a criteria DSL, you get a head start.
But now another question appears: whose vocabulary are those parameters expressed in?
In our case that question isn't theoretical, because discovery doesn't have one physical representation.
Some discovery requests are served from OpenSearch. Some from relational tables and joins. Others from materialized views built for particular access patterns.
Take something as ordinary as bookable.
In a normalized relational model, determining whether a product is bookable might involve relationships across several tables. In a materialized view, some of that interpretation may already have happened when the view was built. In a denormalized search document, the same meaning might reduce to predicates over a handful of flattened fields.
Three different representations. One domain concept.

The stores don't offer identical query semantics either. Filtering relational data and filtering a search index aren't expressed the same way. Database ordering and search ranking aren't quite the same operation. Grouping, faceting, text relevance and projection all depend on the capabilities of the thing answering the request.
So a generic data-access abstraction has an uncomfortable choice:
- Speak in the vocabulary of the underlying schema, which leaks representation.
- Speak in the vocabulary of the store, which leaks implementation.
- Translate domain concepts into those things, which is the right direction, but now the translation itself becomes part of the architecture.
Without a common place for it to live, it spreads: into repositories, search helpers, knowledge of the schema, materialized views and individual features.
Each piece can be perfectly reasonable on its own. The maintenance problem is the aggregate.
Change what "bookable" means and you need to know everywhere that meaning has been translated. Add a new discovery capability and you need to work out which access paths support it and how each expresses it. Move a use case from one store to another and the abstraction presented to its caller may change along with the implementation.
Discovery has a domain. What it doesn't yet have is a boundary.
Speaking in Domain Language
Consider the following product requirement:
Show bookable day passes in South Beach, Miami this weekend, under $150, best rated first, twelve at a time. If there are fewer than six, widen the search to the rest of Miami rather than showing a half-empty page.
There is something important about the language of that request:
- It doesn't say which fields constitute "bookable".
- It doesn't say whether South Beach is a column, a relationship, a field on a search document, or something resolved before the query runs.
- It doesn't say whether "best rated" becomes an
ORDER BY, an OpenSearch sort, a scoring function, or something else.
Those are all implementation concerns.
The request speaks in domain concepts.
And then there is the final sentence:
If there are fewer than six, widen the search to the rest of Miami.
That doesn't naturally belong to any store at all.
It's part of what the feature is asking discovery to do: a policy about what happens when the ideal result isn't good enough. Without somewhere to express it, it becomes control flow around the data-access layer.
That was the boundary we wanted.
Not another operation that answers one discovery request, and not a generic bag of parameters that maps conveniently onto the current store.
We wanted discovery to have a language of its own: a vocabulary for the things discovery knows about, and a grammar for the requests callers can make of it.
Once that language exists, the other half follows naturally: an engine whose job is to interpret it.
The caller owns the intent. The engine owns the translation and execution.
Describe what you want. Don't prescribe how, or where, to get it.
We ended up defining six properties we wanted from the design:
- Declarative: state the request; don't describe how to satisfy it.
- Domain-specific: in our vocabulary, not a data store's.
- Composable: criteria you can name, reuse and combine.
- Type-safe: invalid requests fail before they run.
- Rich: the query carries discovery semantics, not just filters.
- Decoupled: the physical interpretation belongs to the engine.
A Query Is a Value, Not Instructions
The first decision: a query is a value, not a sequence of instructions.
The builder is just one way of authoring queries, and builders can be implemented in any language against the same grammar and vocabulary. The examples here use TypeScript for brevity and readability.
const query = Query.create({
subject: Subject.Product,
view: View.Compact,
filter: all(
Product.active.eq(true),
Inventory.available.eq(true),
Product.type.eq(ProductType.DayPass),
Hotel.location.neighborhood.eq("South Beach"),
Pricing.adult.lte(150),
),
sort: Product.guestRating.desc(),
page: { size: 12 },
});
Nothing has happened yet. No store has been contacted and no decision made about how to satisfy the request.
What exists is a description of the request as a value.
That gives it some useful properties:
- You can read it. The requirement's clauses are clauses again. Someone who has never seen the execution path can review whether it represents the intended request.
- You can pass it around. It can be returned from a method, stored, serialized, sent over a wire, or built in one place and executed in another.
- You can test it without running it. Much of the behavior can be tested by asserting that the right request was constructed, without executing it against a store.
- The engine gets to choose how. The caller hasn't already committed the request to a physical representation or execution strategy.
Declarative isn't the whole point. A generic search API can be declarative too.
What matters is what we've made declarative: a discovery request expressed independently of the machinery that will fulfill it.
The Vocabulary Is Ours
Pricing.adult is a concept in our catalog, not a column or a document path. Subject.Product says what kind of thing is being requested.
Two concepts deserve calling out.
Subject Is What You're Asking For
A discovery query needs to be explicit about what constitutes a result. Are we asking for products, hotels, product types or categories?
That distinction matters because the physical records being searched aren't necessarily the things we want back.
Availability is per product, per day. A product bookable for the next six months may therefore be represented in a search index by hundreds of documents. A query for twelve products shouldn't return twelve product-date documents; those documents need to collapse into products. A query for hotels may need to group those same records differently again.
So subject defines the entity around which the result set is organized: what we group by, what we return, and what we count.
Without it, those semantics tend to be inherited from the physical shape of whichever store happens to answer the query.

View Is How Much Payload You Want
Id, Compact, Standard.
View is also mandatory; there is deliberately no default. Payload size is a real cost, and a silent default is how you end up shipping far more data than a consumer needs.
Grammar Is Not Vocabulary
The grammar defines what can be said: filters, operators, sorts, scoring, facets, pagination.
The domain defines what can be said about: fields, subjects, views.
The grammar doesn't know hotels exist.
If your query language knows the names of your fields, you've written a schema, not a language. The test is whether you can add a completely unrelated domain without touching the grammar.
So there are three distinct layers:
- Grammar: what kinds of requests can be expressed.
- Domain vocabulary: what those requests can talk about.
- Physical interpretation: what those concepts mean to a particular representation.
Keeping those separate is what lets one domain concept have several physical interpretations.

Criteria Compose, Methods Don't
This is where we come back to the method explosion we started with.
Criteria are values, so criteria compose.
const Bookable = all(Product.active.eq(true), Inventory.available.eq(true));
const FamilyFriendly = not(Hotel.adultsOnly.eq(true));
const filter = all(Bookable, FamilyFriendly, Pricing.adult.lte(150));
Bookable isn't a special construct. It's a filter like any other.
But the unit of reuse has changed.
With an operation-based API, available_in_city and under_price don't naturally produce available_in_city_under_price. Someone has to define that operation.
With a language, the reusable things are the expressions themselves, and use cases can combine the same concepts differently without defining every useful intersection in advance.
More importantly, composability gives domain meaning somewhere to live.
Bookable is a domain rule: a statement about what makes something purchasable, written once in discovery vocabulary.
The language gives that concept a name. The engine gives it its physical interpretation.
Invalid Requests Fail Before They Run
Once you define a language, you get to define what constitutes a valid sentence.
Fields have types. Operators declare what types they accept. Fields declare which operations they support. Subjects and views come from a closed vocabulary.
Those aren't conventions of individual repositories; they're part of the language contract.
A price can support numeric comparisons. A location can support geographic operations. Passing a string where a number belongs, or applying an unsupported operator, should fail before the request reaches the engine.
For code constructing queries, we push as much of that feedback as possible into static checking and CI. For serialized queries crossing a boundary, the same rules are enforced when the document is hydrated.
Different mechanism, same contract.
Runtime values can still be represented by typed placeholders:
const MaxPrice = Context.number("maxPrice");
const filter = Pricing.adult.lte(MaxPrice);
A query containing unbound references is a recipe; binding it produces an executable query.
That lets a merchandising rule, for example, be stored once and bound with request-specific context later, without giving up the guarantees of the language.
The same principle applies to results. A query declares its view, and the result conforms to that view rather than exposing whatever shape the underlying store happened to return.
The store's representation stops at the engine boundary.
The Query Carries Discovery Semantics
Now back to the last sentence of the original requirement:
If there are fewer than six, widen the search to the rest of Miami rather than showing a half-empty page.
This is where the distinction between a generic query API and a discovery language becomes more interesting.
The requirement isn't merely describing data. It's describing discovery behavior.
The ideal result is South Beach. But the feature would rather relax that constraint than return too few results.
That's part of the intent.
So fallback is part of the request:
const query = Query.create({
filter: all(
// usual filters
Hotel.location.neighborhood.eq("South Beach"),
),
page: { size: 12 }, // we want twelve
fallback: {
trigger: { minResults: 6 }, // but if fewer than 6
strategy: FallbackStrategy.Append,
filter: Hotel.location.city.eq("Miami"), // relax criteria
},
});
The trigger, the relaxation, and how fallback results combine are all explicit parts of the request.

Note that the execution policy is not the caller's decision.
Fallbacks can run lazily, executing the primary request, inspecting the count, then executing the fallback if needed, or eagerly, fetching both tiers at once.
That's a latency-versus-load tradeoff. It depends on traffic shape, store characteristics and index size.
So it belongs to the engine, not the query.
minResults: 6 is a what.
Lazy versus eager is a how.
That's another expression of the same guiding design principle: describe, don't prescribe.
Decoupling Is a Consequence, Not the Cause
Decoupling is often framed as portability:
We're using OpenSearch today, but now we could swap it for something else tomorrow.
That's useful, but it isn't really our problem.
We already have different physical representations today.
The same discovery domain spans OpenSearch, relational queries and materialized views. The same concept may therefore need several simultaneous physical interpretations.
The query names domain concepts. The engine translates those concepts for the execution path that will satisfy the request.
For OpenSearch, that means compiling the query tree into OpenSearch's query model; a materialized projection can interpret the same concepts differently. The caller doesn't need to know which one happened.

The stable contract is the domain meaning, not any one physical representation.
That also gives us one place to make decisions that otherwise tend to vary subtly across access paths:
- Filters are gates, never scorers. If something affects ranking, it belongs in scoring explicitly.
- Counts are distinct entities, not documents. Totals and facets count the entity named by the subject, not whatever physical records happen to represent it.
- Views constrain the payload. Asking for
Compactfetches compact; it doesn't fetch everything and discard most of it later.
Those are discovery semantics, and the engine defines them once.
The language gives discovery a boundary of its own. Store independence follows from that.
The Language Crosses the Wire
The builder isn't the language. Queries have a canonical serialized representation, so the same contract can be used in-process, stored as a recipe, or sent over an API.
Once it crosses a process boundary, it's versioned and validated like any other public contract.
A Ruby, TypeScript or Python builder is simply an authoring interface over the same grammar and domain vocabulary.
That turns out to matter for another kind of caller too.
The Same Language, Read by a Machine
Somewhere in the middle of all this, we started designing for natural-language access to the catalog. It hasn't shipped yet, but it shaped the language.
The idea is to let someone ask for a quiet adults-only pool day near Santa Monica under a hundred dollars and get back actual products.
One approach is a collection of narrow tools:
search_products, find_similar, products_in_city, filter_by_price.
But tool proliferation is method proliferation wearing a different hat. Compound requests require more tools or more orchestration, and every new request shape puts pressure on the interface.
The other extreme is exposing the store's query language. Now the model needs to understand our physical index, and a language much larger than the one we actually want it to speak.
The discovery language sits between those extremes: one bounded, read-only interface expressed in domain vocabulary.
Because the grammar and vocabulary are structured data, they can describe themselves to a machine caller. And because queries already have a canonical representation, exposing the language through something like MCP becomes an adapter around an existing domain boundary rather than another discovery API.
A closed grammar doesn't guarantee every valid request is cheap, of course. The surface still needs limits on things like pagination, facet cardinality and execution time.
Check Please
There is an obvious counterargument:
You built a language: that's not free!
That's a fair point, and a query language isn't where I'd start. If you have a handful of fixed access patterns against one stable store, a repository or service abstraction is almost certainly simpler.
The pressure toward a language appears when the thing underneath those operations reveals a stable domain of its own: the same vocabulary across many use cases, those concepts combining in many ways, and multiple physical implementations interpreting the same intent.
At that point the complexity already exists. Formalizing it doesn't remove the complexity. It moves it.
The tradeoff isn't really a language versus no complexity. It's more like:
Formal language and centralized translation versus informal language and distributed translation.

Before the Discovery Engine, we already had a language of sorts. It was just spread across repository methods, search parameters, schemas, helpers, materialized views and conventions understood by the people working on each feature.
We're just choosing to make it explicit.
Come Build It With Us
We're a small team, and many of the decisions that will shape discovery at ResortPass are still ahead of us: how the engine grows, which stores it speaks to, and what the language should be able to say next.
If that sounds like the kind of problem you want to work on, we're looking for great engineers. Take a look at our open roles and get in touch.