Skip to content
Mahesh Kadambala
All articles

Architecture3 min read

Building an Enterprise Search Engine on the JVM

How we replaced unindexed SQL filtering with a custom query DSL compiled to tenant-scoped Elasticsearch queries — the grammar, the AST, the trade-offs, and what I'd do differently.

SearchElasticsearchJavaSystem Design

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:

  1. 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.
  2. Materialized views per filter combination. Fine for three filters. Combinatorial suicide for twelve.
  3. 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.

Related articles

AI Engineering3 min read

AI-Assisted Engineering Without Losing the Plot

A year of using AI agents on production backend code: where they genuinely compress work, where they quietly create debt, and the review discipline that makes the difference.

Distributed Systems3 min read

The Transactional Outbox, Properly

Dual-writes between your database and Kafka will lose events — quietly, rarely, and at the worst time. Here's the outbox pattern as actually implemented, including the parts the diagrams skip.