Multi-Tenancy Architecture

1. Overview

The solution requires the ability to manage multiple organisations or clubs using the same software. To achieve that, a technique called multi-tenancy is used to logically separate the data, such as users, events and memberships, between these organisations.

Multi-tenancy enables:

  • Data Isolation - Complete separation of data between organizations

  • Custom Branding - Tenant-specific UI customization per domain

  • Scalability - Single application instance serving multiple organizations

  • Cost Efficiency - Shared infrastructure with logical separation

2. High Level Design

At the heart of the design is the Organisation entity which resides in the Admin Service. Each organisation or club is associated with an instance of this entity.

The Gateway applications, such as the Participant Portal, Membership Portal, Registration Portal, or Admin Portal, can each define separate Tenants, which are Gateway-specific URLs to serve that application, and are then linked to an Organisation. It is then possible to have members.myclub.com as a Tenant of the Membership Portal, which applies tenant-specific UI customization, but also limits any data to those of the Organisation.

3. Architecture Components

3.1. Organisation

An Organisation is a global entity and is associated with all of the high-level business entities. The Organisation entity resides in the Admin Service and represents a single customer organization (e.g., a running club, charity organization, etc.).

Key Characteristics:

  • Global Entity - Exists in the Admin Service (event database)

  • Business Context - Represents a real-world organization or club

  • Data Scope - All organizational data is scoped to Organisation.id

  • Immutable Reference - Organisation ID used throughout the system for data isolation

3.2. Tenant

A Tenant entity represents a unique Gateway domain (DNS) name for a Gateway application. Each Tenant is linked to a specific Organisation, which then enables that Gateway application operating at that domain to only display data associated with that Organisation.

Key Characteristics:

  • Gateway-Specific - Each gateway application has its own Tenant entities

  • Domain-Based - Typically resolved via URL subdomain

  • UI Customization - Enables tenant-specific branding and configuration

  • Organisation Reference - Links to Organisation either directly or via RegistrationSystem

Example:

A domain name like members.myclub.co.za could allow the administration of members for a specific club, even though the Gateway application and underlying back-end services host the data of multiple clubs.

3.3. Registration Portal Indirection

The Registration Portal uses indirection, where the Tenant is linked to a RegistrationSystem in the Admin Service, which in turn is linked to an Organisation. This design maintains proper microservice boundaries.

Relationship Chain:

Tenant (Gateway DB) → RegistrationSystem (Event DB) → Organisation (Event DB)

Benefits:

  • Microservice Boundaries - Gateway doesn’t directly reference Organisation

  • Server-Side Resolution - Organisation context determined by Admin Service

  • Authentication Context - RegistrationSystem ID passed during authentication

  • Proper Encapsulation - Gateway remains decoupled from business domain

tenant-registration-system-relationship

4. Tenant Determination

4.1. Methods

Gateway applications determine the tenant using the following methods:

Method Source Example Priority

URL Subdomain

DNS-based

https://runningclub.example.com/register

1 (Highest)

X-TENANT-ID Header

HTTP header

X-TENANT-ID: 2

2

4.2. Tenant Resolution Flow

tenant-resolution-flow

4.3. URL Examples

Subdomain-based (Production):

https://runningclub.myapp.com/membership/register/42
→ Tenant: "runningclub" (resolved via subdomain lookup)
→ Backend resolves RegistrationSystem → Organisation

Header-based (API/Programmatic Access):

GET https://myapp.com/membership/register/42
X-TENANT-ID: 2 (runningclub)
→ Tenant: 2 - Running Club (from X-TENANT-ID header)
→ Backend resolves RegistrationSystem → Organisation

5. Organisational Entities

5.1. Primary Organisational Entities

Some entities in the solution are considered primary organisational entities and are all directly linked to an Organisation. These include:

  • API Key - Authentication credentials for external systems

  • Event - Event definitions and configurations

  • Membership Type - Membership categories and rules

  • Attachment - File attachments and documents

  • Number Type - Bib number configurations

  • Tag Type - Tagging and categorization systems

  • RegistrationSystem - External registration system integrations

Characteristics:

  • Direct foreign key to Organisation.id

  • Independently queryable by organisation

  • Form the root of data isolation boundaries

  • Must be filtered by organisation in all queries

5.2. Secondary Organisational Entities

Entities such as Event Participant, Event Category, Event Race Type, Race, Program Entry, Membership Period, Membership Criteria, etc. are all secondary entities, because they are associated with at least one primary organisational entity.

Resolution Strategy:

It is possible to determine if a secondary organisational entity can be served to the user by resolving the primary entity, looking up the organisation, and determining if the user is authorized to access the entities of that organisation.

Multiple Primary References:

Some entities might even have multiple primary organisational entities, sometimes indirectly via an extended graph. In these situations, the entities must be equipped with validation logic to ensure that only entities of the same organisation can be associated together.

Example:

// EventParticipant is secondary (via Event)
@Entity
public class EventParticipant {
    @ManyToOne(optional = false)
    private Event event;  // Event → Organisation

