# Multi-Tenant System Complete guide to the multi-tenant architecture supporting custom domains, subdomains, and path-based routing. ## Overview The Wizamart platform supports **three deployment modes** for multi-tenancy, allowing each store to have their own isolated shop while sharing the same application instance and database. **Key Concept**: One application, multiple isolated store shops, each accessible via different URLs. **Important Distinction:** - **Store Dashboard** (all modes): `/store/{code}/*` (singular) - Management interface for stores - **Shop Storefront** (path-based only): `/stores/{code}/shop/*` (plural) - Customer-facing shop - This naming distinction helps separate administrative routes from public-facing shop routes ## The Three Routing Modes ### 1. Custom Domain Mode **Concept**: Each store has their own domain pointing to the platform. **Example**: ``` customdomain1.com → Store 1 Shop anothershop.com → Store 2 Shop beststore.net → Store 3 Shop ``` **How it works**: 1. Store registers a custom domain 2. Domain's DNS is configured to point to the platform 3. Platform detects store by matching domain in database 4. Store's shop is displayed with their theme/branding **Use Case**: Professional stores who want their own branded domain **Configuration**: ```python # Database: store_domains table store_id | domain ----------|------------------ 1 | customdomain1.com 2 | anothershop.com 3 | beststore.net ``` ### 2. Subdomain Mode **Concept**: Each store gets a subdomain of the platform domain. **Example**: ``` store1.platform.com → Store 1 Shop store2.platform.com → Store 2 Shop store3.platform.com → Store 3 Shop admin.platform.com → Admin Interface ``` **How it works**: 1. Store is assigned a unique code (e.g., "store1") 2. Subdomain is automatically available: `{code}.platform.com` 3. Platform detects store from subdomain prefix 4. No DNS configuration needed by store **Use Case**: Easy setup, no custom domain required **Configuration**: ```python # Stores table id | code | name ---|---------|---------- 1 | store1 | Store One Shop 2 | store2 | Store Two Shop 3 | store3 | Store Three Shop ``` ### 3. Path-Based Mode **Concept**: All stores share the same domain, differentiated by URL path. **Example**: ``` platform.com/stores/store1/shop → Store 1 Shop platform.com/stores/store2/shop → Store 2 Shop platform.com/stores/store3/shop → Store 3 Shop ``` **How it works**: 1. URL path includes store code 2. Platform extracts store code from path 3. Path is rewritten for routing 4. All stores on same domain **Use Case**: Development and testing environments only **Path Patterns**: - `/stores/{code}/shop/*` - Storefront pages (correct pattern) - `/store/{code}/*` - Store dashboard pages (different context) ## Routing Mode Comparison | Feature | Custom Domain | Subdomain | Path-Based | |---------|---------------|-----------|------------| | **Professionalism** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | | **Setup Complexity** | High (DNS required) | Low (automatic) | Very Low | | **SSL Complexity** | Medium (wildcard or per-domain) | Low (wildcard SSL) | Very Low (single cert) | | **SEO Benefits** | Best (own domain) | Good | Limited | | **Cost** | High (domain + SSL) | Low (wildcard SSL) | Lowest | | **Isolation** | Best (separate domain) | Good | Good | | **URL Appearance** | `shop.com` | `shop.platform.com` | `platform.com/store/shop` | ## Implementation Details ### Store Detection Logic The `StoreContextMiddleware` detects stores using this priority: ```python def detect_store(request): host = request.headers.get("host") # 1. Try custom domain first store = find_by_custom_domain(host) if store: return store, "custom_domain" # 2. Try subdomain if host != settings.platform_domain: store_code = host.split('.')[0] store = find_by_code(store_code) if store: return store, "subdomain" # 3. Try path-based path = request.url.path if path.startswith("/store/") or path.startswith("/stores/"): store_code = extract_code_from_path(path) store = find_by_code(store_code) if store: return store, "path_based" return None, None ``` ### Path Extraction For path-based routing, clean paths are extracted: **Path-Based Shop Routes** (Development): ``` Original: /stores/WIZAMART/shop/products Extracted: store_code = "WIZAMART" Clean: /shop/products ``` **Store Dashboard Routes** (All environments): ``` Original: /store/WIZAMART/dashboard Extracted: store_code = "WIZAMART" Clean: /dashboard ``` **Note**: The shop storefront uses `/stores/` (plural) while the store dashboard uses `/store/` (singular). This distinction helps separate customer-facing shop routes from store management routes. **Why Clean Path?** - FastAPI routes don't include store prefix - Routes defined as: `@app.get("/shop/products")` - Path must be rewritten to match routes ## Database Schema ### Stores Table ```sql CREATE TABLE stores ( id SERIAL PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, -- For subdomain/path routing name VARCHAR(255) NOT NULL, is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW() ); ``` ### Store Domains Table ```sql CREATE TABLE store_domains ( id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id), domain VARCHAR(255) UNIQUE NOT NULL, -- Custom domain is_verified BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW() ); ``` **Example Data**: ```sql -- Stores INSERT INTO stores (code, name) VALUES ('wizamart', 'Wizamart Shop'), ('techstore', 'Tech Store'), ('fashionhub', 'Fashion Hub'); -- Custom Domains INSERT INTO store_domains (store_id, domain) VALUES (1, 'wizamart.com'), (2, 'mytechstore.net'); ``` ## Deployment Scenarios ### Scenario 1: Small Platform (Path-Based) **Setup**: - Single domain: `myplatform.com` - All stores use path-based routing - Single SSL certificate - Simplest infrastructure **URLs**: ``` myplatform.com/admin myplatform.com/stores/shop1/shop myplatform.com/stores/shop2/shop myplatform.com/stores/shop3/shop ``` **Infrastructure**: ``` [Internet] → [Single Server] → [PostgreSQL] myplatform.com ``` ### Scenario 2: Medium Platform (Subdomain) **Setup**: - Main domain: `myplatform.com` - Stores get subdomains automatically - Wildcard SSL certificate (`*.myplatform.com`) - Better branding for stores **URLs**: ``` admin.myplatform.com shop1.myplatform.com shop2.myplatform.com shop3.myplatform.com ``` **Infrastructure**: ``` [Internet] → [Load Balancer] → [App Servers] → [PostgreSQL] *.myplatform.com ``` ### Scenario 3: Large Platform (Mixed Mode) **Setup**: - Supports all three modes - Premium stores get custom domains - Regular stores use subdomains - Free tier uses path-based **URLs**: ``` # Custom domains (premium) customdomain.com → Store 1 anotherdomain.com → Store 2 # Subdomains (standard) shop3.myplatform.com → Store 3 shop4.myplatform.com → Store 4 # Path-based (free tier) myplatform.com/stores/shop5/shop → Store 5 myplatform.com/stores/shop6/shop → Store 6 ``` **Infrastructure**: ``` [CDN/Load Balancer] | +-----------------+------------------+ | | | [App Server 1] [App Server 2] [App Server 3] | | | +-----------------+------------------+ | [PostgreSQL Cluster] ``` ## DNS Configuration ### For Custom Domains **Store Side**: ``` # DNS A Record customdomain.com. A 203.0.113.10 (platform IP) # Or CNAME customdomain.com. CNAME myplatform.com. ``` **Platform Side**: - Add domain to `store_domains` table - Generate SSL certificate (Let's Encrypt) - Verify domain ownership ### For Subdomains **Platform Side**: ``` # Wildcard DNS *.myplatform.com. A 203.0.113.10 # Or individual subdomains shop1.myplatform.com. A 203.0.113.10 shop2.myplatform.com. A 203.0.113.10 ``` **SSL Certificate**: ```bash # Wildcard certificate *.myplatform.com myplatform.com ``` ## Tenant Isolation ### Data Isolation Every database query is scoped to `store_id`: ```python # Example: Get products for current store products = db.query(Product).filter( Product.store_id == request.state.store_id ).all() # Example: Create order for store order = Order( store_id=request.state.store_id, customer_id=customer_id, # ... other fields ) ``` **Critical**: ALWAYS filter by `store_id` in queries! ### Theme Isolation Each store has independent theme settings: ```python # Store 1 theme { "primary_color": "#3B82F6", "logo_url": "/static/stores/store1/logo.png" } # Store 2 theme { "primary_color": "#10B981", "logo_url": "/static/stores/store2/logo.png" } ``` ### File Storage Isolation Store files stored in separate directories: ``` static/ └── stores/ ├── store1/ │ ├── logo.png │ ├── favicon.ico │ └── products/ │ ├── product1.jpg │ └── product2.jpg └── store2/ ├── logo.png └── products/ └── product1.jpg ``` ## Request Examples ### Example 1: Custom Domain Request **Request**: ```http GET /shop/products HTTP/1.1 Host: customdomain.com ``` **Processing**: ``` 1. StoreContextMiddleware - Checks: domain = "customdomain.com" - Queries: store_domains WHERE domain = "customdomain.com" - Finds: store_id = 1 - Sets: request.state.store = 2. ContextDetectionMiddleware - Analyzes: path = "/shop/products" - Sets: context_type = SHOP 3. ThemeContextMiddleware - Queries: store_themes WHERE store_id = 1 - Sets: request.state.theme = {...} 4. Route Handler - Queries: products WHERE store_id = 1 - Renders: template with Store 1 theme ``` ### Example 2: Subdomain Request **Request**: ```http GET /shop/products HTTP/1.1 Host: wizamart.myplatform.com ``` **Processing**: ``` 1. StoreContextMiddleware - Checks: host != "myplatform.com" - Extracts: subdomain = "wizamart" - Queries: stores WHERE code = "wizamart" - Sets: request.state.store = 2-4. Same as Example 1 ``` ### Example 3: Path-Based Request **Request**: ```http GET /stores/WIZAMART/shop/products HTTP/1.1 Host: myplatform.com ``` **Processing**: ``` 1. StoreContextMiddleware - Checks: path starts with "/store/" - Extracts: code = "WIZAMART" - Queries: stores WHERE code = "WIZAMART" - Sets: request.state.store = - Sets: request.state.clean_path = "/shop/products" 2. FastAPI Router - Routes registered with prefix: /stores/{store_code}/shop - Matches: /stores/WIZAMART/shop/products - store_code path parameter = "WIZAMART" 3-4. Same as previous examples (Context, Theme middleware) ``` ## Testing Multi-Tenancy ### Unit Tests ```python def test_store_detection_custom_domain(): request = MockRequest(host="customdomain.com") middleware = StoreContextMiddleware() store, mode = middleware.detect_store(request, db) assert store.code == "store1" assert mode == "custom_domain" def test_store_detection_subdomain(): request = MockRequest(host="shop1.platform.com") middleware = StoreContextMiddleware() store, mode = middleware.detect_store(request, db) assert store.code == "shop1" assert mode == "subdomain" ``` ### Integration Tests ```python def test_shop_page_multi_tenant(client): # Test subdomain routing response = client.get( "/shop/products", headers={"Host": "wizamart.platform.com"} ) assert "Wizamart" in response.text # Test different store response = client.get( "/shop/products", headers={"Host": "techstore.platform.com"} ) assert "Tech Store" in response.text ``` ## Security Considerations ### 1. Tenant Isolation **Always scope queries**: ```python # ✅ Good - Scoped to store products = db.query(Product).filter( Product.store_id == request.state.store_id ).all() # ❌ Bad - Not scoped, leaks data across tenants! products = db.query(Product).all() ``` ### 2. Domain Verification Before activating custom domain: 1. Verify DNS points to platform 2. Check domain ownership (email/file verification) 3. Generate SSL certificate 4. Mark domain as verified ### 3. Input Validation Validate store codes: ```python # Sanitize store code store_code = store_code.lower().strip() # Validate format if not re.match(r'^[a-z0-9-]{3,50}$', store_code): raise ValidationError("Invalid store code") ``` ## Performance Optimization ### 1. Cache Store Lookups ```python # Cache store by domain/code @lru_cache(maxsize=1000) def get_store_by_code(code: str): return db.query(Store).filter(Store.code == code).first() ``` ### 2. Database Indexes ```sql -- Index for fast lookups CREATE INDEX idx_stores_code ON stores(code); CREATE INDEX idx_store_domains_domain ON store_domains(domain); CREATE INDEX idx_products_store_id ON products(store_id); ``` ### 3. Connection Pooling Ensure database connection pool is properly configured: ```python # sqlalchemy engine engine = create_engine( DATABASE_URL, pool_size=20, max_overflow=40, pool_pre_ping=True ) ``` ## Related Documentation - [Middleware Stack](middleware.md) - How store detection works - [Request Flow](request-flow.md) - Complete request journey - [Architecture Overview](overview.md) - System architecture - [Authentication & RBAC](auth-rbac.md) - Multi-tenant security ## Migration Guide ### Adding Multi-Tenancy to Existing Tables ```python # Alembic migration def upgrade(): # Add store_id to existing table op.add_column('products', sa.Column('store_id', sa.Integer(), nullable=True) ) # Set default store for existing data op.execute("UPDATE products SET store_id = 1 WHERE store_id IS NULL") # Make non-nullable op.alter_column('products', 'store_id', nullable=False) # Add foreign key op.create_foreign_key( 'fk_products_store', 'products', 'stores', ['store_id'], ['id'] ) # Add index op.create_index('idx_products_store_id', 'products', ['store_id']) ```