Architecture fundamentals
This series builds a multi-tenant SaaS from scratch. Part 1 covers the decisions I make before writing any feature code: the stack, the project skeleton, and the initial database schema.
Series Overview
The six parts:
- Part 1: Architecture Fundamentals (This tutorial)
- Part 2: Multi-Tenant Architecture
- Part 3: Database Design and Caching
- Part 4: Authentication and Security
- Part 5: Performance and Optimization
- Part 6: Billing and Deployment
What You’ll Learn in This Part
- Core principles of SaaS architecture
- Choosing the right technology stack
- Setting up your development environment
- Creating the initial project structure
- Understanding scalability patterns
Understanding SaaS Architecture Fundamentals
Core Principles of SaaS Design
A handful of principles drive most of the decisions in this series:
1. Multi-Tenancy
A single application instance serves multiple customers (tenants) while keeping their data isolated and secure.
2. Scalability
Your architecture must handle growth without requiring fundamental changes. This means:
- Horizontal scaling: Adding more servers to handle load
- Vertical scaling: Upgrading server resources
- Auto-scaling: Automatically adjusting resources based on demand
3. High Availability
Achieving 99.9%+ uptime through:
- Redundancy: Multiple instances of critical components
- Load balancing: Distributing traffic across servers
- Failover mechanisms: Automatic switching to backup systems
4. Performance
Sub-second response times regardless of load through:
- Caching strategies: Multi-layer caching
- Database optimization: Indexes, query optimization
- CDN usage: Serving static assets from edge locations
5. Security
Data isolation and protection at every layer:
- Authentication: Verifying user identity
- Authorization: Controlling access to resources
- Encryption: Protecting data at rest and in transit
- Audit logging: Tracking all system activities
6. Maintainability
Easy updates without customer disruption:
- Microservices architecture: Independent service deployment
- Blue-green deployments: Zero-downtime updates
- Feature flags: Gradual feature rollout
Choosing Your Technology Stack
This is the stack I use. Swap pieces if you have reasons; the structure matters more than the brand names.
Frontend Stack
export const frontendStack = { framework: 'Next.js 14', ui: { styling: 'Tailwind CSS', components: 'Radix UI', icons: 'Lucide React' }, state: { global: 'Zustand', server: 'TanStack Query (React Query)', forms: 'React Hook Form + Zod' }, testing: { unit: 'Vitest', integration: 'Testing Library', e2e: 'Playwright' }, tooling: { bundler: 'Turbopack', linting: 'ESLint', formatting: 'Prettier' }}Backend Stack
export const backendStack = { runtime: 'Node.js 20 LTS + TypeScript', framework: { primary: 'NestJS', // Enterprise-grade framework alternative: 'Express + TypeScript' // Lightweight option }, api: { rest: 'RESTful API with OpenAPI', graphql: 'Apollo Server (optional)', realtime: 'Socket.io or WebSockets' }, database: { primary: 'PostgreSQL 15', cache: 'Redis 7', search: 'Elasticsearch 8', timeseries: 'TimescaleDB (for analytics)' }, queue: { primary: 'BullMQ', alternative: 'AWS SQS' }, storage: { files: 'AWS S3 or MinIO', cdn: 'CloudFront or Cloudflare' }}Infrastructure Stack
export const infrastructureStack = { hosting: { cloud: 'AWS / Google Cloud / Azure', alternative: 'DigitalOcean / Linode' }, containers: { runtime: 'Docker', orchestration: 'Kubernetes (K8s)', registry: 'Docker Hub / AWS ECR' }, monitoring: { apm: 'Datadog / New Relic', logs: 'ELK Stack (Elasticsearch, Logstash, Kibana)', metrics: 'Prometheus + Grafana', errors: 'Sentry' }, ci_cd: { pipeline: 'GitHub Actions / GitLab CI', deployment: 'ArgoCD / Flux', secrets: 'HashiCorp Vault / AWS Secrets Manager' }}Setting Up the Development Environment
Step 1: Initialize the Monorepo
We’ll use a monorepo structure to manage all our services:
# Create project directorymkdir saas-platform && cd saas-platform
# Initialize pnpm workspacepnpm init
# Create workspace configurationcat > pnpm-workspace.yaml << EOFpackages: - 'apps/*' - 'packages/*' - 'services/*'EOF
# Create directory structuremkdir -p apps/web apps/admin packages/shared packages/database services/api services/workerStep 2: Set Up the API Service
# Navigate to API servicecd services/api
# Initialize NestJS projectnpx @nestjs/cli new . --package-manager pnpm --skip-install
# Install dependenciespnpm add @nestjs/config @nestjs/jwt @nestjs/passportpnpm add @prisma/client prismapnpm add bcrypt class-validator class-transformerpnpm add -D @types/bcryptStep 3: Configure TypeScript
Create a base TypeScript configuration:
{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src", "baseUrl": ".", "paths": { "@/*": ["src/*"], "@shared/*": ["../../packages/shared/src/*"], "@database/*": ["../../packages/database/src/*"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true }, "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]}Step 4: Database Setup with Prisma
# Initialize Prismacd packages/databasepnpm initpnpm add @prisma/clientpnpm add -D prisma
# Initialize Prisma schemanpx prisma initCreate the initial schema:
generator client { provider = "prisma-client-js" output = "../node_modules/.prisma/client"}
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
// Core tenant modelmodel Tenant { id String @id @default(uuid()) slug String @unique @db.VarChar(63) name String @db.VarChar(255) status TenantStatus @default(ACTIVE) subscriptionTier SubscriptionTier @default(FREE) subscriptionExpiresAt DateTime? settings Json @default("{}") metadata Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
users User[] projects Project[] auditLogs AuditLog[]
@@index([slug]) @@index([status]) @@index([subscriptionTier, subscriptionExpiresAt])}
model User { id String @id @default(uuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) email String @db.VarChar(255) passwordHash String name String? @db.VarChar(255) role UserRole @default(MEMBER) status UserStatus @default(ACTIVE) lastLoginAt DateTime? twoFactorEnabled Boolean @default(false) twoFactorSecret String? settings Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
projects ProjectUser[] auditLogs AuditLog[] sessions Session[]
@@unique([tenantId, email]) @@index([tenantId, email]) @@index([tenantId, role])}
model Project { id String @id @default(uuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) name String @db.VarChar(255) description String? status ProjectStatus @default(ACTIVE) settings Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
users ProjectUser[]
@@index([tenantId]) @@index([tenantId, status])}
model ProjectUser { id String @id @default(uuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) role ProjectRole @default(VIEWER) joinedAt DateTime @default(now())
@@unique([projectId, userId]) @@index([projectId]) @@index([userId])}
model Session { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) token String @unique ipAddress String? userAgent String? expiresAt DateTime createdAt DateTime @default(now())
@@index([userId]) @@index([token])}
model AuditLog { id BigInt @id @default(autoincrement()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) action String @db.VarChar(100) entityType String? @db.VarChar(50) entityId String? @db.VarChar(255) oldValues Json? newValues Json? ipAddress String? userAgent String? createdAt DateTime @default(now())
@@index([tenantId]) @@index([tenantId, userId]) @@index([tenantId, action]) @@index([createdAt])}
// Enumsenum TenantStatus { ACTIVE SUSPENDED CANCELLED}
enum SubscriptionTier { FREE STARTER PRO ENTERPRISE}
enum UserStatus { ACTIVE INACTIVE SUSPENDED}
enum UserRole { OWNER ADMIN MEMBER VIEWER}
enum ProjectStatus { ACTIVE ARCHIVED DELETED}
enum ProjectRole { OWNER EDITOR VIEWER}Creating the Initial Project Structure
API Service Structure
import { NestFactory } from '@nestjs/core';import { ValidationPipe } from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { AppModule } from './app.module';import { setupSwagger } from './setup-swagger';
async function bootstrap() { const app = await NestFactory.create(AppModule);
// Get config service const configService = app.get(ConfigService);
// Global prefix app.setGlobalPrefix('api/v1');
// Enable CORS app.enableCors({ origin: configService.get('CORS_ORIGINS')?.split(',') || '*', credentials: true, });
// Global validation pipe app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, transformOptions: { enableImplicitConversion: true, }, }), );
// Setup Swagger documentation if (configService.get('NODE_ENV') !== 'production') { setupSwagger(app); }
const port = configService.get('PORT') || 3000; await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}/api/v1`); console.log(`📚 Swagger documentation: http://localhost:${port}/api-docs`);}
bootstrap();Environment Configuration
export default () => ({ node_env: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '3000', 10),
database: { url: process.env.DATABASE_URL, pool_size: parseInt(process.env.DATABASE_POOL_SIZE || '10', 10), },
redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), password: process.env.REDIS_PASSWORD, },
jwt: { secret: process.env.JWT_SECRET, access_expiry: process.env.JWT_ACCESS_EXPIRY || '15m', refresh_expiry: process.env.JWT_REFRESH_EXPIRY || '30d', },
cors: { origins: process.env.CORS_ORIGINS || 'http://localhost:3001', },
rate_limit: { window_ms: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes max_requests: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), },});Understanding Scalability Patterns
1. Horizontal Scaling Pattern
apiVersion: apps/v1kind: Deploymentmetadata: name: saas-apispec: replicas: 3 # Start with 3 instances selector: matchLabels: app: saas-api template: metadata: labels: app: saas-api spec: containers: - name: api image: saas-api:latest ports: - containerPort: 3000 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" env: - name: NODE_ENV value: "production" - name: DATABASE_URL valueFrom: secretKeyRef: name: db-secret key: url2. Auto-scaling Configuration
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: saas-api-hpaspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: saas-api minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Percent value: 100 # Double the pods periodSeconds: 60 - type: Pods value: 4 # Or add 4 pods periodSeconds: 60 selectPolicy: Max scaleDown: stabilizationWindowSeconds: 300 policies: - type: Percent value: 50 # Remove 50% of pods periodSeconds: 3003. Load Balancing
upstream api_backend { least_conn; # Use least connection method
server api1.internal:3000 weight=5; server api2.internal:3000 weight=5; server api3.internal:3000 weight=5;
# Health check keepalive 32;}
server { listen 80; server_name api.saas-platform.com;
location / { proxy_pass http://api_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s;
# Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; proxy_busy_buffers_size 8k; }
# Health check endpoint location /health { access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; }}Development Workflow
1. Local Development Setup
# Clone the repositorygit clone https://github.com/your-org/saas-platform.gitcd saas-platform
# Install dependenciespnpm install
# Setup environment variablescp .env.example .env.local
# Start PostgreSQL and Redis with Dockerdocker-compose up -d postgres redis
# Run database migrationspnpm --filter @saas/database migrate:dev
# Start development serverspnpm dev2. Docker Compose for Local Development
version: '3.8'
services: postgres: image: postgres:15-alpine container_name: saas_postgres environment: POSTGRES_USER: saas_user POSTGRES_PASSWORD: saas_password POSTGRES_DB: saas_dev ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U saas_user"] interval: 10s timeout: 5s retries: 5
redis: image: redis:7-alpine container_name: saas_redis ports: - "6379:6379" volumes: - redis_data:/data command: redis-server --appendonly yes healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5
mailhog: image: mailhog/mailhog container_name: saas_mailhog ports: - "1025:1025" # SMTP server - "8025:8025" # Web UI environment: MH_STORAGE: memory MH_SMTP_BIND_ADDR: 0.0.0.0:1025 MH_API_BIND_ADDR: 0.0.0.0:8025 MH_UI_BIND_ADDR: 0.0.0.0:8025
volumes: postgres_data: redis_data:Conventions
1. Code Organization
- Modular architecture: Separate concerns into modules
- Dependency injection: Use NestJS’s built-in DI container
- Repository pattern: Abstract database operations
- Service layer: Business logic separate from controllers
2. Error Handling
export class BusinessException extends Error { constructor( public readonly code: string, public readonly message: string, public readonly statusCode: number = 400, public readonly details?: any ) { super(message); this.name = 'BusinessException'; }}
// Usagethrow new BusinessException( 'TENANT_LIMIT_EXCEEDED', 'Your plan does not support more users', 403, { currentLimit: 5, requested: 6 });3. Logging Strategy
import { Injectable, LoggerService } from '@nestjs/common';import * as winston from 'winston';
@Injectable()export class CustomLoggerService implements LoggerService { private logger: winston.Logger;
constructor() { this.logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ), }), new winston.transports.File({ filename: 'logs/error.log', level: 'error', }), new winston.transports.File({ filename: 'logs/combined.log', }), ], }); }
log(message: string, context?: string) { this.logger.info(message, { context }); }
error(message: string, trace?: string, context?: string) { this.logger.error(message, { trace, context }); }
warn(message: string, context?: string) { this.logger.warn(message, { context }); }
debug(message: string, context?: string) { this.logger.debug(message, { context }); }}Summary and Next Steps
That covers the stack, the monorepo skeleton, the initial Prisma schema, and the scaling patterns the rest of the series builds on.
What’s Next?
Part 2: Multi-Tenant Architecture covers:
- Implementing different multi-tenancy strategies
- Tenant isolation and data security
- Dynamic tenant provisioning
- Tenant-aware middleware and context management
Resources
Practice Exercise
Before moving to Part 2, try implementing:
- Create a simple health check endpoint
- Add request logging middleware
- Implement a basic rate limiter
- Set up Swagger documentation