    // Event.organisation determines organisation scope
}

// Race is secondary (via Event)
@Entity
public class Race {
    @ManyToOne(optional = false)
    private Event event;  // Event → Organisation
}

// ProgramEntry has validation to ensure consistency
@Entity
public class ProgramEntry {
    @ManyToOne(optional = false)
    private Person person;

    @ManyToOne(optional = false)
    private Race race;  // Race → Event → Organisation

    @PrePersist
    @PreUpdate
    private void validateOrganisation() {
        // Ensure person and race belong to same organisation
    }
}

6. Spring Boot Implementation

6.1. Overview

The Spring Boot Gateway applications implement tenant resolution using interceptors and filters that extract the tenant context from the request and store it in a ThreadLocal for the duration of the request.

6.2. Tenant Context

TenantContext Class:

package za.co.idealogic.common.tenant;

/**
 * ThreadLocal storage for the current tenant context.
 * Must be cleared after each request to prevent thread pool contamination.
 */
public class TenantContext {
    private static ThreadLocal<String> currentTenant = new ThreadLocal<>();
    private static ThreadLocal<Long> currentRegistrationSystemId = new ThreadLocal<>();

    /**
     * Set the current tenant identifier (e.g., subdomain or tenant ID)
     */
    public static void setCurrentTenant(String tenant) {
        currentTenant.set(tenant);
    }

    /**
     * Get the current tenant identifier
     */
    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    /**
     * Set the registration system ID for the current tenant
     */
    public static void setCurrentRegistrationSystemId(Long registrationSystemId) {
        currentRegistrationSystemId.set(registrationSystemId);
    }

    /**
     * Get the registration system ID for the current tenant
     */
    public static Long getCurrentRegistrationSystemId() {
        return currentRegistrationSystemId.get();
    }

    /**
     * Clear all tenant context. MUST be called after each request.
     */
    public static void clear() {
        currentTenant.remove();
        currentRegistrationSystemId.remove();
    }
}

6.3. Tenant Resolution Filter

TenantResolutionFilter:

package za.co.idealogic.gateway.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import za.co.idealogic.common.tenant.TenantContext;
import za.co.idealogic.gateway.domain.Tenant;
import za.co.idealogic.gateway.repository.TenantRepository;

import java.io.IOException;
import java.util.Optional;

/**
 * Servlet filter that resolves the tenant from the request and stores it in TenantContext.
 * Executes early in the filter chain (Order 1).
 */
@Component
@Order(1)
public class TenantResolutionFilter implements Filter {

    private final TenantRepository tenantRepository;

    public TenantResolutionFilter(TenantRepository tenantRepository) {
        this.tenantRepository = tenantRepository;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        try {
            HttpServletRequest httpRequest = (HttpServletRequest) request;

            // 1. Try to resolve tenant from URL subdomain
            String tenantId = extractTenantFromSubdomain(httpRequest);

            // 2. If not found, try X-TENANT-ID header
            if (tenantId == null) {
                tenantId = httpRequest.getHeader("X-TENANT-ID");
            }

            // 3. Lookup tenant entity
            if (tenantId != null) {
                Optional<Tenant> tenantOpt = tenantRepository.findByDomain(tenantId);

                if (tenantOpt.isPresent()) {
                    Tenant tenant = tenantOpt.get();
                    TenantContext.setCurrentTenant(tenant.getDomain());
                    TenantContext.setCurrentRegistrationSystemId(tenant.getRegistrationSystemId());
                } else {
                    // Log warning: tenant not found
                }
            }

            // Continue filter chain
            chain.doFilter(request, response);

        } finally {
            // CRITICAL: Always clear context after request
            TenantContext.clear();
        }
    }

    /**
     * Extract tenant identifier from subdomain
     * E.g., "runningclub.example.com" → "runningclub"
     */
    private String extractTenantFromSubdomain(HttpServletRequest request) {
        String serverName = request.getServerName();

        // Skip if localhost or IP address
        if (serverName.equals("localhost") || serverName.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) {
            return null;
        }

        // Extract subdomain (first part before first dot)
        String[] parts = serverName.split("\\.");
        if (parts.length > 2) {
            return parts[0]; // Return subdomain
        }

        return null;
    }
}

6.4. Tenant Repository

TenantRepository Interface:

package za.co.idealogic.gateway.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import za.co.idealogic.gateway.domain.Tenant;

import java.util.Optional;

@Repository
public interface TenantRepository extends JpaRepository<Tenant, Long> {

    /**
     * Find tenant by domain name (subdomain or full domain)
     */
    Optional<Tenant> findByDomain(String domain);

    /**
     * Find tenant by registration system ID
     */
    Optional<Tenant> findByRegistrationSystemId(Long registrationSystemId);
}

6.5. Tenant Entity

Tenant Entity (Gateway Database):

package za.co.idealogic.gateway.domain;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;

/**
 * Tenant entity representing a unique domain for this Gateway application.
 * Links to a RegistrationSystem in the Admin Service.
 */
@Entity
@Table(name = "tenant")
public class Tenant {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    @Column(name = "name", nullable = false)
    private String name;

