← All Articles

Multi-Tenant AI Architectures for Consulting Firms

If you’re a consulting firm, a software-as-a-service company, or any business serving multiple clients, you can’t build a separate AI system for each one. You need a multi-tenant architecture: one codebase, one set of infrastructure, multiple isolated clients.

This is hard. Most teams get it wrong. They create isolated databases per client, duplicate Lambda functions, or use crude string filtering for data access control. Then they discover their cost model is broken (each tenant is oddly expensive), or security is leaky, or scaling is impossible.

I’ve built multi-tenant AI systems for three consulting clients. Here’s the pattern that works.

The Challenge

You have 20 clients. Each has their own documents to process, their own data, their own outputs.

You need to:

Most architects try to solve this with different databases per tenant. That’s wrong. You’ll end up managing 20 databases, 20 Lambda functions, 20 CloudWatch dashboards. A nightmare.

The Right Model

Single database. Multiple tenants. Partition key strategy.

DynamoDB makes this easy:

Partition Key: tenant_id Sort Key: document_id (or whatever entity you’re storing)

All tenant A’s documents have partition key “tenant-abc”. All tenant B’s documents have partition key “tenant-xyz”.

Your code queries by partition key. Tenant data is automatically isolated.

def get_documents_for_tenant(tenant_id: str): dynamodb = boto3.resource("dynamodb") table = dynamodb.Table("documents") response = table.query( KeyConditionExpression="tenant_id = :tid", ExpressionAttributeValues={":tid": tenant_id} ) return response["Items"] # Tenant A gets only tenant A's documents # Tenant B gets only tenant B's documents # No data leakage

Cost tracking is automatic. You tag every DynamoDB write with the tenant_id, then aggregate billing by tenant.

The Complete Architecture

API Gateway → Lambda (authorization) → Lambda (processing) → DynamoDB ↓ validate tenant_id

Step 1: Authorization

Every request includes a tenant_id. Could come from:

Your first Lambda function validates that the caller has access to this tenant:

def authorize_request(event, context): tenant_id = event.get("pathParameters", {}).get("tenant_id") api_key = event["headers"].get("Authorization", "").replace("Bearer ", "") # Look up tenant from API key kms = boto3.client("kms") result = kms.decrypt(CiphertextBlob=base64.b64decode(api_key)) actual_tenant_id = json.loads(result["Plaintext"])["tenant_id"] if actual_tenant_id != tenant_id: return {"statusCode": 403, "body": "Forbidden"} # Authorization passed; call processing Lambda lambda_client = boto3.client("lambda") lambda_client.invoke( FunctionName="process-document", InvocationType="RequestResponse", Payload=json.dumps(event) )

Step 2: Processing

The processing Lambda always receives an authenticated tenant_id. It processes the document and stores results tagged with the tenant:

def process_document(event, context): tenant_id = event["tenant_id"] # Passed from auth layer document_id = event["document_id"] content = event["content"] # Call Claude client = anthropic.Anthropic() message = client.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1024, messages=[{"role": "user", "content": content}] ) extracted_data = message.content[0].text # Store with tenant_id as partition key dynamodb = boto3.resource("dynamodb") table = dynamodb.Table("documents") table.put_item( Item={ "tenant_id": tenant_id, "document_id": document_id, "extracted_data": extracted_data, "timestamp": int(time.time()) } ) return {"statusCode": 200, "body": "Processed"}

Step 3: DynamoDB Partition Strategy

Your table structure:

