Back to blog
Backend Architecture for Modern SaaS Applications
A deep dive into scalable backend patterns, database design, and API architecture that power production SaaS platforms.
6 min read·Talha Bilal
Share:

Introduction
Building a SaaS application that scales from 10 to 10,000 users requires thoughtful architecture from day one. Over the past few years working on production systems, I've learned that the right backend architecture isn't about using the latest tech—it's about making pragmatic choices that balance developer velocity with long-term scalability.
In this post, I'll walk through the core architectural patterns I use when building modern SaaS backends, including database design, API structure, background job processing, and caching strategies.
The Foundation: Database Schema Design
The database is the backbone of any SaaS application. Here's how I approach schema design for multi-tenant systems:
Multi-Tenancy Strategy
There are three main approaches to multi-tenancy:
- Shared database, shared schema - All tenants in one database with
tenant_idcolumns - Shared database, separate schemas - One database, separate PostgreSQL schemas per tenant
- Separate databases - Dedicated database per tenant
For most B2B SaaS apps with 100-1000 customers, I recommend option 1 (shared database, shared schema) because:
- Lower operational complexity
- Easier to implement features across all tenants
- Cost-effective for small to mid-size deployments
- Simple backup and migration strategies
typescript
1// Example: Tenant isolation in queries2interface QueryContext {3 tenantId: string;4 userId: string;5}6
7async function getTickets(ctx: QueryContext) {8 return await db.ticket.findMany({9 where: {10 tenantId: ctx.tenantId, // Critical: Always filter by tenant11 assigneeId: ctx.userId,12 },13 });14}Key principle: Every query must filter by
tenantId. I enforce this with database-level Row-Level Security (RLS) policies in PostgreSQL:sql
1-- Enable RLS on the tickets table2ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;3
4-- Policy: Users can only see tickets from their tenant5CREATE POLICY tenant_isolation ON tickets6 USING (tenant_id = current_setting('app.current_tenant_id')::uuid);This provides defense-in-depth: even if application code forgets the tenant filter, the database prevents cross-tenant data leaks.
API Architecture: RESTful Design with GraphQL Where It Matters
I typically use a hybrid approach:
- REST APIs for CRUD operations, webhooks, and integrations
- GraphQL for complex queries with nested relationships
REST API Structure
typescript
1// routes/api/tickets.ts2import { router } from "@/lib/api";3import { requireAuth } from "@/middleware/auth";4import { validateTenant } from "@/middleware/tenant";5
6export default router7 .use(requireAuth)8 .use(validateTenant)9 .get("/api/tickets", async (req, res) => {10 const tickets = await getTickets({11 tenantId: req.tenant.id,12 userId: req.user.id,13 });14 return res.json({ tickets });15 })16 .post("/api/tickets", async (req, res) => {17 const ticket = await createTicket({18 tenantId: req.tenant.id,19 userId: req.user.id,20 data: req.body,21 });22 return res.json({ ticket });23 });Middleware pattern keeps route handlers clean. Authentication and tenant validation happen once, not in every handler.
Background Job Processing
For any operation that takes >500ms, move it to a background job. This keeps your API responsive and improves UX.
I use BullMQ with Redis for job queues:
typescript
1import { Queue, Worker } from "bullmq";2
3// Define job queue4export const emailQueue = new Queue("emails", {5 connection: redis,6 defaultJobOptions: {7 attempts: 3,8 backoff: { type: "exponential", delay: 2000 },9 },10});11
12// Enqueue job (fast, returns immediately)13await emailQueue.add("welcome-email", {14 userId: user.id,15 email: user.email,16});17
18// Worker processes jobs in background19const worker = new Worker("emails", async (job) => {20 if (job.name === "welcome-email") {21 await sendWelcomeEmail(job.data.userId);22 }23});Use cases for background jobs:
- Email sending
- PDF generation
- AI model inference
- Data exports
- Third-party API calls
- Analytics aggregation
Caching Strategies
Cache aggressively, invalidate precisely. I use Redis with a tiered approach:
1. Query-Level Caching
typescript
1async function getUser(userId: string) {2 const cacheKey = `user:${userId}`;3 4 // Try cache first5 const cached = await redis.get(cacheKey);6 if (cached) return JSON.parse(cached);7 8 // Cache miss: fetch from database9 const user = await db.user.findUnique({ where: { id: userId } });10 11 // Store in cache (TTL: 5 minutes)12 await redis.setex(cacheKey, 300, JSON.stringify(user));13 14 return user;15}2. API Response Caching
typescript
1app.get("/api/stats", async (req, res) => {2 const cacheKey = `stats:${req.tenant.id}:${req.user.id}`;3 4 const cached = await redis.get(cacheKey);5 if (cached) {6 res.setHeader("X-Cache", "HIT");7 return res.json(JSON.parse(cached));8 }9 10 const stats = await calculateStats(req.tenant.id);11 await redis.setex(cacheKey, 60, JSON.stringify(stats));12 13 res.setHeader("X-Cache", "MISS");14 return res.json(stats);15});3. Cache Invalidation
The hard part. Use event-driven invalidation:
typescript
1// When user updates profile2async function updateUser(userId: string, data: UserUpdate) {3 const user = await db.user.update({4 where: { id: userId },5 data,6 });7 8 // Invalidate cache9 await redis.del(`user:${userId}`);10 11 // Publish event for other services12 await eventBus.publish("user.updated", { userId });13 14 return user;15}API Rate Limiting
Protect your backend from abuse with rate limiting:
typescript
1import rateLimit from "express-rate-limit";2
3const limiter = rateLimit({4 windowMs: 15 * 60 * 1000, // 15 minutes5 max: 100, // 100 requests per window6 standardHeaders: true,7 legacyHeaders: false,8 keyGenerator: (req) => req.tenant.id, // Rate limit per tenant9});10
11app.use("/api/", limiter);For authenticated APIs, rate limit by tenant + user:
typescript
1const userLimiter = rateLimit({2 windowMs: 60 * 1000, // 1 minute3 max: 60, // 60 requests per minute4 keyGenerator: (req) => `${req.tenant.id}:${req.user.id}`,5});Database Connection Pooling
Critical for scalability. Use connection pooling to avoid exhausting database connections:
typescript
1import { Pool } from "pg";2
3const pool = new Pool({4 host: process.env.DB_HOST,5 database: process.env.DB_NAME,6 max: 20, // Maximum pool size7 idleTimeoutMillis: 30000,8 connectionTimeoutMillis: 2000,9});For serverless environments (Vercel, AWS Lambda), use a connection pooler like PgBouncer or Supabase Pooler to avoid connection storms.
Monitoring and Observability
Instrument your backend from day one:
typescript
1import { Sentry } from "@sentry/node";2import { logger } from "@/lib/logger";3
4app.use((req, res, next) => {5 const start = Date.now();6 7 res.on("finish", () => {8 const duration = Date.now() - start;9 10 logger.info("request_completed", {11 method: req.method,12 path: req.path,13 status: res.statusCode,14 duration,15 tenantId: req.tenant?.id,16 userId: req.user?.id,17 });18 19 // Alert on slow requests20 if (duration > 5000) {21 Sentry.captureMessage("Slow request", {22 level: "warning",23 extra: { path: req.path, duration },24 });25 }26 });27 28 next();29});Key metrics to track:
- API response times (p50, p95, p99)
- Error rates by endpoint
- Database query performance
- Background job success/failure rates
- Cache hit ratios
Error Handling Pattern
Structured error handling improves debugging and user experience:
typescript
1class AppError extends Error {2 constructor(3 public statusCode: number,4 public code: string,5 message: string,6 public metadata?: Record<string, unknown>7 ) {8 super(message);9 }10}11
12// Usage13throw new AppError(404, "TICKET_NOT_FOUND", "Ticket not found", {14 ticketId: req.params.id,15});16
17// Error handler middleware18app.use((err: Error, req, res, next) => {19 if (err instanceof AppError) {20 return res.status(err.statusCode).json({21 error: {22 code: err.code,23 message: err.message,24 },25 });26 }27 28 // Unexpected error: log and return generic message29 logger.error("Unexpected error", { err, path: req.path });30 Sentry.captureException(err);31 32 return res.status(500).json({33 error: {34 code: "INTERNAL_ERROR",35 message: "An unexpected error occurred",36 },37 });38});Key Takeaways
- Design for multi-tenancy from day one - Retrofitting is painful
- Use background jobs for anything >500ms
- Cache aggressively but invalidate precisely
- Rate limit to protect your backend
- Monitor everything - you can't improve what you don't measure
- Handle errors gracefully with structured error types
These patterns have served me well across multiple production SaaS applications. They're not about using the latest tech—they're about building systems that are maintainable, scalable, and reliable.
Questions or thoughts? Reach out through the contact form.
Related Articles

Database Schema Design: Lessons from Production Systems
Practical patterns for designing maintainable, scalable PostgreSQL schemas for SaaS applications with real-world examples.
Read more

Building Real-Time Features with WebSockets in Next.js
How to implement WebSocket communication for live notifications, collaborative editing, and real-time dashboards in modern web applications.
Read more

TypeScript Patterns for Scalable Frontend Applications
Advanced TypeScript techniques for building type-safe, maintainable React applications with real-world examples.
Read more
