Skip to content

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.md explains 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 active RequestContext. Jersey requests give you one for free; anywhere else you must establish one with TenantScope.run(tenantId, ...). Violating this throws IllegalStateException on the first DB call.

If you remember nothing else, remember that.


Table of contents

  1. Decide which flow you're in
  2. Pattern A — Jersey API endpoint
  3. Pattern B — Facade called from an API
  4. Pattern C — Scheduled background task
  5. Pattern D — Hazelcast topic listener
  6. Pattern E — Plugin initializePlugin()
  7. Pattern F — Singleton with eager DB-loading constructor
  8. Pattern G — Unit / integration test
  9. Platform-DB access (the only other path)
  10. Common mistakes — wrong vs. right
  11. 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:

if (tenants.isEmpty()) {
    logger.fine("No active tenants — skipping MyRefresh tick.");
    return;
}

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:

TenantScope.run(tenantId, this::doWork);

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.