resource "aws_dynamodb_table" "documents" { name = "multi-tenant-documents" billing_mode = "PAY_PER_REQUEST" # or PROVISIONED if you have predictable load hash_key = "tenant_id" range_key = "document_id" attribute { name = "tenant_id" type = "S" } attribute { name = "document_id" type = "S" } # Optional: Global Secondary Index for querying by status global_secondary_index { name = "document_status_index" hash_key = "tenant_id" range_key = "status" projection_type = "ALL" } ttl { attribute_name = "expiration_time" enabled = true } }

Every item includes tenant_id. Your queries always filter by tenant. Data isolation is automatic.

Cost Attribution

Multi-tenant systems can be tricky to bill. How much did processing documents for tenant A actually cost?

Use tagging and cost allocation:

# Every API call is tagged with tenant_id cloudwatch.put_metric_data( Namespace="MultiTenantAI", MetricData=[ { "MetricName": "ProcessingCost", "Value": 0.15, # $0.15 per document "Unit": "None", "Dimensions": [ {"Name": "TenantID", "Value": tenant_id}, {"Name": "DocumentType", "Value": "invoice"} ] } ] )

Track:

Aggregate by tenant. You can now tell tenant A exactly what they cost you.

def calculate_tenant_cost(tenant_id: str, month: str) -> dict: """Calculate monthly cost for a tenant.""" cloudwatch = boto3.client("cloudwatch") # Get metrics for this tenant response = cloudwatch.get_metric_statistics( Namespace="MultiTenantAI", MetricName="ProcessingCost", Dimensions=[{"Name": "TenantID", "Value": tenant_id}], StartTime=datetime(2026, 5, 1), EndTime=datetime(2026, 6, 1), Period=86400, # Daily Statistics=["Sum"] ) total_cost = sum(dp["Sum"] for dp in response["Datapoints"]) return {"tenant_id": tenant_id, "month": month, "cost": total_cost}

Rate Limiting and Quotas

Prevent one tenant from overwhelming the system:

def check_tenant_quota(tenant_id: str): """Enforce per-tenant rate limits.""" dynamodb = boto3.resource("dynamodb") quota_table = dynamodb.Table("tenant-quotas") response = quota_table.get_item(Key={"tenant_id": tenant_id}) quota = response.get("Item", {}) monthly_limit = quota.get("documents_per_month", 1000) documents_processed = quota.get("documents_processed_month", 0) if documents_processed >= monthly_limit: return False, f"Quota exceeded: {documents_processed}/{monthly_limit}" return True, "" def process_document(event, context): tenant_id = event["tenant_id"] allowed, error = check_tenant_quota(tenant_id) if not allowed: return {"statusCode": 429, "body": error} # Process normally...

Failure Isolation

If tenant A’s data causes Claude to return garbage, don’t let it crash the system for tenant B.

Use Lambda’s concurrency reservation to isolate tenants:

resource "aws_lambda_function" "process_document" { # ... function config reserved_concurrent_executions = 10 # Max 10 concurrent invocations } # Per-tenant concurrency (requires custom logic) # Only process 2 concurrent documents per tenant at a time

Alternatively, use SQS with per-tenant queues:

resource "aws_sqs_queue" "tenant_queue_a" { name = "documents-tenant-a" } resource "aws_sqs_queue" "tenant_queue_b" { name = "documents-tenant-b" } # Lambda processes from both queues independently # If tenant A's queue backs up, tenant B's queue is unaffected

Terraform Modules for Tenant Provisioning

When you add a new tenant, create a module that provisions all their infrastructure:

module "tenant_a" { source = "./modules/tenant" tenant_id = "tenant-abc" api_key = random_password.api_key_a.result monthly_quota = 10000 # documents per month providers = { aws = aws } } # The module creates: # - IAM role for this tenant's Lambda executions # - SQS queue for this tenant's documents # - CloudWatch log group scoped to this tenant # - DynamoDB Streams trigger (if needed) # - Cost tracking dashboards

The module looks like:

# modules/tenant/main.tf variable "tenant_id" { type = string } variable "api_key" { type = string } variable "monthly_quota" { type = number } resource "aws_iam_role" "tenant_role" { name = "tenant-${var.tenant_id}-execution" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_sqs_queue" "tenant_queue" { name = "documents-${var.tenant_id}" } # When you add a new tenant: # terraform apply # Tenant infrastructure is provisioned

Data Retention and Cleanup

Different tenants might have different data retention policies. Use DynamoDB TTL:

# Store documents with expiration table.put_item( Item={ "tenant_id": tenant_id, "document_id": document_id, "extracted_data": extracted_data, "timestamp": int(time.time()), "expiration_time": int(time.time()) + (90 * 86400) # 90 days } )

DynamoDB automatically deletes expired items. Configure TTL per tenant if retention periods differ.

Scaling Multi-Tenant Systems

With partition key = tenant_id, DynamoDB’s hot partition problem is solved by design. Each tenant’s data is in a separate partition.

But if one tenant’s processing is resource-intensive (e.g., processing 100,000 documents at once), it could impact others.

A few options handle this. Reserve concurrent Lambda capacity per tenant so one client can’t starve the others. Give each tenant their own SQS queue and process it serially. Or route high-volume tenants to a separate Lambda alias entirely, so their load never touches the shared pool.

For most SaaS/consulting use cases, concurrency reservation is enough.

The Bottom Line

Multi-tenant AI systems are not simple. But they’re necessary if you’re serving multiple clients.

The architecture comes down to a few rules. One codebase, one set of infrastructure, deployed once. A tenant_id on every request, filtered at every layer. DynamoDB partitioned by tenant for automatic isolation. Cost tracked and billed per tenant through metrics. Failures contained so one tenant’s problems stay that tenant’s problems. And a Terraform module that provisions a new tenant without touching the others.

Build this right, and you can serve 100 tenants with the same infrastructure that serves one.

Ready to build a multi-tenant AI system? Book a call and I’ll help you design the architecture for serving multiple clients at scale.

Get the free AI Readiness Checklist

15 questions to diagnose your team’s AI readiness, where you’ll see ROI fastest, and what to tackle first.

Takes 5 minutes Actionable next steps No sales pitch

No spam. Unsubscribe anytime.

or

Ready to build AI that actually works?

Let’s talk about how SRE discipline transforms AI from a risky experiment into a reliable business system.

Book Your Free Discovery Call

About the author

Charles Harvey is the founder of Three Moons Network and a site reliability engineer who builds production-grade AI automation for small businesses — monitoring, cost visibility, and documentation included. He writes about his hands-on AI experiments at floggingclaude.com. Connect on LinkedIn or see the code on GitHub.