Tenant-Aware Coding — Developer Guide¶
Audience: every developer (human or AI agent) writing or modifying Java code in TQPro. Read this once before touching any code that opens a Hibernate session, schedules a background task, or runs outside a Jersey request handler.
Companion document:
doc/architecture/multitenant-architecture.mdexplains how the runtime works. This guide tells you what to type.
TL;DR — the one rule you must internalize¶
Every call to
XxxDBSession.getSession()must happen inside an activeRequestContext. Jersey requests give you one for free; anywhere else you must establish one withTenantScope.run(tenantId, ...). Violating this throwsIllegalStateExceptionon the first DB call.
If you remember nothing else, remember that.
Table of contents¶
- Decide which flow you're in
- Pattern A — Jersey API endpoint
- Pattern B — Facade called from an API
- Pattern C — Scheduled background task
- Pattern D — Hazelcast topic listener
- Pattern E — Plugin
initializePlugin() - Pattern F — Singleton with eager DB-loading constructor
- Pattern G — Unit / integration test
- Platform-DB access (the only other path)
- Common mistakes — wrong vs. right
- Self-audit checklist
1. Decide which flow you're in¶
Ask yourself these two questions, in order:
Q1: Will this code ever run on a thread invoked by Jersey
(i.e. inside a request handler, or in a facade/service it calls)?
│
├─ YES → You're in the REQUEST FLOW. The filter set the context for
│ you. Just call XxxDBSession.getSession() normally. No
│ TenantScope needed. Skip to §2.
│
└─ NO → Continue to Q2.
Q2: Does this code touch a *DBSession.getSession() call (directly, or
indirectly through a facade, service, or cache that does)?
│
├─ YES → You're in the MANUAL FLOW. You MUST wrap the work in
│ TenantScope.run(tenantId, ...). Go to §4–§7 for the right
│ pattern (scheduled? listener? plugin init?).
│
└─ NO → Carry on. No tenant scoping needed.
Do not invent a third option. If your code path doesn't fit, ask before inventing — see the architecture doc §7 for the "no third path" guarantee.
2. Pattern A — Jersey API endpoint¶
Context: Code in tqapi/.../api/*Api.java. Invoked by Jersey on a
worker thread. The AuthenticationFilter has already set
RequestContext from the JWT / dev-mode credentials before your method
runs.
What to do: nothing special. Just delegate to your facade.
@Path("/cruise")
public class CruiseApi {
@POST @Path("/itinerary/list")
@Produces(MediaType.APPLICATION_JSON)
public Response listItineraries(ItineraryListRequest req) {
// Tenant context is already set. Just delegate.
CruiseFacade facade = new CruiseFacade();
List<CItinerary> result = facade.listItineraries(req.getFilter());
return Response.ok(new ApiResponse(result)).build();
}
}
Do not call RequestContext.current().getTenantId() to "manually
route" the request — it's already routed for you. The session helper
reads the ThreadLocal on its own.
3. Pattern B — Facade called from an API¶
Context: Code in tqapp/.../entity/*Facade.java or
tqcommon/.../entity/.../EntityFacade.java. Invoked from a Jersey
resource — so the request flow is active.
What to do: nothing special. Open sessions as usual.
public class CruiseFacade {
public List<CItinerary> listItineraries(ItineraryFilter f) {
try (Session session = NTSDBSession.getSession()) {
// session is bound to the right tenant's DB pool, automatically
return session.createQuery(
"from ItineraryEntity where ...", ItineraryEntity.class)
.getResultList()
.stream()
.map(this::toCanonical)
.toList();
}
}
}
This pattern is identical whether the facade is called from a Jersey API
or from inside a TenantScope.run(...) block — getSession() doesn't
care how the context got set, only that it exists.
4. Pattern C — Scheduled background task¶
Context: A Runnable registered with
ScheduledExecutorService.scheduleAtFixedRate(...). Runs on an executor
thread that the request lifecycle never touches. No context is set
when your run() method fires.
What to do: fan out over the active tenant registry. Always.
import com.perun.tlinq.tenant.TenantInfo;
import com.perun.tlinq.tenant.TenantRegistry;
import com.perun.tlinq.tenant.TenantScope;
import java.util.Collection;
import java.util.logging.Level;
import java.util.logging.Logger;
public class MyRefreshRunner implements Runnable {
private static final Logger logger = Logger.getLogger(MyRefreshRunner.class.getName());
@Override
public void run() {
Collection<TenantInfo> tenants = TenantRegistry.instance().listActive();
// 1. Empty registry is a valid state (greenfield first start).
// Log and exit cleanly — don't throw.
if (tenants.isEmpty()) {
logger.fine("No active tenants — skipping MyRefresh tick.");
return;
}
// 2. One TenantScope per tenant. A failure for one tenant must not
// abort the others — log it and continue.
for (TenantInfo tenant : tenants) {
try {
TenantScope.run(tenant.getTenantId(), () -> {
logger.info("Running MyRefresh for tenant " + tenant.getTenantCode() + ".");
doRefreshWork(); // your per-tenant logic
});
} catch (Exception ex) {
logger.log(Level.WARNING,
"MyRefresh failed for tenant " + tenant.getTenantCode(), ex);
}
}
}
private void doRefreshWork() {
try (Session s = NTSDBSession.getSession()) {
// ... your DB work for ONE tenant ...
}
}
}
If your runner uses a Hazelcast distributed lock¶
The lock is global (one execution across the whole cluster per tick), not per-tenant. Acquire it once, fan out tenants inside the locked section, release once:
@Override
public void run() {
Collection<TenantInfo> tenants = TenantRegistry.instance().listActive();
if (tenants.isEmpty()) {
logger.fine("No active tenants — skipping tick.");
return;
}
IMap<String, Long> lockMap;
boolean acquired = false;
try {
lockMap = TlinqClusterCache.instance()
.getHazelcastInstance().getMap("schedulerLocks");
acquired = lockMap.tryLock(LOCK_KEY, 0, TimeUnit.SECONDS,
55, TimeUnit.MINUTES);
if (!acquired) {
logger.info("Another node is running — skipping.");
return;
}
for (TenantInfo tenant : tenants) {
try {
TenantScope.run(tenant.getTenantId(), () -> doRefreshWork(tenant));
} catch (Exception ex) {
logger.log(Level.WARNING,
"failed for tenant " + tenant.getTenantCode(), ex);
}
}
} catch (Exception ex) {
logger.log(Level.WARNING, "Lock acquisition failed", ex);
} finally {
if (acquired) {
try { lockMap.unlock(LOCK_KEY); }
catch (Exception ex) { /* log */ }
}
}
}
Reference implementations: tqryb2b/.../SDRefreshRunner.java,
tqgglbl/.../GGRefreshRunner.java, tqapp/.../sched/OptionExpiryRunner.java.
5. Pattern D — Hazelcast topic listener¶
Context: A MessageListener registered with an ITopic. Fires on a
Hazelcast thread when a message arrives. No context is set.
What to do: if the listener does per-tenant work, the message payload
must carry the tenantId. Use it to wrap the body.
ITopic<TenantChangeEvent> topic = hz.getTopic("tenant-events");
topic.addMessageListener(msg -> {
TenantChangeEvent ev = msg.getMessageObject();
if (ev.getTenantId() == null) {
logger.warning("Received tenant-events message with no tenantId — dropping.");
return;
}
TenantScope.run(ev.getTenantId(), () -> handleEvent(ev));
});
If your listener is not per-tenant (e.g., it just refreshes a global
in-memory map) it doesn't need a TenantScope — but then it also
must not call any *DBSession.getSession().
6. Pattern E — Plugin initializePlugin()¶
Context: A plugin's initializePlugin() method, invoked by
TlinqFrameworkInitializer.initAllPlugins() at startup. No context is
set, and the framework cannot guess what tenant your plugin cares about.
What to do:
- Schedule any periodic work (executor, scheduler) — register the runnable, but make sure the runnable itself follows Pattern C.
- Initialize static / shared state that does NOT touch the per-tenant DB (factories, HTTP clients, in-memory config) — do it directly.
- Initialize per-tenant caches or anything that calls
*DBSession.getSession()— fan out over the registry, same shape as Pattern C. Empty registry → log "deferring … until a tenant is provisioned" and return.
Skeleton:
@Override
public void initializePlugin() {
logger.info("MyPlugin initializing.");
// (A) Things that don't touch the per-tenant DB — do them directly.
MyServiceFactory.getInstance();
// (B) Schedule background work — the runnable does its own fan-out.
executorService = Executors.newScheduledThreadPool(1, /* daemon factory */);
executorService.scheduleAtFixedRate(new MyRefreshRunner(), 0, 1, TimeUnit.HOURS);
// (C) Per-tenant init that touches the DB — fan out now.
Collection<TenantInfo> tenants = TenantRegistry.instance().listActive();
if (tenants.isEmpty()) {
logger.info("No active tenants — deferring MyPlugin cache init until a tenant is provisioned.");
} else {
for (TenantInfo tenant : tenants) {
try {
TenantScope.run(tenant.getTenantId(), () -> {
logger.info("Initializing MyPlugin caches for tenant " + tenant.getTenantCode());
MyCacheManager.instance(); // see Pattern F
});
} catch (Exception ex) {
logger.log(Level.WARNING,
"MyPlugin cache init failed for tenant " + tenant.getTenantCode(), ex);
}
}
}
logger.info("MyPlugin initialized.");
}
Do not put a synchronous "smoke check" like
XxxDBSession.getSession().close() at the top of initializePlugin().
On a tenant-less first start it throws and prevents the JVM from booting.
Per-tenant DB readiness surfaces naturally on first request / first tick.
Reference: tqryb2b/.../RaynaB2BActPlugin.java.
7. Pattern F — Singleton with eager DB-loading constructor¶
Context: A class with a static instance() method whose constructor
eagerly loads data from the per-tenant DB on first call (e.g.
RaynaCacheManager). This is a legacy pattern — new code should prefer
lazy loading + per-tenant maps — but until the shared-cache refactor
lands, here's how to handle it correctly.
What to do: call MyCacheManager.instance() inside a
TenantScope.run(...). The constructor runs once on the very first call;
the tenant whose scope is active at that moment determines what gets
loaded. Currently "last-tenant-wins" — a known limitation explicitly
called out in the architecture doc §6.
// In the plugin's initializePlugin() — see Pattern E:
for (TenantInfo tenant : tenants) {
TenantScope.run(tenant.getTenantId(), () -> {
MyCacheManager.instance(); // constructor runs only the first iteration
});
}
When you're writing a new cache manager, do not do DB work in the
constructor. Make instance() cheap (just register listeners,
allocate the empty cache map) and add a separate initialize() method
that does the DB load. Then callers wrap initialize() in TenantScope
exactly as above. Reference: TiqetsCacheManager follows the correct
pattern; RaynaCacheManager is the legacy one.
8. Pattern G — Unit / integration test¶
Context: A JUnit test that calls a facade or service that opens a DB session.
What to do: in @BeforeEach, set up an H2 test DB, register it with
TenantRegistry.initForTest(...), and either call your code inside
TenantScope.run(...) or set RequestContext directly in setup.
class MyFacadeTest {
private static final String TEST_TENANT_ID = "11111111-1111-1111-1111-111111111111";
private HikariDataSource ds;
@BeforeEach
void setUp() throws Exception {
// 1. In-memory H2 platform DB
HikariConfig hc = new HikariConfig();
hc.setJdbcUrl("jdbc:h2:mem:platform;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
hc.setUsername("sa");
hc.setPassword("");
ds = new HikariDataSource(hc);
try (Connection c = ds.getConnection(); Statement s = c.createStatement()) {
s.execute("CREATE TABLE tenant (tenant_id VARCHAR(36) PRIMARY KEY, "
+ "tenant_code VARCHAR(50), tenant_name VARCHAR(200), "
+ "db_name VARCHAR(63), db_user VARCHAR(63), db_pass VARCHAR(200), "
+ "kc_realm VARCHAR(63), status VARCHAR(20), config CLOB)");
s.execute("INSERT INTO tenant VALUES ('" + TEST_TENANT_ID + "', "
+ "'test', 'Test', 'test_db', 'sa', '', 'test', 'ACTIVE', '{}')");
}
TenantRegistry.initForTest(ds);
}
@AfterEach
void tearDown() {
TenantRegistry.initForTest(null);
if (ds != null) ds.close();
}
@Test
void myFacadeWorks() {
TenantScope.run(TEST_TENANT_ID, () -> {
MyFacade f = new MyFacade();
assertEquals(expected, f.doWork());
});
}
}
Reference: tqcommon/src/test/.../TenantRegistryTest.java,
TenantScopeTest.java.
9. Platform-DB access (the only other path)¶
The tqplatform DB (tenant registry, WhatsApp routing, platform
migrations) is not a tenant DB. It does not participate in the
TenantAwareDBSession flow. There is exactly one way to get a connection
to it:
import com.perun.tlinq.tenant.PlatformDbConfig;
try (Connection c = PlatformDbConfig.instance().getDataSource().getConnection();
PreparedStatement ps = c.prepareStatement(
"SELECT tenant_code FROM tenant WHERE status='ACTIVE'")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ...
}
}
}
When to use it:
- Tenant registry queries (and TenantRegistry already does this for you)
- Tenant provisioning / deprovisioning (TenantProvisioningFacade)
- Platform-admin API endpoints (PlatformAdminApi)
- WhatsApp phone-routing lookup
When NOT to use it:
- Anything that reads or writes per-tenant business data. Booking,
customer, hotel, flight, cruise, invoice — none of that lives in
tqplatform. If you find yourself writing
PlatformDbConfig.instance().getDataSource() in a facade or API, stop
— you're in the wrong DB.
10. Common mistakes — wrong vs. right¶
Mistake 1: opening a session in a runnable without TenantScope¶
Wrong:
executor.scheduleAtFixedRate(() -> {
try (Session s = NTSDBSession.getSession()) { // ❌ IllegalStateException
// ...
}
}, 0, 1, TimeUnit.HOURS);
Right: see Pattern C.
Mistake 2: using SEED_TENANT_ID as a default¶
Wrong:
String tid = RequestContext.current() != null
? RequestContext.current().getTenantId()
: RequestContext.SEED_TENANT_ID; // ❌ writes to whatever DB has that ID
TenantScope.run(tid, this::doWork);
SEED_TENANT_ID is a backwards-compatibility constant for the in-place
migration scenario (Appendix A of the operations runbook). On a
greenfield install no such tenant exists, and on a multi-tenant install
it might point to a real tenant whose data you would corrupt by writing
into it from a context that has nothing to do with that tenant.
Right: if your code path has no natural tenant, fan out over
TenantRegistry.listActive() (Pattern C) — never assume a default.
Mistake 3: aborting fan-out on one tenant's failure¶
Wrong:
for (TenantInfo t : tenants) {
TenantScope.run(t.getTenantId(), () -> doWork(t)); // ❌ throws → loop exits
}
A single broken tenant DB would silently prevent all other tenants from being processed.
Right: wrap each iteration in try/catch and log:
for (TenantInfo t : tenants) {
try {
TenantScope.run(t.getTenantId(), () -> doWork(t));
} catch (Exception ex) {
logger.log(Level.WARNING, "failed for tenant " + t.getTenantCode(), ex);
}
}
Mistake 4: treating "no active tenants" as an error¶
Wrong:
Collection<TenantInfo> tenants = TenantRegistry.instance().listActive();
if (tenants.isEmpty()) {
throw new IllegalStateException("No tenants registered!"); // ❌
}
A tenant-less first start is the documented greenfield state (operations runbook §8).
Right:
Mistake 5: using PlatformDbConfig for tenant data¶
Wrong:
// In a CustomerFacade or similar:
try (Connection c = PlatformDbConfig.instance().getDataSource().getConnection()) {
// ❌ writing customer data to the platform registry DB
}
Right: use *DBSession.getSession() and let the tenant routing happen
automatically. PlatformDbConfig is only for the four classes listed in
§9 of the architecture doc.
Mistake 6: calling RequestContext.set(...) directly¶
Wrong:
RequestContext.set(new RequestContext("system", "system", null, null, tenantId));
try {
doWork();
} finally {
RequestContext.clear();
}
This works, but it bypasses TenantScope's save/restore semantics — if
the current thread already had a context, you've trashed it.
Right:
The only file in the production codebase that's allowed to call
RequestContext.set() directly is AuthenticationFilter, because it is
the entry point and has no "previous" context to preserve.
11. Self-audit checklist¶
Before you push a change that touches any of: a plugin's
initializePlugin(), a scheduled task, a Hazelcast listener, a singleton
cache, or anything that calls *DBSession.getSession(), run these
greps locally and read every hit:
# 1. Every place you opened a session — are you inside a TenantScope or
# a Jersey request handler?
git grep -n 'DBSession\.getSession()' -- '*.java'
# 2. Every place that builds a RequestContext or sets it directly —
# should only be AuthenticationFilter (production) or test fixtures.
git grep -n 'RequestContext\.set\b\|new RequestContext(' -- '*.java'
# 3. Every scheduled task or runnable — does the body do the fan-out?
git grep -n 'scheduleAtFixedRate\|scheduleWithFixedDelay' -- '*.java'
# 4. Every singleton-style cache or facade — does its constructor or
# instance() method touch the per-tenant DB? If yes, who wraps it?
git grep -nE 'static.*instance\(\)|getInstance\(\)' -- '*.java' \
| xargs -I{} echo {} | grep -i 'cache\|manager'
If any of these hits represent a path that isn't covered by Patterns
A–G or §9, you've either found a bug or you're about to write one — stop
and double-check against
multitenant-architecture.md.
12. Quick reference card¶
| You are writing… | Pattern | Wraps in TenantScope? |
|---|---|---|
A Jersey @POST/@GET method |
A | No (request flow) |
| A facade called from a Jersey API | B | No (inherits request flow) |
A Runnable for scheduleAtFixedRate |
C | Yes, fan out per tenant |
A Hazelcast MessageListener |
D | Yes, with tenantId from payload |
A plugin initializePlugin() that loads per-tenant caches |
E | Yes, fan out per tenant |
The constructor of a legacy singleton (MyCacheManager) |
F | Yes, on the call site of instance() |
| A JUnit test that calls a facade | G | Yes, in test body |
Reading from the tqplatform.tenant table |
§9 | No — different DB, different API |
| Anything else that obtains a DB connection | — | Don't. Re-read this guide. |