    @NotNull
    @Column(name = "domain", nullable = false, unique = true)
    private String domain;

    @NotNull
    @Column(name = "registration_system_id", nullable = false)
    private Long registrationSystemId; // References RegistrationSystem.id in Admin Service

    // OAuth2/OIDC configuration (optional)
    @Column(name = "issuer_url")
    private String issuerURL;

    @Column(name = "client_id")
    private String clientId;

    @Column(name = "client_secret")
    private String clientSecret;

    // Getters and setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDomain() {
        return domain;
    }

    public void setDomain(String domain) {
        this.domain = domain;
    }

    public Long getRegistrationSystemId() {
        return registrationSystemId;
    }

    public void setRegistrationSystemId(Long registrationSystemId) {
        this.registrationSystemId = registrationSystemId;
    }

    public String getIssuerURL() {
        return issuerURL;
    }

    public void setIssuerURL(String issuerURL) {
        this.issuerURL = issuerURL;
    }

    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public String getClientSecret() {
        return clientSecret;
    }

    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }
}

6.6. Usage in Services

Accessing Tenant Context:

package za.co.idealogic.gateway.service;

import org.springframework.stereotype.Service;
import za.co.idealogic.common.tenant.TenantContext;

@Service
public class AuthenticationService {

    /**
     * Authenticate user with Admin Service
     */
    public JwtToken authenticateUser(String userId, String userHash) {
        // Get current tenant's registration system ID
        Long registrationSystemId = TenantContext.getCurrentRegistrationSystemId();

        if (registrationSystemId == null) {
            throw new IllegalStateException("No tenant context available");
        }

        // Call Admin Service to mint JWT
        // Pass registrationSystemId for organisation resolution
        return adminServiceClient.authenticate(userId, userHash, registrationSystemId);
    }
}

6.7. Configuration

Application Properties:

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/gateway_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

# Tenant resolution settings
tenant:
  resolution:
    enabled: true
    subdomain-extraction: true
    header-name: X-TENANT-ID

7. Multi-Tenant Data Isolation

7.1. Backend Enforcement

All queries to the Admin Service must be scoped to the Organisation ID contained in the JWT token:

@Service
public class EventService {

    @PreAuthorize("hasPermission(#organisationId, 'Organisation', 'READ')")
    public List<Event> getEvents(Long organisationId) {
        // Query automatically scoped to organisationId
        return eventRepository.findByOrganisationId(organisationId);
    }
}

7.2. Security Rules

  • Users can only access data within their organisation

  • Organisation ID validated against user permissions (from JWT)

  • Cross-organisation queries blocked

  • Audit logging for access attempts

7.3. JWT Token Structure

The JWT token minted by the Admin Service contains:

{
  "sub": "12345",
  "orgId": 8,
  "personId": 456,
  "accountId": 789,
  "linkedPersonIds": [456, 457, 458],
  "authorities": ["ROLE_USER", "ROLE_MEMBER"],
  "exp": 1735123456,
  "iat": 1735120456
}

Claims:

  • orgId - Organisation ID from RegistrationSystem.organisation

  • personId - Person.id for the authenticated user

  • accountId - OrgUser.id (account ID)

  • linkedPersonIds - Array of linked Person IDs

  • authorities - User roles and permissions

8. Authentication Flow with Tenant Context

tenant-auth-flow

9. Best Practices

9.1. ThreadLocal Management

CRITICAL: Always clear TenantContext after each request to prevent thread pool contamination:

try {
    // Process request
    chain.doFilter(request, response);
} finally {
    TenantContext.clear(); // MUST be in finally block
}

9.2. Tenant Validation

Validate that the tenant exists before processing the request:

if (tenantId != null) {
    Optional<Tenant> tenantOpt = tenantRepository.findByDomain(tenantId);

    if (tenantOpt.isEmpty()) {
        response.setStatus(HttpServletResponse.SC_NOT_FOUND);
        response.getWriter().write("Tenant not found");
        return; // Stop processing
    }
}

9.3. Fallback Handling

For development/testing environments, consider a default tenant:

if (tenantId == null && isDevelopmentEnvironment()) {
    tenantId = applicationProperties.getDefaultTenant();
}

9.4. Logging

Log tenant resolution for debugging and audit purposes:

logger.info("Resolved tenant: {} (registrationSystemId: {})",
    tenant.getDomain(),
    tenant.getRegistrationSystemId());

10. Security Considerations

10.1. Data Isolation

  • Tenant context determines data scope

  • JWT token provides organisation-level security

  • Backend enforces organisation-scoped queries

  • Cross-tenant data access prevented at multiple layers

10.2. Tenant Spoofing Prevention

  • Tenant resolution happens server-side

  • X-TENANT-ID header validated against database

  • Subdomain extraction uses server name (not client-provided)

  • JWT minting includes organisation validation

10.3. Session Security

  • JWT tokens stored in HTTP session (server-side)

  • Never exposed to frontend

  • Session tied to tenant context

  • Cross-tenant session reuse prevented