@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;
}
}
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private TenantContext() {
}
public static void set(String tenantId) {
CURRENT.set(tenantId);
}
public static String get() {
String tenantId = CURRENT.get();
if (tenantId == null) {
throw new IllegalStateException("No tenant bound to the current request");
}
return tenantId;
}
public static void clear() {
CURRENT.remove();
}
}
@Component
public class TenantFilterInterceptor implements HandlerInterceptor {
private final EntityManager entityManager;
public TenantFilterInterceptor(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = request.getHeader("X-Tenant-Id");
if (tenantId == null || tenantId.isBlank()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
TenantContext.set(tenantId);
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.get());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
Session session = entityManager.unwrap(Session.class);
session.disableFilter("tenantFilter");
TenantContext.clear();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TenantFilterInterceptor tenantFilterInterceptor;
public WebConfig(TenantFilterInterceptor tenantFilterInterceptor) {
this.tenantFilterInterceptor = tenantFilterInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantFilterInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health", "/api/auth/**");
}
}
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
Turbo Streams + authorization: signed per-user stream name
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
Share this code
Here's the card — post it anywhere.