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>
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);
}
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);
}
}
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);
}
Couple of things to notice here:
- The controller still receives the raw filter string. We just pass it through
parseForTenantbefore converting it. - The tenant node gets AND-wrapped around whatever the user sends. So
status : 'paid'becomestenantId : 42 and status : 'paid'. - If the user sends an empty filter (or no filter param), the
@Filterannotation withOptionalor a default handles that, and the tenant clause still gets injected. The node mapper always fires. - Native queries, custom
@Querymethods, projections — they all pass through the sameparseForTenantmethod. 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);
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);
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);
}
}
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)