Back to blog
BackendArchitectureNode.jsTypeScriptPatterns
Backend Architecture Patterns for Scalable Systems
Exploring proven backend architecture patterns — from clean architecture to event-driven systems — with real-world examples from production deployments.
4 min read·Talha Bilal

Why Architecture Matters
As systems grow, architecture becomes the difference between a codebase that scales gracefully and one that collapses under its own weight. Let's explore patterns that have proven effective in production.
The Three Pillars
1. Layered Architecture
The most fundamental pattern separates code into distinct layers:
text
1┌─────────────────────────────────────┐2│ Presentation Layer │3│ (API routes, controllers, guards) │4├─────────────────────────────────────┤5│ Application Layer │6│ (use-cases, DTOs, ports) │7├─────────────────────────────────────┤8│ Domain Layer │9│ (entities, value objects, rules) │10├─────────────────────────────────────┤11│ Infrastructure Layer │12│ (DB, cache, external services) │13└─────────────────────────────────────┘2. Repository Pattern
The repository pattern abstracts data access behind an interface:
typescript
1interface TicketRepository {2 findById(id: string): Promise<Ticket | null>;3 findByStatus(status: TicketStatus): Promise<Ticket[]>;4 save(ticket: Ticket): Promise<void>;5 delete(id: string): Promise<void>;6}This makes it trivial to swap databases, add caching, or write unit tests without touching business logic.
3. Event-Driven Architecture
For systems that need to react to changes, event-driven patterns are essential:
Rendering diagram...
Building a Production API
Error Handling
Consistent error handling is critical for maintainability:
typescript
1export class AppError extends Error {2 constructor(3 public readonly code: string,4 message: string,5 public readonly statusCode: number = 500,6 public readonly details?: unknown,7 ) {8 super(message);9 this.name = "AppError";10 }11}12
13export function handleError(error: unknown): Response {14 if (error instanceof AppError) {15 return Response.json(16 { error: error.message, code: error.code, details: error.details },17 { status: error.statusCode },18 );19 }20
21 console.error("Unhandled error:", error);22 return Response.json(23 { error: "Internal server error" },24 { status: 500 },25 );26}Input Validation
Use Zod for runtime validation combined with TypeScript for compile-time safety:
typescript
1import { z } from "zod";2
3export const CreateTicketSchema = z.object({4 title: z.string().min(1).max(200),5 description: z.string().min(10).max(5000),6 priority: z.enum(["low", "medium", "high", "critical"]).optional(),7 customerEmail: z.string().email(),8});9
10export type CreateTicketInput = z.infer<typeof CreateTicketSchema>;Common Pitfalls
| Pitfall | Solution | Why |
|---|---|---|
| God objects | Single Responsibility Principle | Keeps code focused and testable |
| Leaky abstractions | Clean interface design | Prevents implementation details from spreading |
| Premature optimization | Measure first | 90% of bottlenecks are in 10% of the code |
| No observability | Structured logging + tracing | You can't fix what you can't see |
Database Design
Migrations with Drizzle
typescript
1import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";2
3export const tickets = pgTable("tickets", {4 id: serial("id").primaryKey(),5 title: text("title").notNull(),6 description: text("description").notNull(),7 status: text("status").notNull().default("open"),8 createdAt: timestamp("created_at").notNull().defaultNow(),9 updatedAt: timestamp("updated_at").notNull().defaultNow(),10});Pro tip: Always use timestamp with timezone (timestamptz) in production databases to avoid timezone-related bugs.
Request Lifecycle
A complete request lifecycle through the layered architecture:
Rendering diagram...
Database Migration State Machine
Managing database schema changes across environments requires careful orchestration:
Rendering diagram...
Caching Strategy
A multi-layer caching approach balances freshness with performance:
- Application cache — in-memory for hot data (5 min TTL)
- Redis cache — shared across instances (15 min TTL)
- Database — source of truth
typescript
1async function getTicket(id: string): Promise<Ticket> {2 const cached = await cache.get(`ticket:${id}`);3 if (cached) return JSON.parse(cached);4
5 const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });6 if (!ticket) throw new NotFoundError("Ticket not found");7
8 await cache.set(`ticket:${id}`, JSON.stringify(ticket), "EX", 900);9 return ticket;10}Key Takeaways
- Start with a layered architecture and evolve toward event-driven as needed
- Use the repository pattern to keep business logic database-agnostic
- Validate inputs at the boundary, not throughout the codebase
- Build observability in from day one
- Cache aggressively but invalidate thoughtfully
Building a scalable backend? Let's talk about your architecture. Reach out through the contact form.
