java 109 lines · 4 tabs

Per-Tenant Row Filtering with Hibernate @Filter Enabled Per Request

Shared by codesnips Aug 2026
4 tabs
@Entity
@Table(name = "invoices")
@FilterDef(
    name = "tenantFilter",
    parameters = @ParamDef(name = "tenantId", type = String.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Invoice {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "tenant_id", nullable = false, insertable = false, updatable = false)
    private String tenantId;

    @Column(nullable = false)
    private String number;

    @Column(nullable = false)
    private BigDecimal amount;

    @Column(name = "issued_at", nullable = false)
    private Instant issuedAt;

    public Long getId() {
        return id;
    }

    public String getTenantId() {
        return tenantId;
    }

    public BigDecimal getAmount() {
        return amount;
    }
}
4 files · java Explain with highlit

Row-level multi-tenancy keeps every tenant's data in one shared schema but guarantees a query never returns another tenant's rows. This snippet shows the discretionary form of that pattern using Hibernate's @Filter, which injects a WHERE predicate into every generated SQL statement for annotated entities — but only when the filter has been explicitly enabled on the session.

In Invoice entity, the @FilterDef declares a named filter tenantFilter with a tenantId parameter, and @Filter binds its condition (tenant_id = :tenantId) to the table. Declaring the filter does nothing on its own; Hibernate treats it as opt-in so that admin jobs and migrations can still see all rows. The tenantId column is deliberately mapped insertable = false, updatable = false so application code cannot spoof it — it is populated elsewhere at persist time.

TenantContext is a simple ThreadLocal holder. Because a servlet request is handled on a single thread, stashing the current tenant here lets any layer read it without threading the value through method signatures. The clear() call matters: thread-pool reuse means a stale value would leak across requests if it were not reset.

TenantFilterInterceptor is the enforcement point. As a Spring HandlerInterceptor, its preHandle runs before the controller. It unwraps the JPA EntityManager to a Hibernate Session, calls enableFilter("tenantFilter"), and sets the tenantId parameter from TenantContext. From that moment every query against Invoice silently gains the tenant predicate. afterCompletion clears the context to keep the pooled thread clean.

The WebConfig registers the interceptor for the API paths, completing the wiring. The key trade-off is that this is application-enforced, not database-enforced: any code path that forgets to enable the filter — a native query, a @Async job on a fresh thread, or a repository call outside the request scope — bypasses isolation entirely. For that reason the filter should be paired with careful review of background work, and the tenantId should be assigned server-side rather than trusted from the client. Used within request scope, it is a low-ceremony way to make every finder tenant-safe without rewriting a single query.


Related snips

Share this code

Here's the card — post it anywhere.

Per-Tenant Row Filtering with Hibernate @Filter Enabled Per Request — share card
Link copied