DEV Community

torshid
torshid

Posted on

Multi-Tenant Query Filtering That Doesn't Touch Your Controllers

Remember when you first added multi-tenancy to an app? You probably went through the exact same stages I did:

Phase 1: just add tenant_id = ? to the controller. Easy.
Phase 2: realize you have 30 controllers. Add it everywhere. Swear a lot.
Phase 3: someone adds a new endpoint and forgets. Bug in prod. More swearing.
Phase 4: create a base repository method. Feels clever for about a week.
Phase 5: someone writes a native query or a report endpoint that bypasses the base repo. Swearing intensifies.

I went through all five phases before I realized the problem isn't the repository layer at all. The problem is that filtering happens at the wrong level. If your controller receives a filter string, parses it, and then slaps on a tenant clause, you're doing surgery at the reception desk. You should intercept the filter before anyone even sees the patient.

Here's the thing about Spring Filter's ParseContext: it sits between the HTTP request and your JPA repository. The filter string comes in as status : 'active'. Before it hits the query builder, ParseContext can rewrite the AST and turn it into tenantId : 42 and status : 'active'. Your controller has no idea. Your repository has no idea. Neither of them needs to.

The 20-minute setup

I'm using Spring Boot 4 here but it's the same for 3.x.

Maven dependency for the JPA module:

<dependency>
    <groupId>com.turkraft.springfilter</groupId>
    <artifactId>jpa</artifactId>
    <version>4.0.5</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Your controller stays exactly as you'd write it for a non-tenant app:

@GetMapping("/invoices")
Page<Invoice> search(@Filter Specification<Invoice> spec, Pageable pageable) {
    return invoiceRepo.findAll(spec, pageable);
}
Enter fullscreen mode Exit fullscreen mode

That's it. No tenantId parameter. No @AuthenticationPrincipal. No custom repository method. The controller is done.

Now the clever part. You create a service that wraps the raw filter:

@Service
public class TenantFilterService {

    @Autowired FilterParser parser;
    @Autowired FilterSpecificationConverter converter;
    @Autowired FilterBuilder fb;

    public <T> Specification<T> parseForTenant(String rawFilter, Long tenantId) {
        ParseContext ctx = new ParseContextImpl(null, userNode -> {
            FilterNode tenantNode = fb.field("tenantId")
                .equal(fb.input(tenantId))
                .get();
            return fb.and(tenantNode, userNode).get();
        });

        FilterNode finalNode = parser.parse(rawFilter, ctx);
        return converter.convert(finalNode);
    }
}
Enter fullscreen mode Exit fullscreen mode

And your controller calls that instead of letting Spring auto-resolve the @Filter:

@GetMapping("/invoices")
Page<Invoice> search(@Filter String rawFilter, Pageable pageable,
                     @AuthenticationPrincipal User user) {
    Specification<Invoice> spec = tenantService
        .parseForTenant(rawFilter, user.getTenantId());
    return invoiceRepo.findAll(spec, pageable);
}
Enter fullscreen mode Exit fullscreen mode

Couple of things to notice here:

  1. The controller still receives the raw filter string. We just pass it through parseForTenant before converting it.
  2. The tenant node gets AND-wrapped around whatever the user sends. So status : 'paid' becomes tenantId : 42 and status : 'paid'.
  3. If the user sends an empty filter (or no filter param), the @Filter annotation with Optional or a default handles that, and the tenant clause still gets injected. The node mapper always fires.
  4. Native queries, custom @Query methods, projections — they all pass through the same parseForTenant method. No more "oh but this endpoint uses a native query so it bypasses the tenant filter" hell.

What if someone tries to filter on tenantId directly?

Valid question. If a user crafts ?filter=tenantId : 99, the node mapper will turn it into tenantId : 42 and tenantId : 99 which always returns nothing. That's actually correct behavior for a tenant filter — user 42 should never see tenant 99's data, even by accident.

But you can also be explicit about it if you prefer. Override the field mapper in ParseContext to reject or rewrite any field named tenantId:

ParseContext ctx = new ParseContextImpl(field -> {
    if ("tenantId".equals(field)) {
        throw new IllegalArgumentException("nice try");
    }
    return field;
}, nodeMapper);
Enter fullscreen mode Exit fullscreen mode

Or silently redirect it to the current tenant. Up to you.

One context, multiple backends

The same parseForTenant approach works if you switch to MongoDB. Instead of returning a Specification, you return a Query or Document. The ParseContext step is identical because it operates on the AST, not on JPA or Mongo specifics. The AST doesn't know or care what database you're using.

// Same ParseContext, different converter:
@Autowired FilterQueryConverter mongoConverter;

Query mongoQuery = mongoConverter.convert(finalNode);
Enter fullscreen mode Exit fullscreen mode

Why this beats the alternatives

approach why it fails
@Where(clause = "tenant_id = ?") in Hibernate doesn't work for native queries, doesn't work for projections, doesn't work if you need per-request tenant switching
Base repository method same problems plus someone always finds a way to call entityManager.createNativeQuery() directly
Spring Security ACL overkill, requires separate ACL tables, massive performance hit for simple tenant filtering
Interceptor/filter on HTTP level can't inject into the query language itself, can only add request params which might conflict

ParseContext works at the right level of abstraction. It sees the parsed query tree and rewrites it before any database-specific code runs. That's the whole trick.

Real talk: what you lose

Not much but worth mentioning. You can't just annotate your controller and have it work magically anymore — you need to call parseForTenant explicitly. It's one extra line per search endpoint. That's the trade.

Also, if you have endpoints that should NOT be tenant-filtered (admin dashboards, cross-tenant reports), you can either skip parseForTenant for those endpoints or pass a flag that tells the node mapper to stay out of the way.

The full code, no commentary

// TenantFilterService.java
@Service
public class TenantFilterService {

    @Autowired private FilterParser parser;
    @Autowired private FilterSpecificationConverter jpaConverter;
    @Autowired private FilterQueryConverter mongoConverter;
    @Autowired private FilterBuilder fb;

    public <T> Specification<T> parseJpa(String rawFilter, Long tenantId) {
        FilterNode node = wrapWithTenant(rawFilter, tenantId);
        return jpaConverter.convert(node);
    }

    public <T> Query parseMongo(String rawFilter, Long tenantId, Class<T> entityClass) {
        FilterNode node = wrapWithTenant(rawFilter, tenantId);
        return mongoConverter.convert(node, entityClass);
    }

    private FilterNode wrapWithTenant(String rawFilter, Long tenantId) {
        ParseContext ctx = new ParseContextImpl(null, userNode -> {
            FilterNode tenantNode = fb.field("tenantId")
                .equal(fb.input(tenantId))
                .get();
            return fb.and(tenantNode, userNode).get();
        });
        return parser.parse(rawFilter, ctx);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. Twenty-ish lines that save you from touching every controller every time a tenant requirement changes.

I first wrote about this library when it was at v1 and all it did was parse filter strings for JPA entities. Feels like a lifetime ago. The ParseContext stuff came later and it's by far the most underrated feature — it turns the library from "handy query parser" into "query interception framework" without anyone having to learn a new DSL or wire up Spring Security magic.

Repo's at turkraft/springfilter if you want to dig in. PRs welcome, especially if you've got a weird tenant setup I haven't thought of yet.

Top comments (0)