Tenant Context

Once a tenant is resolved, the tenant context is propagated through the request lifecycle using Java’s ScopedValue API. The tenant identifier is bound for a specific scope and automatically unbound when the scope exits, making it impossible to leak tenant context across requests.

TenantContext

The TenantContext provides static methods to access the current tenant identifier from anywhere in your code. A binding cannot be changed from inside its scope, only shadowed by a nested one. The tenant context is a per-scope binding, not a tenant store: code that leaves the scope does not see it.

import io.arconia.multitenancy.core.context.TenantContext;

// Get the current tenant (may be null)
String tenantId = TenantContext.getTenantIdentifier();

// Get the current tenant (throws TenantNotFoundException if not bound)
String tenantId = TenantContext.getRequiredTenantIdentifier();

In web applications, the TenantContext is automatically bound for each HTTP request by the TenantContextFilter. You do not need to manage it manually.

Manual Context Management

For non-web scenarios, such as background jobs and message consumers, you can bind the tenant context using TenantContext.where(). The identifier is trusted as it is, apart from being rejected with an IllegalArgumentException when null or empty, so validate and verify an identifier taken from untrusted input, such as a message header, before binding it. See Trust Boundaries.

TenantContext.where("acme").run(() -> {
    // Business logic with tenant context
});
// Tenant context is automatically unbound here

Nested scopes can rebind to a different tenant. The outer binding is restored when the inner scope exits:

TenantContext.where("acme").run(() -> {
    // TenantContext.getTenantIdentifier() returns "acme"
    TenantContext.where("beans").run(() -> {
        // TenantContext.getTenantIdentifier() returns "beans"
    });
    // TenantContext.getTenantIdentifier() returns "acme" again
});

Context Propagation Across Threads

A ScopedValue binding belongs to the thread that established it. It is not inherited by threads started from within the scope, so Arconia propagates it to the tasks run by the executors and schedulers that Spring Boot auto-configures. It registers a TenantContextPropagatingTaskDecorator bean, and Spring Boot applies every TaskDecorator bean to those executors and schedulers. The tenant bound when a task is submitted is bound again on the thread running it. When MDC logging is enabled, the tenant identifier is also put in the MDC for the duration of the task, so it appears in the logs of that thread. This covers:

  • methods annotated with @Async

  • tasks submitted to the auto-configured applicationTaskExecutor or taskScheduler

  • the body of a Callable returned by a Spring MVC controller method, which runs on the applicationTaskExecutor

  • executors built from the auto-configured ThreadPoolTaskExecutorBuilder or SimpleAsyncTaskExecutorBuilder

A task submitted with no tenant bound runs unchanged, so methods annotated with @Scheduled, which run outside any request, still have no tenant. The decorator composes with any other TaskDecorator bean, such as the one Spring Boot registers when spring.task.execution.propagate-context is enabled.

The tenant context is not visible in threads Arconia cannot reach: an executor you create yourself, CompletableFuture stages that run on the common pool, or a plain Thread. There, TenantContext.getTenantIdentifier() returns null and getRequiredTenantIdentifier() throws a TenantNotFoundException. Apply the decorator to your own executor with setTaskDecorator(…​), or rebind the tenant explicitly on the other thread:

String tenantId = TenantContext.getRequiredTenantIdentifier();

executor.submit(() -> TenantContext.where(tenantId).run(() -> {
    // Business logic with tenant context
}));

The one case that inherits the binding without any extra work is a StructuredTaskScope fork, since its threads are children of the scope that owns the binding:

TenantContext.where("acme").run(() -> {
    try (var scope = StructuredTaskScope.open()) {
        // TenantContext.getTenantIdentifier() returns "acme" inside the fork
        var task = scope.fork(() -> loadProducts());
        scope.join();
    }
});

StructuredTaskScope is a preview API in Java 25 and is disabled by default. Compiling and running the example above requires --enable-preview.

Configuration Properties

Table 1. Tenant Context Configuration Properties
Property Default Description

arconia.multitenancy.context.propagation.enabled

true

Whether the tenant context is propagated to the tasks run by the executors and schedulers auto-configured by Spring Boot.

Tenant Context Events

Spring application events are published at key points in the tenant context lifecycle, allowing you to react to tenant context changes with custom behavior. They are published by the entry point that establishes the tenant context, such as the TenantContextFilter for an HTTP request.

TenantContextAttachedEvent

Published when a tenant context is established. This event carries the tenant identifier. For example, the built-in MdcTenantEventListener listens for this event to add the tenant to the SLF4J MDC, enabling tenant information in logs (see Observability).

TenantContextClosedEvent

Published when a tenant context ends. For example, the built-in MdcTenantEventListener listens for this event to clear the tenant from the SLF4J MDC, preventing tenant information from leaking into subsequent requests (see Observability).

Custom Event Listeners

You can register your own listeners to react to tenant context events:

import org.springframework.context.event.EventListener;

import io.arconia.multitenancy.core.context.TenantContextAttachedEvent;
import io.arconia.multitenancy.core.context.TenantContextClosedEvent;

@Component
class CustomTenantListener {

    @EventListener
    void onTenantAttached(TenantContextAttachedEvent event) {
        String tenantId = event.getTenantIdentifier();
        // Custom logic when tenant context starts
    }

    @EventListener
    void onTenantClosed(TenantContextClosedEvent event) {
        String tenantId = event.getTenantIdentifier();
        // Custom logic when tenant context ends
    }

}