A tenant isolation framework ensures secure multi-tenant environments by providing isolated resource access, preventing data leakage, and maintaining strict boundaries between tenants in shared infrastructure.
1. Problem Statement & Requirements
Clarifying questions
- What scale expectations are we targeting? Are we designing for hundreds of tenants or tens of thousands?
- What consistency requirements does each tenant need? Do they require strong consistency or eventual consistency?
- What are the key entities and their relationships? Is this a multi-tenant SaaS platform or a shared infrastructure?
- What's the read/write ratio? Is this primarily write-heavy (e.g., real-time analytics) or read-heavy (e.g., content delivery)?
- What latency sensitivity do we have? Are sub-100ms SLAs required for all operations or can some be batched?
- What authentication model is in place? Is it OAuth2, JWT, or API keys with tenant context?
- Are there any external integrations or data sources we must consider? Do tenants have external dependencies (e.g., SSO, third-party APIs)?
Functional requirements
- Tenant creation and lifecycle management - Support for creating, updating, and deleting tenants with proper validation.
- Isolated data access - Ensure data of one tenant is not accessible by another tenant.
- Tenant context propagation - All operations must be associated with a specific tenant context.
- Multi-tenancy-aware queries - All database queries must filter by tenant ID to ensure data isolation.
- Tenant-specific configuration and settings - Allow tenants to have custom configurations without affecting others.
Non-functional requirements
- Latency targets:
- p50: <50ms for read operations
- p95: <100ms for read operations
- p99: <200ms for read operations
- Throughput: Support 10,000 concurrent requests across all tenants with no single tenant causing bottlenecks.
- Availability: 99.9% uptime (5-minute SLA) with automatic failover capability.
- Scalability: Support horizontal scaling across multiple database shards or instances.
Constraints and assumptions
- Assumption: Tenant IDs are globally unique and stable across the system.
- Constraint: No cross-tenant data sharing is allowed under any circumstances.
- Assumption: All tenant data will be stored in a single database or database cluster for simplicity.
- Constraint: Tenant context must be provided with every request (e.g., via header or JWT claim).
- Assumption: The system will not support multi-tenancy across different regions or data centers without additional infrastructure.
Out of scope
- Tenant migration - Moving tenant data between databases or clusters.
- Cross-tenant analytics or reporting - Aggregating or analyzing data across tenants.
- Tenant-to-tenant communication - Direct interaction between tenants.
- Multi-region or cross-cloud deployments - Isolation is only within a single data center or cloud region.
- Tenant-specific SLAs - All tenants are treated equally in terms of performance and availability.
2. API Design
API Style Selection
gRPC with Protocol Buffers is selected for the Tenant Isolation Framework due to:
- Strong typing and schema validation at compile time
- Efficient binary serialization for high-throughput scenarios
- Native support for streaming and bidirectional communication
- Built-in tooling for code generation across multiple languages
- Better performance characteristics than REST for microservices communication
Endpoint Definitions
| Method | Path | Description | Auth | Rate Limit |
|---|---|---|---|---|
| CreateTenant | /tenant.v1.TenantService/CreateTenant | Creates a new tenant with configuration | JWT + Admin Role | 10 req/min |
| GetTenant | /tenant.v1.TenantService/GetTenant | Retrieves tenant details by ID | JWT + Tenant Access | 100 req/min |
| UpdateTenant | /tenant.v1.TenantService/UpdateTenant | Updates tenant configuration | JWT + Admin Role | 5 req/min |
| DeleteTenant | /tenant.v1.TenantService/DeleteTenant | Soft deletes tenant and marks for archival | JWT + Admin Role | 2 req/min |
| ListTenants | /tenant.v1.TenantService/ListTenants | Lists tenants with pagination | JWT + Admin Role | 20 req/min |
| GetTenantStatus | /tenant.v1.TenantService/GetTenantStatus | Gets tenant operational status | JWT + Tenant Access | 50 req/min |
Request and Response Schemas
CreateTenantRequest
message CreateTenantRequest {
string name = 1;
string description = 2;
map<string, string> metadata = 3;
TenantConfig config = 4;
}
message TenantConfig {
int32 max_users = 1;
int32 max_apps = 2;
string storage_quota_gb = 3;
repeated string allowed_regions = 4;
}
CreateTenantResponse
message CreateTenantResponse {
string tenant_id = 1;
string name = 2;
string status = 3; // ACTIVE, PENDING, SUSPENDED
int64 created_at = 4;
}
GetTenantRequest
message GetTenantRequest {
string tenant_id = 1;
}
GetTenantResponse
message GetTenantResponse {
string tenant_id = 1;
string name = 2;
string description = 3;
map<string, string> metadata = 4;
TenantConfig config = 5;
string status = 6;
int64 created_at = 7;
int64 updated_at = 8;
}
HTTP Status Codes and Error Response Format
Standard gRPC error codes are used:
OK(0) - SuccessINVALID_ARGUMENT(3) - Malformed requestUNAUTHENTICATED(16) - Invalid authentication tokenPERMISSION_DENIED(7) - Insufficient permissionsNOT_FOUND(5) - Tenant not foundALREADY_EXISTS(6) - Tenant already existsRESOURCE_EXHAUSTED(8) - Rate limit exceeded
Standardized error response:
message ErrorDetail {
string code = 1;
string message = 2;
string details = 3;
repeated string field_violations = 4;
}
API Versioning Strategy
Semantic versioning with gRPC service names:
tenant.v1.TenantService- Current stable version- Future:
tenant.v2.TenantServicefor breaking changes
Pagination Design
Cursor-based pagination is used because:
- More efficient than offset-based for large datasets
- Consistent results even when data changes during pagination
- Better performance characteristics with large offsets
message Pagination {
string cursor = 1;
int32 page_size = 2; // max 1000
}
Authentication and Authorization
Authentication:
- JWT tokens with audience claim matching service name
- Token validation via OAuth2 introspection endpoint
Authorization:
- Admin role required for tenant creation/update/deletion
- Tenant access token required for tenant-specific operations
- RBAC model with role-based permissions:
TENANT_ADMIN- Full tenant controlTENANT_USER- Read-only access to own tenant data
Rate Limiting Per Endpoint
| Endpoint | Limit | Enforcement |
|---|---|---|
| CreateTenant | 10/min | In-memory counter with Redis fallback |
| GetTenant | 100/min | Token bucket algorithm |
| UpdateTenant | 5/min | Fixed window with sliding counter |
| DeleteTenant | 2/min | Rate limiter with burst support |
| ListTenants | 20/min | Distributed rate limiting via Redis |
Idempotency Design
Idempotency key strategy:
X-Idempotency-Keyheader in requests- For create operations, same key returns existing resource if already created
- Stored in Redis with TTL of 24 hours
// Example idempotency check logic in gRPC interceptor
public class IdempotencyInterceptor implements ServerInterceptor {
private final RedisTemplate<String, String> redisTemplate;
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String idempotencyKey = headers.get(Metadata.Key.of("X-Idempotency-Key",
Metadata.ASCII_STRING_MARSHALLER));
if (idempotencyKey != null) {
String existingResponse = redisTemplate.opsForValue().get(
"idempotency:" + idempotencyKey);
if (existingResponse != null) {
// Return cached response
return new ServerCall.Listener<ReqT>() {};
}
}
return next.startCall(call, headers);
}
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
TENANT ||--o{ USER : "has"
TENANT ||--o{ ORGANIZATION : "owns"
TENANT ||--o{ APPLICATION : "manages"
USER ||--o{ USER_ROLE : "has"
USER_ROLE ||--o{ ROLE : "is"
ROLE ||--o{ PERMISSION : "grants"
ORGANIZATION ||--o{ DEPARTMENT : "has"
DEPARTMENT ||--o{ EMPLOYEE : "employs"
APPLICATION ||--o{ FEATURE_FLAG : "uses"
APPLICATION ||--o{ CONFIGURATION : "has"
APPLICATION ||--o{ EVENT_LOG : "logs"
TENANT {
string id PK
string name
string description
string status
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
USER {
string id PK
string tenant_id FK
string email
string first_name
string last_name
string status
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
ORGANIZATION {
string id PK
string tenant_id FK
string name
string description
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
DEPARTMENT {
string id PK
string organization_id FK
string name
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
EMPLOYEE {
string id PK
string department_id FK
string user_id FK
timestamp created_at
timestamp updated_at
}
APPLICATION {
string id PK
string tenant_id FK
string name
string description
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
FEATURE_FLAG {
string id PK
string application_id FK
string key
boolean enabled
timestamp created_at
timestamp updated_at
}
CONFIGURATION {
string id PK
string application_id FK
string key
string value
timestamp created_at
timestamp updated_at
}
EVENT_LOG {
string id PK
string application_id FK
string event_type
string payload
timestamp created_at
}
USER_ROLE {
string user_id FK
string role_id FK
timestamp created_at
}
ROLE {
string id PK
string name
string description
timestamp created_at
timestamp updated_at
}
PERMISSION {
string id PK
string role_id FK
string resource
string action
timestamp created_at
}
Table Schema Definitions
Tenant Table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_deleted_at ON tenants(deleted_at);
User Table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(100),
last_name VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
Organization Table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_org_name_length CHECK (LENGTH(name) > 0)
);
CREATE INDEX idx_organizations_tenant_id ON organizations(tenant_id);
CREATE INDEX idx_organizations_deleted_at ON organizations(deleted_at);
Department Table
CREATE TABLE departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_dept_name_length CHECK (LENGTH(name) > 0)
);
CREATE INDEX idx_departments_organization_id ON departments(organization_id);
CREATE INDEX idx_departments_deleted_at ON departments(deleted_at);
Employee Table
CREATE TABLE employees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uk_employee_dept_user UNIQUE (department_id, user_id)
);
CREATE INDEX idx_employees_department_id ON employees(department_id);
CREATE INDEX idx_employees_user_id ON employees(user_id);
Application Table
CREATE TABLE applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_app_name_length CHECK (LENGTH(name) > 0)
);
CREATE INDEX idx_applications_tenant_id ON applications(tenant_id);
CREATE INDEX idx_applications_deleted_at ON applications(deleted_at);
Feature Flag Table
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uk_feature_flag_app_key UNIQUE (application_id, key)
);
CREATE INDEX idx_feature_flags_application_id ON feature_flags(application_id);
Configuration Table
CREATE TABLE configurations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uk_configuration_app_key UNIQUE (application_id, key)
);
CREATE INDEX idx_configurations_application_id ON configurations(application_id);
Event Log Table
CREATE TABLE event_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_event_logs_application_id ON event_logs(application_id);
CREATE INDEX idx_event_logs_event_type ON event_logs(event_type);
CREATE INDEX idx_event_logs_created_at ON event_logs(created_at);
Role Table
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roles_name ON roles(name);
Permission Table
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
resource VARCHAR(255) NOT NULL,
action VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_permission_resource_action CHECK (LENGTH(resource) > 0 AND LENGTH(action) > 0)
);
CREATE INDEX idx_permissions_role_id ON permissions(role_id);
User Role Table
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id)
);
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
Primary Key Strategy
UUIDs are used as primary keys for all tables to ensure:
- Global uniqueness across distributed systems
- No sequential patterns that could expose system structure
- Better performance in distributed environments where auto-increment can cause contention
- Tenant isolation without requiring separate sequences per tenant
Index Design
Composite Indices
idx_users_tenant_id_email- For fast user lookups by tenant and emailidx_employees_department_user- For efficient employee-to-user lookups
Partial Indices
idx_tenants_active-WHERE status = 'active'for tenant status filteringidx_users_active-WHERE status = 'active' AND deleted_at IS NULLfor active user filtering
Covering Indices
idx_applications_tenant_active- Covers tenant and status queries for applicationsidx_feature_flags_app_enabled- Covers application and enabled flag lookups
Data Partitioning / Sharding Strategy
Tenant-based sharding is implemented:
- Shard key:
tenant_idin all tables - Partitioning strategy:
- Horizontal partitioning by tenant ID using consistent hashing or range-based sharding
- Each tenant gets its own database shard or table space in multi-tenant DBs
- Cross-tenant queries: Prohibited by design - all queries must include tenant context
Soft Delete vs. Hard Delete Decision
Soft delete is used for all entities to:
- Maintain audit trail of deleted records
- Support data recovery in case of accidental deletions
- Compliance requirements for data retention policies
- Historical analysis capabilities
Audit Trail Design
All tables include audit fields:
created_at- Timestamp when record was createdupdated_at- Timestamp when record was last modifieddeleted_at- Timestamp when record was soft-deleted (if applicable)created_by- User who created the record (not included in current design but could be added)updated_by- User who last updated the record (not included in current design but could be added)
Data Archival Strategy and Retention Policy
- Archival trigger: Records with
deleted_atolder than 90 days are archived - Archive storage: Separate schema or database for archived data with reduced indexing
- Retention policy: 365 days for compliance, 90 days for general cleanup
- Archival process: Scheduled batch job that moves records to archive tables with minimal metadata retention
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class TenantIsolationFramework {
+isolateTenant(TenantContext)
+validateTenantAccess(TenantContext, Resource)
+getActiveTenant()
}
class TenantContext {
-tenantId: String
-userId: String
-permissions: Set~String~
+getTenantId()
+getUserId()
+hasPermission(String)
}
class TenantResource {
-id: String
-tenantId: String
-createdAt: Instant
-updatedAt: Instant
}
class TenantAwareRepository {
<<Interface>>
+findByTenantId(String)
+save(TenantResource)
+deleteById(String, String)
}
class TenantAwareService {
<<Interface>>
+createResource(TenantContext, Resource)
+getResourceById(TenantContext, String)
+updateResource(TenantContext, String, Resource)
+deleteResource(TenantContext, String)
}
class DefaultTenantAwareService {
-repository: TenantAwareRepository
-eventPublisher: EventPublisher
-auditLogger: AuditLogger
+createResource(TenantContext, Resource)
+getResourceById(TenantContext, String)
+updateResource(TenantContext, String, Resource)
+deleteResource(TenantContext, String)
}
class TenantIsolationInterceptor {
-tenantValidator: TenantValidator
+preHandle(HttpServletRequest, HttpServletResponse, HandlerMethod)
}
class TenantValidator {
-tenantContextProvider: TenantContextProvider
+validateTenantAccess(TenantContext, Resource)
}
class TenantContextProvider {
<<Interface>>
+getCurrentTenantContext()
}
class DefaultTenantContextProvider {
-tenantHeaderExtractor: TenantHeaderExtractor
+getCurrentTenantContext()
}
class TenantHeaderExtractor {
-tenantIdHeader: String
-userIdHeader: String
+extractTenantContext(HttpServletRequest)
}
class AuditLogger {
-auditEventPublisher: EventPublisher
+logAuditEvent(AuditEvent)
}
class AuditEvent {
-tenantId: String
-userId: String
-action: String
-resourceType: String
-resourceId: String
-timestamp: Instant
}
class EventPublisher {
<<Interface>>
+publish(Event)
}
class DefaultEventPublisher {
-eventBus: EventBus
+publish(Event)
}
class Event {
-type: String
-payload: Object
-timestamp: Instant
}
TenantIsolationFramework --> TenantContext : uses
TenantIsolationFramework --> TenantValidator : uses
TenantValidator --> TenantContextProvider : uses
TenantContextProvider --> DefaultTenantContextProvider : implements
DefaultTenantContextProvider --> TenantHeaderExtractor : uses
DefaultTenantAwareService --> TenantAwareRepository : uses
DefaultTenantAwareService --> EventPublisher : publishes
DefaultTenantAwareService --> AuditLogger : logs
TenantAwareService <|-- DefaultTenantAwareService : implements
TenantContextProvider <|-- DefaultTenantContextProvider : implements
TenantValidator --> TenantContext : validates
AuditLogger --> EventPublisher : publishes
EventPublisher <|-- DefaultEventPublisher : implements
State Machine Diagram for TenantResource
stateDiagram-v2
[*] --> Active
Active --> Deleted : deleteResource()
Deleted --> Active : restoreResource()
Service Class Structure and Responsibilities
The service layer implements tenant isolation logic with clear separation of concerns:
- TenantAwareService - Interface defining the contract for tenant-aware operations
- DefaultTenantAwareService - Primary implementation handling business logic and tenant validation
- TenantIsolationFramework - Central coordinator managing isolation context and validation
- TenantValidator - Validates tenant access permissions for operations
- AuditLogger - Logs all tenant-accessible operations for compliance and monitoring
Interface / Abstract Class Design
public interface TenantAwareRepository<T extends TenantResource> {
Optional<T> findById(String id, String tenantId);
List<T> findByTenantId(String tenantId);
T save(T resource);
void deleteById(String id, String tenantId);
}
public interface TenantAwareService<T extends TenantResource> {
T createResource(TenantContext context, T resource);
Optional<T> getResourceById(TenantContext context, String id);
T updateResource(TenantContext context, String id, T resource);
void deleteResource(TenantContext context, String id);
}
public interface TenantContextProvider {
TenantContext getCurrentTenantContext();
}
Business Logic Encapsulation Patterns
Strategy Pattern for tenant validation strategies:
public interface TenantValidationStrategy {
boolean validate(TenantContext context, TenantResource resource);
}
public class RBACTenantValidationStrategy implements TenantValidationStrategy {
private final RoleBasedAccessControl rbac;
public RBACTenantValidationStrategy(RoleBasedAccessControl rbac) {
this.rbac = rbac;
}
@Override
public boolean validate(TenantContext context, TenantResource resource) {
return rbac.hasPermission(context.getUserId(), resource.getTenantId(), "RESOURCE_ACCESS");
}
}
public class TenantValidationStrategyFactory {
public static TenantValidationStrategy createStrategy(String type) {
return switch (type.toLowerCase()) {
case "rbac" -> new RBACTenantValidationStrategy(new RoleBasedAccessControl());
default -> new DefaultTenantValidationStrategy();
};
}
}
Critical-Path Method Implementation
@Transactional
public User createResource(TenantContext context, User user) {
// Validate tenant access before proceeding
if (!tenantValidator.validateTenantAccess(context, user)) {
throw new TenantAccessDeniedException("User does not have access to tenant: " + context.getTenantId());
}
// Set tenant ID on resource
user.setTenantId(context.getTenantId());
// Ensure unique constraint across tenants (if applicable)
String userKey = generateUserKey(user.getEmail(), context.getTenantId());
if (userRepository.existsByKey(userKey)) {
throw new DuplicateResourceException("User with email " + user.getEmail() + " already exists in tenant");
}
// Save to database
User savedUser = userRepository.save(user);
// Publish audit event
AuditEvent auditEvent = new AuditEvent(
context.getTenantId(),
context.getUserId(),
"CREATE_USER",
"User",
savedUser.getId(),
Instant.now()
);
auditLogger.logAuditEvent(auditEvent);
// Publish domain event for async processing
UserCreatedEvent event = new UserCreatedEvent(
savedUser.getId(),
context.getTenantId(),
savedUser.getEmail(),
Instant.now()
);
eventPublisher.publish(event);
return savedUser;
}
Transaction Boundary Design
Each tenant-aware operation is wrapped in a single database transaction:
- Create/Update/Delete operations - Single transaction per operation
- Cross-tenant operations - Split into separate transactions with proper error handling
- Event publishing - Part of same transaction to ensure consistency
Service Interface Contracts
public interface TenantAwareService<T extends TenantResource> {
/**
* Creates a new resource within the tenant scope
* @param context Tenant context for isolation
* @param resource Resource to create
* @return Created resource with tenant context applied
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws DuplicateResourceException when resource already exists
*/
T createResource(TenantContext context, T resource);
/**
* Retrieves a resource by ID within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier
* @return Optional resource if found and accessible
* @throws TenantAccessDeniedException when tenant access is not authorized
*/
Optional<T> getResourceById(TenantContext context, String id);
/**
* Updates a resource within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier
* @param resource Updated resource data
* @return Updated resource
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws ResourceNotFoundException when resource doesn't exist
*/
T updateResource(TenantContext context, String id, T resource);
/**
* Deletes a resource within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier to delete
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws ResourceNotFoundException when resource doesn't exist
*/
void deleteResource(TenantContext context, String id);
}
Saga / Orchestration Pattern
For multi-step tenant operations, a saga pattern is implemented:
public class TenantResourceSaga {
private final List<SagaStep> steps;
public void execute(TenantContext context, String operationId) {
for (SagaStep step : steps) {
try {
step.execute(context, operationId);
} catch (Exception e) {
// Compensate previous steps
compensateSteps(steps, step);
throw new SagaExecutionException("Saga failed at step: " + step.getName(), e);
}
}
}
private void compensateSteps(List<SagaStep> steps, SagaStep failedStep) {
// Reverse the order and execute compensation logic
for (int i = steps.indexOf(failedStep) - 1; i >= 0; i--) {
steps.get(i).compensate();
}
}
}
Domain Object / Value Object Design
public class TenantContext {
private final String tenantId;
private final String userId;
private final Set<String> permissions;
public TenantContext(String tenantId, String userId, Set<String> permissions) {
this.tenantId = Objects.requireNonNull(tenantId);
this.userId = Objects.requireNonNull(userId);
this.permissions = Set.copyOf(permissions);
}
public String getTenantId() {
return tenantId;
}
public String getUserId() {
return userId;
}
public boolean hasPermission(String permission) {
return permissions.contains(permission);
}
}
public abstract class TenantResource {
protected String id;
protected String tenantId;
protected Instant createdAt;
protected Instant updatedAt;
// Getters and setters
}
Transaction Boundary Design
Each tenant-aware operation is wrapped in a single database transaction:
- Create/Update/Delete operations - Single transaction per operation
- Cross-tenant operations - Split into separate transactions with proper error handling
- Event publishing - Part of same transaction to ensure consistency
Service Interface Contracts
public interface TenantAwareService<T extends TenantResource> {
/**
* Creates a new resource within the tenant scope
* @param context Tenant context for isolation
* @param resource Resource to create
* @return Created resource with tenant context applied
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws DuplicateResourceException when resource already exists
*/
T createResource(TenantContext context, T resource);
/**
* Retrieves a resource by ID within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier
* @return Optional resource if found and accessible
* @throws TenantAccessDeniedException when tenant access is not authorized
*/
Optional<T> getResourceById(TenantContext context, String id);
/**
* Updates a resource within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier
* @param resource Updated resource data
* @return Updated resource
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws ResourceNotFoundException when resource doesn't exist
*/
T updateResource(TenantContext context, String id, T resource);
/**
* Deletes a resource within tenant scope
* @param context Tenant context for isolation
* @param id Resource identifier to delete
* @throws TenantAccessDeniedException when tenant access is not authorized
* @throws ResourceNotFoundException when resource doesn't exist
*/
void deleteResource(TenantContext context, String id);
}
5. Concurrency & Thread Safety
Concurrency Model
The system uses a thread-per-request model with a thread pool for handling concurrent tenant requests. Each HTTP request is processed in its own thread, ensuring that tenant-specific operations don't block each other. For asynchronous processing (e.g., event handling), a separate thread pool is used with bounded queues to prevent resource exhaustion.
Race Condition Prevention Strategy
Race conditions are mitigated through:
- Tenant-aware locking: All operations that access shared resources use tenant-specific locks to prevent cross-tenant interference
- Database-level atomicity: Critical operations are wrapped in database transactions with proper isolation levels
- Version-based concurrency control: Optimistic locking using version fields to detect and handle concurrent updates
- Idempotency keys: For mutation operations, idempotency keys prevent duplicate processing in concurrent scenarios
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for the following reasons:
- High read-to-write ratio: Most operations are reads, so pessimistic locking would cause unnecessary contention
- Tenant isolation: Each tenant's data is isolated, reducing conflict likelihood between tenants
- Scalability: Optimistic locking scales better with concurrent tenants
- Performance: Eliminates lock wait times for read-heavy workloads
Implementation approach:
// Example optimistic locking in a tenant-aware service
public class TenantAwareService {
private final EntityManager entityManager;
@Transactional
public void updateTenantResource(String tenantId, Long resourceId, ResourceUpdateRequest request) {
// Fetch resource with version
Resource resource = entityManager.find(Resource.class, resourceId);
if (resource == null) {
throw new ResourceNotFoundException("Resource not found");
}
// Validate tenant ownership
if (!resource.getTenantId().equals(tenantId)) {
throw new TenantMismatchException("Resource does not belong to tenant");
}
// Validate version for optimistic locking
if (resource.getVersion() != request.getExpectedVersion()) {
throw new ConcurrencyException("Resource has been modified by another process");
}
// Apply updates
resource.setName(request.getName());
resource.setVersion(resource.getVersion() + 1);
// Persist changes
entityManager.merge(resource);
}
}
Deadlock Prevention Strategy
Deadlock prevention is implemented through:
- Lock ordering: All tenant operations acquire locks in a consistent order (e.g., by tenant ID, then resource ID)
- Timeout configuration: Database transaction timeouts to prevent indefinite lock waits
- Lock hierarchy: Tenant-level locks are acquired before resource-level locks to avoid circular dependencies
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is enforced using a distributed idempotency key store (Redis or similar) with TTL:
@Component
public class IdempotencyService {
private final RedisTemplate<String, String> redisTemplate;
public boolean isIdempotent(String tenantId, String idempotencyKey) {
String key = "idempotency:" + tenantId + ":" + idempotencyKey;
String existing = redisTemplate.opsForValue().get(key);
if (existing != null) {
// Request was already processed
return true;
}
// Store the key with TTL (e.g., 5 minutes)
redisTemplate.opsForValue().set(key, "processed", 5, TimeUnit.MINUTES);
return false;
}
public void processWithIdempotency(String tenantId, String idempotencyKey,
Supplier<Boolean> operation) {
if (isIdempotent(tenantId, idempotencyKey)) {
// Already processed - return success or cached result
return;
}
try {
operation.get();
} catch (Exception e) {
// If operation fails, remove the key so it can be retried
String key = "idempotency:" + tenantId + ":" + idempotencyKey;
redisTemplate.delete(key);
throw e;
}
}
}
Thread Safety in Tenant Context
Tenant context is maintained thread-local to ensure proper isolation:
public class TenantContext {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(String tenantId) {
currentTenant.set(tenantId);
}
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}
Concurrency Control for Multi-Tenant Operations
When operations span multiple tenants, the system uses:
- Tenant-aware transaction boundaries: Each tenant's operations are isolated within their own transaction scope
- Batch processing with tenant grouping: Operations are batched by tenant to minimize cross-tenant contention
- Async processing with tenant queues: Tenant-specific message queues ensure proper ordering and isolation
Performance Considerations
- Lock contention monitoring: Track lock wait times to identify potential bottlenecks
- Tenant-specific thread pools: Separate thread pools per tenant for critical operations to prevent one tenant from starving others
- Connection pool sizing: Configure database connection pools with tenant-aware sizing to prevent resource exhaustion
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant Client
participant AuthN
participant TenantMiddleware
participant Service
participant DB
Client->>AuthN: Request with tenant header (X-Tenant-ID)
AuthN-->>Client: JWT token with tenant info
Client->>TenantMiddleware: Request with JWT token
TenantMiddleware->>Service: Forward request with tenant context
Service->>DB: Query with tenant filter (tenant_id = 'tenant-123')
DB-->>Service: Query results
Service-->>Client: Response with data for tenant-123
sequenceDiagram
participant Client
participant AuthN
participant TenantMiddleware
participant Service
participant DB
Client->>AuthN: Request with invalid tenant ID (X-Tenant-ID: 'tenant-999')
AuthN-->>Client: JWT token with tenant info
Client->>TenantMiddleware: Request with JWT token and invalid tenant ID
TenantMiddleware->>Service: Forward request with tenant context
Service->>DB: Query with tenant filter (tenant_id = 'tenant-999')
DB-->>Service: Empty results
Service-->>Client: 404 Not Found - Tenant does not exist or no data
sequenceDiagram
participant Client
participant AuthN
participant TenantMiddleware
participant Service
participant DB
participant EventPublisher
Client->>AuthN: Create tenant request (X-Tenant-ID: 'tenant-123')
AuthN-->>Client: JWT token with tenant info
Client->>TenantMiddleware: Request with JWT token and create tenant payload
TenantMiddleware->>Service: Forward request with tenant context
Service->>DB: Insert new tenant record (tenant_id = 'tenant-123')
Service->>EventPublisher: Publish tenant.created event
EventPublisher-->>Service: Acknowledgement
Service-->>Client: 201 Created - Tenant created successfully
7. Caching Design
Cache Strategy Selection
For tenant isolation, we'll use cache-aside pattern with tenant-aware cache keys. This approach provides the most control over cache invalidation and allows for fine-grained tenant-level cache management.
Cache Key Design
Cache keys will follow the namespace pattern:
{cacheNamespace}:{tenantId}:{entityType}:{entityId}
Example:
user:12345:profile:67890
booking:12345:seat:54321
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| User Profile | 5 minutes | Frequent updates, low cache hit ratio |
| Booking Details | 1 hour | High consistency requirements |
| Seat Availability | 2 minutes | Real-time updates needed |
| Tenant Metadata | 1 day | Infrequently changing |
| Session Tokens | 30 minutes | Security sensitive |
Cache Eviction Policy
We'll use LRU (Least Recently Used) eviction policy with TTL-based soft expiration to handle cache stampede prevention. Cache entries will be marked as "expired" but remain accessible for a grace period to prevent thundering herd.
Cache Stampede Prevention
- Probabilistic Early Expiry: 20% of TTL is used for early refresh
- Distributed Locks: For high-traffic entities, use Redis distributed locks to serialize cache rebuilds
- Cache Entry Versioning: Each cache entry includes a version number that's incremented on data changes
Distributed Cache Architecture
We'll use Redis Cluster with:
- 3 master nodes for data distribution
- 3 replica nodes per master for high availability
- Redis Sentinel for automatic failover
- Cache sharding based on tenant ID hash
Cache Invalidation Strategy
Event-driven invalidation: Cache entries are invalidated via events published to a message broker when data changes. This ensures immediate consistency across tenants.
Cache warming: On application startup, warm tenant caches for frequently accessed entities using batch operations.
Cache Implementation Example
@Component
public class TenantAwareCache {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
public <T> T get(String tenantId, String entityType, String entityId,
Class<T> type, Supplier<T> loader) {
String cacheKey = buildCacheKey(tenantId, entityType, entityId);
// Try to get from cache
String cachedValue = redisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
try {
return objectMapper.readValue(cachedValue, type);
} catch (Exception e) {
// Log and proceed to reload
log.warn("Failed to deserialize cache value for key: {}", cacheKey);
}
}
// Cache miss - load and store
T value = loader.get();
if (value != null) {
String serializedValue = objectMapper.writeValueAsString(value);
redisTemplate.opsForValue().set(cacheKey, serializedValue,
Duration.ofMinutes(5));
}
return value;
}
public void invalidate(String tenantId, String entityType, String entityId) {
String cacheKey = buildCacheKey(tenantId, entityType, entityId);
redisTemplate.delete(cacheKey);
}
private String buildCacheKey(String tenantId, String entityType, String entityId) {
return String.format("%s:%s:%s:%s",
"tenant", tenantId, entityType, entityId);
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract data access logic and provide a clean separation between domain logic and persistence concerns. Each entity has its own repository interface with standardized CRUD operations.
public interface UserRepository {
User findById(UUID id);
User findByEmail(String email);
List<User> findAllByTenantId(UUID tenantId);
User save(User user);
void delete(UUID id);
}
public interface TenantRepository {
Tenant findById(UUID id);
Tenant findByDomain(String domain);
List<Tenant> findAll();
Tenant save(Tenant tenant);
}
ORM vs Raw Query Decisions
For the core tenant-isolation framework, raw SQL queries are preferred over ORM for:
- Fine-grained control over tenant filtering
- Performance optimization for high-throughput operations
- Explicit handling of tenant context in queries
@Repository
public class UserRepositoryImpl implements UserRepository {
private final JdbcTemplate jdbcTemplate;
private final TenantContext tenantContext;
public UserRepositoryImpl(JdbcTemplate jdbcTemplate, TenantContext tenantContext) {
this.jdbcTemplate = jdbcTemplate;
this.tenantContext = tenantContext;
}
@Override
public User findById(UUID id) {
String sql = "SELECT * FROM users WHERE id = ? AND tenant_id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id, tenantContext.getCurrentTenantId()}, userRowMapper);
}
@Override
public List<User> findAllByTenantId(UUID tenantId) {
String sql = "SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC";
return jdbcTemplate.query(sql, new Object[]{tenantId}, userRowMapper);
}
@Override
public User save(User user) {
if (user.getId() == null) {
String sql = "INSERT INTO users (id, email, name, tenant_id, created_at) VALUES (?, ?, ?, ?, ?)";
UUID id = UUID.randomUUID();
jdbcTemplate.update(sql, id, user.getEmail(), user.getName(),
tenantContext.getCurrentTenantId(), LocalDateTime.now());
return findById(id);
} else {
String sql = "UPDATE users SET email = ?, name = ? WHERE id = ? AND tenant_id = ?";
jdbcTemplate.update(sql, user.getEmail(), user.getName(),
user.getId(), tenantContext.getCurrentTenantId());
return findById(user.getId());
}
}
}
Read vs Write Separation
The CQRS pattern is implemented to separate read and write operations:
public interface UserReadRepository {
User findByEmail(String email);
List<User> findAllByTenantId(UUID tenantId);
long countByTenantId(UUID tenantId);
}
public interface UserWriteRepository {
User save(User user);
void delete(UUID id);
void updateLastLogin(UUID userId);
}
Connection Pool Configuration
Connection pool is configured with:
- Max pool size: 20
- Min idle: 5
- Connection timeout: 30 seconds
- Validation query:
SELECT 1
@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/tenantdb");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setValidationTimeout(5000);
config.setLeakDetectionThreshold(60000);
return new HikariDataSource(config);
}
}
N+1 Query Prevention Strategy
Eager loading is implemented for related entities, and batch operations are used where appropriate:
@Repository
public class TenantRepositoryImpl implements TenantRepository {
private final JdbcTemplate jdbcTemplate;
@Override
public List<Tenant> findAllWithUsers() {
String sql = "SELECT t.id as tenant_id, t.name as tenant_name, u.id as user_id, u.email as user_email " +
"FROM tenants t LEFT JOIN users u ON t.id = u.tenant_id";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Tenant tenant = new Tenant();
tenant.setId(UUID.fromString(rs.getString("tenant_id")));
tenant.setName(rs.getString("tenant_name"));
// Handle multiple users per tenant
if (rs.getObject("user_id") != null) {
User user = new User();
user.setId(UUID.fromString(rs.getString("user_id")));
user.setEmail(rs.getString("user_email"));
tenant.addUser(user);
}
return tenant;
});
}
public List<User> findUsersByTenantIds(List<UUID> tenantIds) {
String placeholders = String.join(",", Collections.nCopies(tenantIds.size(), "?"));
String sql = "SELECT * FROM users WHERE tenant_id IN (" + placeholders + ")";
return jdbcTemplate.query(sql, tenantIds.toArray(), userRowMapper);
}
}
Batch Operation Design
Batch operations are implemented for bulk data processing:
@Repository
public class UserBatchRepository {
private final JdbcTemplate jdbcTemplate;
public void batchCreateUsers(List<User> users) {
String sql = "INSERT INTO users (id, email, name, tenant_id, created_at) VALUES (?, ?, ?, ?, ?)";
List<Object[]> batchArgs = users.stream()
.map(user -> new Object[]{
user.getId(),
user.getEmail(),
user.getName(),
tenantContext.getCurrentTenantId(),
LocalDateTime.now()
})
.collect(Collectors.toList());
jdbcTemplate.batchUpdate(sql, batchArgs);
}
public void batchUpdateUserEmails(List<UUID> userIds, String newEmail) {
String sql = "UPDATE users SET email = ? WHERE id IN (" +
String.join(",", Collections.nCopies(userIds.size(), "?")) + ")";
List<Object[]> batchArgs = userIds.stream()
.map(id -> new Object[]{newEmail, id})
.collect(Collectors.toList());
jdbcTemplate.batchUpdate(sql, batchArgs);
}
}
Transaction Management
Transaction boundaries are carefully defined to ensure tenant isolation:
@Service
@Transactional
public class UserService {
private final UserWriteRepository userWriteRepository;
private final TenantContext tenantContext;
public User createUser(User user) {
// Transaction boundary includes tenant context validation
validateTenantAccess();
User savedUser = userWriteRepository.save(user);
// Publish event within same transaction
eventPublisher.publish(new UserCreatedEvent(savedUser));
return savedUser;
}
private void validateTenantAccess() {
if (tenantContext.getCurrentTenantId() == null) {
throw new TenantNotFoundException("No tenant context found");
}
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why
In a tenant isolation framework, the primary use case for asynchronous processing is around tenant lifecycle operations and audit logging, where immediate response to the client isn't required but eventual consistency is acceptable. For example:
- Tenant creation: The system needs to provision resources (databases, storage buckets, etc.) which can take time. These operations should be async to avoid blocking the API response.
- Tenant deletion: Cleanup of resources across multiple systems (databases, storage, etc.) should be handled asynchronously to ensure the operation doesn't timeout or fail due to external dependencies.
- Audit logging: All tenant operations should be logged for compliance and monitoring purposes, but these logs can be published asynchronously to avoid impacting the performance of the primary operation.
Synchronous operations include:
- Tenant lookup: A direct query to find a tenant by ID or name, which must return immediately.
- Tenant update: Changes like metadata updates or role assignments should be synchronous to ensure immediate consistency.
Message schema design with a sample payload (Avro / Protobuf / JSON Schema — with versioning strategy)
We use JSON Schema for simplicity and readability, but in production, Avro or Protocol Buffers would be preferred for strong typing and schema evolution.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TenantEvent",
"type": "object",
"version": "1.0",
"properties": {
"eventId": {
"type": "string",
"description": "Unique identifier for the event"
},
"tenantId": {
"type": "string",
"description": "Tenant identifier"
},
"eventType": {
"type": "string",
"enum": ["TENANT_CREATED", "TENANT_DELETED", "TENANT_UPDATED"]
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"data": {
"type": "object",
"description": "Event-specific data"
},
"source": {
"type": "string",
"description": "Service that published the event"
}
},
"required": ["eventId", "tenantId", "eventType", "timestamp", "source"]
}
Versioning strategy:
- Semantic versioning (MAJOR.MINOR.PATCH) for breaking changes, feature additions, and bug fixes.
- Schema registry to manage versions and enforce compatibility.
- Backward compatibility is maintained for minor version changes.
Dead letter queue strategy and poison message handling
Poison messages are events that repeatedly fail to be processed. Strategy:
- DLQ topic: Each event type has a corresponding DLQ topic (e.g.,
tenant.created.dlq). - Retry logic: Events are retried up to 3 times with exponential backoff (1s, 2s, 4s).
- Dead letter handling: If retries exceed the limit, messages are sent to DLQ.
- Manual intervention: DLQ messages are logged with metadata (last error, retry count) and can be manually reprocessed by an operator.
At-least-once vs. exactly-once delivery — decision with justification for this feature
We choose at-least-once delivery because:
- Tenant operations are generally idempotent (e.g., creating a tenant twice is safe).
- The system uses idempotency keys for deduplication.
- Eventual consistency is acceptable for audit logging and resource provisioning.
Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)
Retry policy:
- Max retries: 3 attempts
- Backoff formula:
delay = baseDelay * (2^retryCount) + random(0, 1000) - Non-retryable errors:
400 Bad Request,401 Unauthorized,403 Forbidden,500 Internal Server Error(if the error is not transient)
Outbox pattern for reliable event publishing — include or explicitly justify omission
We do not use the outbox pattern in this design because:
- The system assumes that event publishing is part of a single transaction (e.g., within the same database).
- If we were to use an external message broker, the outbox pattern would be necessary to ensure that events are published only after the database transaction is committed.
Sequence diagram for all async flows (Mermaid)
sequenceDiagram
participant Client
participant APIGateway
participant TenantService
participant EventPublisher
participant MessageBroker
Client->>APIGateway: POST /tenants
APIGateway->>TenantService: CreateTenantRequest
TenantService->>TenantService: Validate and persist tenant
TenantService->>EventPublisher: Publish TENANT_CREATED event
EventPublisher->>MessageBroker: Send to tenant.created topic
MessageBroker-->>EventPublisher: Acknowledge
TenantService-->>APIGateway: 201 Created
APIGateway-->>Client: Tenant created successfully
10. Error Handling Design
Error Classification Hierarchy
The error handling framework follows a hierarchical classification to enable proper routing and response generation:
- Domain Errors - Business logic violations (e.g.,
TenantNotFound,InsufficientPermissions) - Validation Errors - Input validation failures (e.g.,
InvalidTenantId,MissingRequiredField) - Infrastructure Errors - System-level issues (e.g.,
DatabaseConnectionFailed,CacheTimeout) - External Service Errors - Third-party integration failures (e.g.,
PaymentGatewayTimeout,AuthServiceUnavailable)
Error Propagation Strategy
Errors are handled at their point of occurrence and propagated upward through the service layer. The framework follows a "bubble up" strategy for domain and validation errors, while infrastructure errors are caught at the service boundary and wrapped with appropriate context.
public class ErrorHandlingStrategy {
public void handleDomainError(DomainException ex) {
// Log with context and rethrow for higher-level handling
logger.error("Domain error occurred: {}", ex.getMessage(), ex);
throw new ServiceException("Business logic error", ex);
}
public void handleInfrastructureError(InfrastructureException ex) {
// Log and wrap with service-level context
logger.error("Infrastructure error: {}", ex.getMessage(), ex);
throw new ServiceUnavailableException("Service temporarily unavailable", ex);
}
}
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors, a retry mechanism is implemented using exponential backoff with jitter to prevent thundering herd:
public class RetryStrategy {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation) {
int attempts = 0;
while (attempts < MAX_RETRIES) {
try {
return operation.get();
} catch (TransientException e) {
if (attempts == MAX_RETRIES - 1) throw e;
long delay = calculateDelay(attempts);
logger.warn("Transient error on attempt {}: retrying in {}ms",
attempts + 1, delay, e);
Thread.sleep(delay);
attempts++;
}
}
throw new RuntimeException("Retry limit exceeded");
}
private long calculateDelay(int attempt) {
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
long jitter = (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return delay + jitter;
}
}
Circuit Breaker Design
For external service calls, a circuit breaker pattern is implemented to prevent cascading failures:
public class CircuitBreaker {
private final int failureThreshold = 5;
private final long timeoutMs = 30000; // 30 seconds
private final int resetTimeoutMs = 60000; // 1 minute
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
try {
return operation.get();
} catch (Exception e) {
handleFailure();
throw e;
}
case OPEN:
if (System.currentTimeMillis() - lastFailureTime > resetTimeoutMs) {
state = CircuitState.HALF_OPEN;
return operation.get();
}
throw new CircuitBreakerOpenException("Circuit breaker is open");
case HALF_OPEN:
try {
T result = operation.get();
state = CircuitState.CLOSED;
failureCount = 0;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
throw e;
}
}
throw new IllegalStateException("Invalid circuit state");
}
private void handleFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
}
}
Fallback Behavior Design
Fallbacks are implemented for critical external dependencies:
public class FallbackHandler {
public List<Tenant> getTenantsWithFallback(String userId) {
try {
return tenantRepository.findAll();
} catch (DatabaseException e) {
logger.warn("Database unavailable, using cached tenants");
return getCachedTenants();
}
}
public Tenant getTenantWithFallback(String tenantId) {
try {
return tenantRepository.findById(tenantId);
} catch (DatabaseException e) {
logger.warn("Database unavailable, using default tenant");
return createDefaultTenant();
}
}
}
Error Logging Format and Alerting Thresholds
Structured logging includes trace context and error classification:
public class ErrorLogger {
private static final ObjectMapper objectMapper = new ObjectMapper();
public void logError(String operation, String userId, String tenantId,
Exception exception, ErrorType errorType) {
Map<String, Object> logEntry = new HashMap<>();
logEntry.put("timestamp", Instant.now());
logEntry.put("operation", operation);
logEntry.put("userId", userId);
logEntry.put("tenantId", tenantId);
logEntry.put("errorType", errorType.name());
logEntry.put("errorCode", getErrorCode(exception));
logEntry.put("errorMessage", exception.getMessage());
logEntry.put("stackTrace", getStackTrace(exception));
logger.error("{}", objectMapper.writeValueAsString(logEntry));
}
private String getErrorCode(Exception ex) {
if (ex instanceof TenantNotFoundException) return "TENANT_NOT_FOUND";
if (ex instanceof InsufficientPermissionsException) return "INSUFFICIENT_PERMISSIONS";
return "INTERNAL_ERROR";
}
}
Alerting Thresholds
Critical error thresholds trigger alerts:
| Error Type | Alert Threshold | Severity |
|---|---|---|
| Database Connection Failures | > 5 errors in 5 minutes | CRITICAL |
| Authentication Failures | > 100 errors in 1 hour | WARNING |
| Tenant Isolation Violations | > 1 occurrence | CRITICAL |
| Cache Timeout Errors | > 5 errors in 10 minutes | WARNING |
Error Response Format
Standardized JSON error responses:
{
"error": {
"code": "TENANT_NOT_FOUND",
"message": "Tenant with ID 'tenant-123' was not found",
"details": {
"tenantId": "tenant-123",
"timestamp": "2023-10-15T14:30:00Z"
},
"httpStatus": 404
}
}
Error Handling in Service Layer
@Service
public class TenantService {
public Tenant getTenant(String tenantId, String userId) {
try {
// Validate input
if (tenantId == null || tenantId.trim().isEmpty()) {
throw new ValidationException("Tenant ID is required");
}
// Check tenant isolation
if (!tenantAuthorizationService.isAuthorized(userId, tenantId)) {
throw new InsufficientPermissionsException(
"User does not have access to tenant: " + tenantId);
}
// Fetch tenant with retry logic
return retryStrategy.executeWithRetry(() -> {
Tenant tenant = tenantRepository.findById(tenantId);
if (tenant == null) {
throw new TenantNotFoundException("Tenant not found: " + tenantId);
}
return tenant;
});
} catch (TenantNotFoundException e) {
logger.warn("Tenant not found: {}", tenantId, e);
throw new TenantNotFoundException("Tenant not found", e);
} catch (InsufficientPermissionsException e) {
logger.warn("Permission denied for user {} accessing tenant {}", userId, tenantId, e);
throw new InsufficientPermissionsException("Access denied", e);
} catch (DatabaseException e) {
logger.error("Database error while fetching tenant: {}", tenantId, e);
throw new ServiceUnavailableException("Service temporarily unavailable", e);
}
}
}
11. Security Design
Input Validation and Sanitization Strategy
All API inputs are validated using a combination of:
- Custom validation annotations (e.g.,
@NotBlank,@Size) for basic constraints - Apache Commons Validator for complex patterns (email, phone numbers, UUIDs)
- OWASP Java HTML Sanitizer for any user-provided content that gets rendered
- Custom regex patterns for domain-specific constraints (e.g., tenant ID format)
SQL Injection Prevention
- Prepared statements used exclusively for all database queries
- Parameterized queries with no string concatenation in SQL generation
- ORM framework (Hibernate) with strict mapping configuration to prevent dynamic query building
- Query whitelisting for any dynamic SQL components (e.g., column names in ORDER BY clauses)
Authentication Middleware Design
- JWT-based authentication with RS256 signing algorithm using asymmetric keys
- Authentication filter intercepts all requests and validates JWT tokens
- Tenant context extraction from JWT claims to establish current tenant scope
- Token refresh mechanism with sliding window (30-minute TTL, 15-minute refresh window)
- Token revocation via Redis-based blacklisting for immediate logout scenarios
Authorization Enforcement
- Role-Based Access Control (RBAC) model with:
TENANT_ADMIN- Full access to tenant resourcesTENANT_USER- Read/write access within tenant scopeSYSTEM_ADMIN- Cross-tenant admin capabilities (system-wide)
- Permission checks implemented at service layer using
@PreAuthorizeannotations - Tenant-aware resource access - All queries include tenant ID filter in WHERE clause
- Resource ownership validation - Verifies that user has access to requested tenant resource
Sensitive Data Encryption
- At-rest encryption using AES-256 with GCM mode for database fields containing sensitive data (user passwords, PII)
- In-transit encryption enforced via TLS 1.3 for all API communications
- Key management through AWS KMS (Customer Managed Keys) with automatic rotation every 90 days
- Data encryption keys are stored in encrypted format within the application's secure configuration store
PII Handling and Masking Strategy
- PII fields (email, phone, SSN) are encrypted at rest using AES-256-GCM
- Masking in logs - PII fields are truncated or replaced with asterisks in log output
- PII in API responses - Only masked versions shown to non-admin users
- Data retention policy - PII data is automatically deleted after 3 years of inactivity
Secrets Management
- AWS Secrets Manager for storing:
- Database connection credentials (rotated every 90 days)
- Third-party API keys (Stripe, Twilio)
- JWT signing keys (rotated every 6 months)
- Secrets rotation handled via Lambda functions with CloudWatch alarms for failure detection
- Access control - IAM roles restrict access to secrets to only necessary services
Audit Logging Design
- Audit log schema includes:
timestamp(UTC)tenant_iduser_idaction_type(CREATE, UPDATE, DELETE, READ)resource_typeresource_idip_addressuser_agentsuccess(boolean)
- Audit logging implemented via interceptor pattern that wraps all service methods
- Log retention policy - 2 years for audit logs, archived to S3 after 1 year
- Compliance reporting - Audit logs exported in structured format (JSON) for SOC2 and GDPR compliance checks
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage with focus on business logic, validation, and error paths
- Repository Layer: 90% coverage with all CRUD operations and query edge cases
- Domain Objects: 100% coverage for value objects and immutable entities
- Exception Handling: 100% coverage for all custom exception paths
- Integration Points: 95% coverage for external service mocks and database interactions
Integration Testing Strategy
- Test Containers: Use Docker-based test containers for PostgreSQL, Redis, and Kafka to ensure realistic environment behavior
- Embedded Databases: H2/PostgreSQL in-memory databases for fast unit test execution with schema compatibility
- WireMock: For external HTTP service stubbing (e.g., payment gateways, notification services)
- Test Data Isolation: Each test suite runs in isolated database schemas with automatic cleanup
Contract Testing
- Pact Framework: Consumer-driven contract testing between tenant isolation service and dependent services (e.g., user management, billing)
- Versioned Contracts: Maintain contract versions with semantic versioning to support backward compatibility
- Contract Validation: Run contract tests in CI pipeline as part of pre-deployment validation
Performance Testing Tool and Key Scenarios
- k6: Load testing tool for simulating concurrent tenant access patterns
- Key Scenarios:
- Concurrent tenant creation under load (1000 concurrent requests)
- Multi-tenant query performance with large datasets (10k+ records per tenant)
- Tenant data isolation under high contention
- Cache hit/miss ratio validation under realistic traffic patterns
Test Data Management Strategy
- Fixtures: Pre-defined tenant configurations and user data for integration tests
- Factories: Programmatic test data generation using Factory Pattern to create valid tenant and user objects
- Seeding: Automated database seeding scripts for test environments with consistent baseline data
- Data Cleanup: Transactional test cleanup using database snapshots or schema-level truncation to maintain test isolation
// Example unit test for tenant validation logic
@Test
public void testCreateTenantWithValidData() {
TenantRequest request = new TenantRequest.Builder()
.withName("ValidTenant")
.withDomain("valid.tenant.com")
.withContactEmail("admin@valid.com")
.build();
Tenant tenant = tenantService.createTenant(request);
assertThat(tenant.getId()).isNotNull();
assertThat(tenant.getName()).isEqualTo("ValidTenant");
assertThat(tenant.getDomain()).isEqualTo("valid.tenant.com");
}
@Test
public void testCreateTenantWithInvalidDomain() {
TenantRequest request = new TenantRequest.Builder()
.withName("InvalidTenant")
.withDomain("invalid-domain")
.withContactEmail("admin@valid.com")
.build();
assertThatThrownBy(() -> tenantService.createTenant(request))
.isInstanceOf(InvalidTenantDomainException.class)
.hasMessageContaining("Invalid domain format");
}
13. Observability Design
Structured Logging Schema
All logs must follow a consistent schema to enable effective querying and analysis:
{
"timestamp": "2023-10-27T14:30:45.123Z",
"level": "INFO|WARN|ERROR",
"service": "tenant-isolation-service",
"trace_id": "123e4567-e89b-12d3-a456-426614174000",
"span_id": "abcdef1234567890",
"user_id": "user-123",
"tenant_id": "tenant-abc",
"operation": "create_tenant",
"request_id": "req-xyz",
"duration_ms": 150,
"error_code": "TENANT_NOT_FOUND",
"error_message": "Tenant with ID tenant-abc does not exist",
"stack_trace": "...",
"payload": {
"input_data": "{...}",
"output_data": "{...}"
}
}
PII masking rules:
- User IDs and tenant IDs are logged in masked format (e.g.,
user-123→user-***) - Full email addresses are masked to
user@***.com - Any sensitive fields in payloads are redacted or replaced with
***
Distributed Tracing Instrumentation
Using OpenTelemetry, we instrument key operations:
Custom Spans to Add:
tenant_isolation_check- wrapping tenant validation logicdata_access_tenant_filter- filtering data by tenant contexttenant_context_extraction- extracting tenant from request headers
Attributes to Capture:
Span currentSpan = Span.current();
currentSpan.setAttribute("tenant.id", tenantId);
currentSpan.setAttribute("user.id", userId);
currentSpan.setAttribute("operation.name", "create_tenant");
currentSpan.setAttribute("isolation.level", "multi-tenant");
Metrics Design
Service-Level RED Metrics:
| Metric Name | Type | Description | Labels |
|---|---|---|---|
tenant_isolation_requests_total | Counter | Total requests processed | operation, status |
tenant_isolation_errors_total | Counter | Request errors by type | error_type, tenant_id |
tenant_isolation_duration_ms | Histogram | Request processing duration | operation, status |
tenant_isolation_active_tenant_count | Gauge | Active tenants in system | environment |
tenant_isolation_data_access_count | Counter | Data access operations | tenant_id, operation_type |
Labels Design:
operation: create_tenant, validate_access, filter_datastatus: success, error, timeouterror_type: invalid_tenant, access_denied, internal_errorenvironment: production, staging, development
Health Check Endpoint Design
/health:
- Status: 200 OK (healthy) or 503 Service Unavailable (unhealthy)
- Response: {"status": "healthy", "timestamp": "...", "checks": {...}}
- Checks:
- Database connectivity (tenant_isolation_db)
- Cache availability (redis_tenant_cache)
- External dependency health (auth_service)
/ready:
- Status: 200 OK (ready) or 503 Service Unavailable (not ready)
- Response: {"status": "ready", "timestamp": "..."}
- Checks:
- All required services are reachable
- Cache is initialized
- Database pool has available connections
/live:
- Status: 200 OK (live) or 503 Service Unavailable (unhealthy)
- Response: {"status": "live", "timestamp": "..."}
- Checks:
- Service process is running
- Memory usage below threshold (e.g., 80%)
- CPU utilization is normal
Alerting Thresholds
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
tenant_isolation_errors_total (rate over 5m) | > 10 errors/min | > 50 errors/min | On-call engineer, SRE team |
tenant_isolation_duration_ms (p95) | > 500ms | > 2s | On-call engineer, SRE team |
tenant_isolation_requests_total (rate) | > 10k req/sec | > 50k req/sec | SRE team, product team |
tenant_isolation_data_access_count (rate) | > 100k access/sec | > 500k access/sec | SRE team, security team |
tenant_isolation_active_tenant_count (change rate) | > 100% in 5m | > 300% in 5m | SRE team, product team |
Alerts will be configured with:
- Slack notifications for warning level
- PagerDuty escalation for critical level
- Email alerts to engineering leadership for critical incidents
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Tenant scope granularity: Should tenants be isolated at database level, schema level, or table level? This decision impacts the complexity of cross-tenant queries and data migration strategies.
- Cross-tenant data access policy: Will tenants be allowed to access shared data or other tenants' data? If so, what's the scope and how is it controlled?
- Multi-tenancy model: Is this a single-tenant-per-database or multi-tenant-per-database model? This affects the complexity of tenant provisioning and migration strategies.
- Tenant lifecycle management: How are tenants created, migrated, or deleted? What's the process for handling tenant data archival and compliance requirements?
Known trade-offs and justifications
- Performance vs. Isolation: Stronger tenant isolation (e.g., separate databases) provides better security and performance isolation but increases operational complexity, cost, and management overhead. Weighed against the need for shared resources and reduced infrastructure costs.
- Flexibility vs. Simplicity: Supporting multiple isolation strategies (database, schema, table) provides flexibility but increases complexity in the framework and requires more sophisticated tenant management logic.
- Consistency vs. Availability: Enforcing strict consistency across all tenant data operations (e.g., using distributed transactions) provides strong guarantees but can impact availability and performance under high load or network issues.
- Security vs. Performance: Implementing strict tenant data access controls and validation at every layer adds security but can introduce latency and complexity in the request handling pipeline.
Risks and mitigation strategies
- Cross-Tenant Data Leakage: Risk of data leakage between tenants due to improper filtering or configuration errors. Mitigation includes: comprehensive unit and integration testing, strict access control validation, regular security audits, and automated compliance checks.
- Performance Degradation: Risk of one tenant's heavy usage impacting others. Mitigation includes: implementing resource quotas and limits, using database-level query optimization, and monitoring tenant-specific metrics for anomaly detection.
- Tenant Data Migration Failures: Risk of data loss or corruption during tenant migration. Mitigation includes: implementing robust backup and rollback strategies, using atomic operations where possible, and maintaining detailed audit trails for recovery.
- Scalability Bottlenecks: Risk of framework becoming a bottleneck as tenant count increases. Mitigation includes: implementing efficient caching strategies, using asynchronous processing where appropriate, and designing for horizontal scaling.
- Configuration Drift: Risk of inconsistent tenant configurations leading to security or performance issues. Mitigation includes: centralized configuration management, automated validation checks, and enforced configuration versioning.
Technical debt acknowledged
- Framework Extensibility: The current design assumes a fixed set of tenant isolation strategies, which may not accommodate future requirements without significant refactoring. This is acceptable for MVP but needs to be revisited for long-term maintainability.
- Tenant Metadata Management: The current implementation stores tenant metadata in a centralized location, which can become a single point of failure and performance bottleneck at scale. This is a known limitation with mitigation through eventual consistency patterns and distributed metadata stores.
- Cross-Tenant Query Complexity: Supporting cross-tenant queries introduces complexity in query planning and execution that is not fully addressed in the current design. This is a trade-off for the flexibility of multi-tenant architecture that will need to be revisited for high-volume cross-tenant operations.
- Tenant-Specific Configuration: The framework currently assumes all tenants follow the same configuration patterns, which may not scale well for highly customized tenant needs. This is a design limitation that can be addressed through configuration inheritance and override mechanisms in future iterations.