Supporting multi-tenancy is often a crucial step for scaling your application and expanding its reach to a broader customer base. However, building this capability introduces a layer of complexity to your application, particularly when working within a microservice architecture. In this article, we’ll explore ways to manage this complexity effectively and discuss practical approaches to implementing multi-tenancy with ease.

What Is a Multi-Tenancy

Multi-tenancy is the capability of a system or application to support multiple tenants (such as customers or organizations) within a single running instance while maintaining logical isolation. This approach optimizes resource utilization, reducing infrastructure and maintenance costs by sharing hardware, software, and operational overhead.

A well-implemented multi-tenancy solution should be seamless for tenants—they shouldn’t notice that they are sharing the instance with others. Tenant isolation can be achieved at different levels, such as the database, authentication, or application code itself.

Since multi-tenancy introduces additional complexity, the architectural decisions made during implementation significantly impact scalability, security, and future costs. In this article, we will explore how to handle tenants across different layers of an application. We will specifically focus on isolation at the application layer and briefly discuss approaches for data separation.

Managing a Tenant Context

When implementing tenant isolation at the application level, you will quickly realize that you need to have a tenant identifier available in different parts of your application code. We will refer to this identifier as tenant-id.

Passing this ID across multiple layers of an application can be cumbersome, adding unnecessary complexity and making code maintenance and testability significantly more challenging. If every interaction with the application logic requires the tenant-id, we can introduce the concept of a tenant context, which centrally holds this information.

Since Java is a multi-threaded language, we can leverage thread-local storage to manage the tenant context efficiently. This approach allows us to associate the tenant-id with the current thread, making it accessible throughout the application's execution flow. However, it's important to note that this method does not work in reactive frameworks, as they handle concurrency differently from traditional thread-based processing.

Using ThreadLocal to Keep the Context

This simple class will be the core of our multi-tenancy implementation. It contains only three methods and very little logic, but it is powerful and provides incredible flexibility.

public class TenantContext {
    private static final ThreadLocal<String> tenantIdHolder = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        if (tenantId == null) {
            throw new TenantContextException("Tenant ID cannot be null");
        }
        tenantIdHolder.set(tenantId);
        MDC.put("tenantId", tenantId);
    }

    public static String getTenantId() {
        String tenantId = tenantIdHolder.get();
        if (tenantId == null) {
            throw new TenantContextException("Tenant ID is not set in the current thread");
        }
        return tenantId;
    }

    public static void clear() {
        tenantIdHolder.remove();
        MDC.remove("tenantId"); 
    }
}

Whenever a request arrives at the application, we are expected to set the TenantContext using the setter, which stores the tenant-id in a ThreadLocal. This ensures that whenever we call getTenantId() within the same thread where the context was set, we reliably obtain the correct tenant-id.

The clear method is also very important. Since threads in Java can be reused (for example, in thread pools), we must clear the context once processing is complete to prevent unintended data leakage between requests. This cleanup is typically performed in an interceptor after a REST call or when Kafka message processing ends.

Additionally, you can see that we put the tenant ID into the MDC, which allows us to clearly associate logs with a particular tenant.

Passing a Tenant Context between Microservices

In a microservice architecture, requests often travel across multiple services, and it's crucial to pass tenant information between them. Ensuring proper tenant context propagation is essential for maintaining data isolation and security.

For simplicity, we'll focus on two of the most common communication methods:

  • Synchronous calls via REST API
  • Asynchronous calls via Kafka

Header vs. Body: Where to Pass the Tenant Id?

Regardless of the communication channel, one key decision is where to include the tenant-id — in the header or the body of the request? Both approaches have advantages and drawbacks. Let’s compare them:

Passing the Tenant ID in the Header
✅ Pros:

  • Standardized: HTTP headers are commonly used for metadata (e.g., authentication tokens).
  • Cleaner request bodies: The payload remains focused on business data.
  • Abstracted from business logic: Tenant ID handling can be managed at the infrastructure level (e.g., middleware, filters), reducing code duplication.

❌ Cons:

  • Risk of header loss: In multi-service calls, headers might be unintentionally stripped or modified, making tenant context propagation unreliable.
  • Requires explicit validation: You need additional checks to ensure the tenant ID is always present in each request.

Passing the Tenant ID in the Body
✅ Pros:

  • Self-contained: The request body holds all necessary context, making it easier to debug.

