Search looked like a feature. It turned out to be an architecture decision.
Our platform stores millions of equipment, contract, and claim records across many tenants. Users
needed to combine filters freely — status, date ranges, free text, nested relations — and the
original implementation did what most codebases do: optional request parameters glued onto a JPA
Specification. It worked at ten thousand records. At a few million, every "flexible" query was a
full-table scan, and tenant scoping depended on every developer remembering a predicate.
This post walks through the redesign: a small query DSL, parsed to an AST, compiled to parameterized, tenant-scoped Elasticsearch queries.
Why not just fix the SQL?
We evaluated three options before reaching for a search engine:
- PostgreSQL full-text search. Good for text, but our problem was combinable structured filters across denormalized relations. GIN indexes on tsvector don't help you filter claims by warranty status joined through equipment.
- Materialized views per filter combination. Fine for three filters. Combinatorial suicide for twelve.
- Elasticsearch with denormalized read models. An indexing pipeline and eventual consistency, in exchange for filters and text search that are all fast, all composable.
We took option three, and the honest cost was consistency: a record you just edited may take a second to reflect in search. That is a UX problem as much as an engineering one, and pretending otherwise is how teams lose user trust. We surfaced it directly ("results update within seconds") rather than hiding it.
A grammar small enough to trust
Client-built JSON query bodies were off the table — that's handing your index to the internet. Instead, clients send a compact query string:
status:open AND (priority:high OR sla.breached:true) AND created:>2025-01-01 "compressor unit"The grammar is deliberately tiny: field predicates, boolean operators, grouping, ranges, and quoted free text. Small enough that a recursive-descent parser stays readable:
// Recursive descent: each grammar rule is one method.
private Node parseOr() {
Node left = parseAnd();
while (peek() == Token.OR) {
consume(Token.OR);
left = new OrNode(left, parseAnd());
}
return left;
}The parser produces an AST. Nothing downstream ever sees the raw string again — and that separation is the entire security story.
Compile, don't concatenate
The compiler walks the AST and emits an Elasticsearch query body. Two rules are enforced structurally, not by convention:
QueryNode compile(Node ast, TenantContext tenant, FieldWhitelist fields) {
Node validated = fields.validate(ast); // unknown field -> reject at compile time
return new BoolQuery()
.filter(term("tenantId", tenant.id())) // injected on every query, unconditionally
.must(walk(validated));
}- Field whitelisting. Every field reference is checked against a per-resource whitelist at compile time. An unknown or forbidden field is a 400, not a slow query or a data leak.
- Tenant scoping. The tenant filter is injected by the compiler itself. There is no code path that produces an unscoped query, which is a much stronger claim than "we always remember to add it."
Keeping the index honest
The write store publishes domain events (via a transactional outbox — separate post), and an indexer projects them into denormalized documents. Each document carries everything a search result needs, so search never joins and never calls back into services.
Hot facet counts — the "Open (1,204)" numbers in the sidebar — were the surprise cost. They're aggregations over the whole tenant dataset on every keystroke. Caching them in Redis with a short TTL took p95 from embarrassing to under 150ms, and nobody has ever noticed facet counts that are thirty seconds stale.
Results and regrets
p95 under 150ms across 1M+ records, zero cross-tenant leakage, and the DSL got extracted into a standalone library. The regret: I built the indexer's replay tooling after the first mapping change forced a full reindex under pressure. Build replay before you need it — an event-sourced index you can't rebuild is a liability with good latency.
The lesson that generalizes: search is a read model, not a query feature. Model it explicitly, own its consistency story, and make the safe query path the only path.