Building Scalable SaaS Applications - Part 1

Building Scalable SaaS - Part 1: Architecture Fundamentals

ST

Surendra Tamang

25 min read intermediate

Prerequisites

  • Basic understanding of web development
  • Familiarity with JavaScript/TypeScript
  • Knowledge of databases (SQL)

Building Scalable SaaS Applications: Architecture Fundamentals

Welcome to this comprehensive tutorial series on building production-ready SaaS applications. In this first part, we’ll establish the foundation by understanding core SaaS principles and choosing the right technology stack.

Series Overview

This tutorial series will guide you through:

  1. Part 1: Architecture Fundamentals (This tutorial)
  2. Part 2: Multi-Tenant Architecture
  3. Part 3: Database Design and Caching
  4. Part 4: Authentication and Security
  5. Part 5: Performance and Optimization
  6. 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

Building a successful SaaS application requires understanding these fundamental principles:

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

Let’s build a modern, battle-tested technology stack for our SaaS application:

Frontend Stack

tech-stack/frontend.config.ts
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

tech-stack/backend.config.ts
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

tech-stack/infrastructure.config.ts
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:

Terminal window
# Create project directory
mkdir saas-platform && cd saas-platform
# Initialize pnpm workspace
pnpm init
# Create workspace configuration
cat > pnpm-workspace.yaml << EOF
packages:
- 'apps/*'
- 'packages/*'
- 'services/*'
EOF
# Create directory structure
mkdir -p apps/web apps/admin packages/shared packages/database services/api services/worker

Step 2: Set Up the API Service

Terminal window
# Navigate to API service
cd services/api
# Initialize NestJS project
npx @nestjs/cli new . --package-manager pnpm --skip-install
# Install dependencies
pnpm add @nestjs/config @nestjs/jwt @nestjs/passport
pnpm add @prisma/client prisma
pnpm add bcrypt class-validator class-transformer
pnpm add -D @types/bcrypt

Step 3: Configure TypeScript

Create a base TypeScript configuration:

tsconfig.base.json
{
"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

Terminal window
# Initialize Prisma
cd packages/database
pnpm init
pnpm add @prisma/client
pnpm add -D prisma
# Initialize Prisma schema
npx prisma init

Create the initial schema:

packages/database/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Core tenant model
model 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])
}
// Enums
enum 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

services/api/src/main.ts
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

services/api/src/config/configuration.ts
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

infrastructure/kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: saas-api
spec:
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: url

2. Auto-scaling Configuration

infrastructure/kubernetes/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: saas-api-hpa
spec:
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: 300

3. Load Balancing

infrastructure/nginx/nginx.conf
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

Terminal window
# Clone the repository
git clone https://github.com/your-org/saas-platform.git
cd saas-platform
# Install dependencies
pnpm install
# Setup environment variables
cp .env.example .env.local
# Start PostgreSQL and Redis with Docker
docker-compose up -d postgres redis
# Run database migrations
pnpm --filter @saas/database migrate:dev
# Start development servers
pnpm dev

2. Docker Compose for Local Development

docker-compose.yml
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:

Best Practices and Tips

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

common/exceptions/business-exception.ts
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';
}
}
// Usage
throw new BusinessException(
'TENANT_LIMIT_EXCEEDED',
'Your plan does not support more users',
403,
{ currentLimit: 5, requested: 6 }
);

3. Logging Strategy

common/logging/logger.service.ts
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

Congratulations! You’ve completed Part 1 of our SaaS tutorial series. You now have:

✅ Understanding of core SaaS principles
✅ A modern technology stack
✅ Initial project structure
✅ Database schema design
✅ Development environment setup
✅ Scalability patterns knowledge

What’s Next?

In Part 2: Multi-Tenant Architecture, we’ll dive deep into:

  • 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:

  1. Create a simple health check endpoint
  2. Add request logging middleware
  3. Implement a basic rate limiter
  4. Set up Swagger documentation

Happy coding! 🚀