❌ Cons:

  • Requires modifying the data model for each service to include tenant information.
  • GET request limitation: Since request bodies are not supported in GET requests, the tenant ID must be passed as a query parameter instead.

Let's look at how to easily abstract the tenant context from the rest of the business logic when using header propagation of the tenant id.

REST

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class TenantInterceptor implements HandlerInterceptor {
    private static final String TENANT_HEADER = "X-Tenant-ID";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String tenantId = request.getHeader(TENANT_HEADER);
        if (tenantId != null) {
            TenantContext.setTenantId(tenantId);
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        TenantContext.clear(); // Clean up after request processing
    }
}

After registering the interceptor, we no longer need to explicitly retrieve the tenant id or pass it between calls. It will be automatically set in the TenantContext and work seamlessly.

To also automate the propagation of the tenant-id in outgoing REST calls, we can provide another interceptor and register it with RestTemplate. This ensures successful tenant context propagation across multiple services without requiring any handling in the business logic.

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class TenantPropagationInterceptor implements ClientHttpRequestInterceptor {
    private static final String TENANT_HEADER = "X-Tenant-ID";

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, 
                                        ClientHttpRequestExecution execution) throws IOException {
        String tenantId = TenantContext.getTenantId();
        if (tenantId != null) {
            request.getHeaders().add(TENANT_HEADER, tenantId);
        }
        return execution.execute(request, body);
    }
}

Registering the interceptor.

import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate(TenantPropagationInterceptor tenantInterceptor) {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setInterceptors(List.of(tenantInterceptor));
        return restTemplate;
    }
}

Kafka

For Kafka, we can take a very similar approach by defining an interceptor that reads the message header and passes the value into the tenant context.

import org.apache.kafka.clients.consumer.ConsumerInterceptor;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import java.util.Map;

public class TenantConsumerInterceptor implements ConsumerInterceptor<String, String> {
    private static final String TENANT_HEADER = "X-Tenant-ID";

    @Override
    public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
        for (ConsumerRecord<String, String> record : records) {
            if (record.headers().lastHeader(TENANT_HEADER) != null) {
                String tenantId = new String(record.headers().lastHeader(TENANT_HEADER).value());
                TenantContext.setTenantId(tenantId);
            }
        }
        return records;
    }

    @Override
    public void onCommit(Map offsets) {
        // No action needed on commit
    }

    @Override
    public void close() {
        // Cleanup if needed
    }

    @Override
    public void configure(Map<String, ?> configs) {
        // No configuration needed
    }
}

We will also provide an interceptor that writes the tenant-id into the header of outgoing messages.

import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import java.util.Map;

public class TenantProducerInterceptor implements ProducerInterceptor<String, String> {
    private static final String TENANT_HEADER = "X-Tenant-ID";

    @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        String tenantId = TenantContext.getTenantId();
        if (tenantId != null) {
            record.headers().add(TENANT_HEADER, tenantId.getBytes());
        }
        return record;
    }

    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        // No action needed on acknowledgment
    }

    @Override
    public void close() {
        // Cleanup if needed
    }

    @Override
    public void configure(Map<String, ?> configs) {
        // No configuration needed
    }
}

Passing Tenant-Id from Ingress

Tenants can have customized URLs specific to them. Our goal is to find an easy way to bind the URL of the tenant application to the tenant-id. When running in Kubernetes, we can use the Ingress definition and set the header in the Nginx configuration.

The following configuration sets the tenant ID in the header while also hiding any incoming tenant-id headers from the request. Why do this?

We want to ensure that clients cannot inject unexpected tenant-id, ensuring that only our system assigns and propagates the correct tenant-id.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_set_header X-Tenant-Id "tenant123";
      proxy_hide_header X-Tenant-Id;

Splitting DB

Splitting the database layer is the next logical step in building a secure multi-tenant architecture. We will not dive into the implementation details now; instead, we will explore how separation can be designed using the previously implemented components.

By customizing the abstraction of our database layer—responsible for selecting the database connection and executing queries—we gain the ability to adapt this behavior and choose the appropriate database based on the current tenant context. This further separates multi-tenancy from the business logic, allowing the application to stay focused on business functionality rather than data separation.

Summary

Multi-tenancy shouldn’t be something to fear. Following basic principles will open the door to better scaling of your applications and allow you to focus on business logic rather than the technical overhead of passing context information.

The following diagram summarizes the article and illustrates the complete architecture that has been discussed.

Let me know in the comments, what would you improve or what would you do differently?

Author Of article : Martin Simon Read full article