Full-stack backend architecture and frontend-backend integration guide.
TRIGGER when: building a full-stack app, creating REST API with frontend, scaffolding backend service,
building todo app, building CRUD app, building real-time app, building chat app,
Express + React, Next.js API, Node.js backend, Python backend, Go backend,
designing service layers, implementing error handling, managing config/auth,
setting up API clients, implementing auth flows, handling file uploads,
adding real-time features (SSE/WebSocket), hardening for production.
DO NOT TRIGGER when: pure frontend UI work, pure CSS/styling, database schema only.
Full-stack backend architecture and frontend-backend integration guide.
TRIGGER when: building a full-stack app, creating REST API with frontend, scaffolding backend service,
building todo app, building CRUD app, building real-time app, building chat app,
Express + React, Next.js API, Node.js backend, Python backend, Go backend,
designing service layers, implementing error handling, managing config/auth,
setting up API clients, implementing auth flows, handling file uploads,
adding real-time features (SSE/WebSocket), hardening for production.
DO NOT TRIGGER when: pure frontend UI work, pure CSS/styling, database schema only.
1. ✅ Organize by FEATURE, not by technical layer
2. ✅ Controllers never contain business logic
3. ✅ Services never import HTTP request/response types
4. ✅ All config from env vars, validated at startup, fail fast
5. ✅ Every error is typed, logged, and returns consistent format
6. ✅ All input validated at the boundary — trust nothing from client
7. ✅ Structured JSON logging with request ID — not console.log
const config = {
port: parseInt(process.env.PORT || '3000', 10),
database: { url: requiredEnv('DATABASE_URL'), poolSize: intEnv('DB_POOL_SIZE', 10) },
auth: { jwtSecret: requiredEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN || '1h' },
} as const;
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`); // fail fast
return value;
}
Python:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str # required — app won't start without it
jwt_secret: str # required
port: int = 3000 # optional with default
db_pool_size: int = 10
class Config:
env_file = ".env"
settings = Settings() # fails fast if DATABASE_URL missing
Rules
✅ All config via environment variables (Twelve-Factor)
✅ Validate required vars at startup — fail fast
✅ Type-cast at config layer, not at usage sites
✅ Commit .env.example with dummy values
❌ Never hardcode secrets, URLs, or credentials
❌ Never commit .env files
❌ Never scatter process.env / os.environ throughout code
3. Error Handling & Resilience (HIGH)
Typed Error Hierarchy
// Base (TypeScript)
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly isOperational: boolean = true,
) { super(message); }
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(public readonly errors: FieldError[]) {
super('Validation failed', 'VALIDATION_ERROR', 422);
}
}
# Base (Python)
class AppError(Exception):
def __init__(self, message: str, code: str, status_code: int):
self.message, self.code, self.status_code = message, code, status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)
✅ Typed, domain-specific error classes
✅ Global error handler catches everything
✅ Operational errors → structured response
✅ Programming errors → log + generic 500
✅ Retry transient failures with exponential backoff
❌ Never catch and ignore errors silently
❌ Never return stack traces to client
❌ Never throw generic Error('something')
4. Database Access Patterns (HIGH)
Migrations Always
# TypeScript (Prisma) # Python (Alembic) # Go (golang-migrate)
npx prisma migrate dev alembic revision --autogenerate migrate -source file://migrations
npx prisma migrate deploy alembic upgrade head migrate -database $DB up
✅ Schema changes via migrations, never manual SQL
✅ Migrations must be reversible
✅ Review migration SQL before production
❌ Never modify production schema manually
N+1 Prevention
// ❌ N+1: 1 query + N queries
const orders = await db.order.findMany();
for (const o of orders) { o.items = await db.item.findMany({ where: { orderId: o.id } }); }
// ✅ Single JOIN query
const orders = await db.order.findMany({ include: { items: true } });
// ✅ Structured — parseable, filterable, alertable
logger.info('Order created', {
orderId: order.id, userId: user.id, total: order.total,
items: order.items.length, duration_ms: Date.now() - startTime,
});
// Output: {"level":"info","msg":"Order created","orderId":"ord_123",...}
// ❌ Unstructured — useless at scale
console.log(`Order created for user ${user.id} with total ${order.total}`);
Log Levels
Level
When
Production?
error
Requires immediate attention
✅ Always
warn
Unexpected but handled
✅ Always
info
Normal operations, audit trail
✅ Always
debug
Dev troubleshooting
❌ Dev only
Rules
✅ Request ID in every log entry (propagated via middleware)
✅ Log at layer boundaries (request in, response out, external call)
❌ Never log passwords, tokens, PII, or secrets
❌ Never use console.log in production code
8. Background Jobs & Async (MEDIUM)
Rules
✅ All jobs must be IDEMPOTENT (same job running twice = same result)
✅ Failed jobs → retry (max 3) → dead letter queue → alert
✅ Workers run as SEPARATE processes (not threads in API server)
❌ Never put long-running tasks in request handlers
❌ Never assume job runs exactly once
Idempotent Job Pattern
async function processPayment(data: { orderId: string }) {
const order = await orderRepo.findById(data.orderId);
if (order.paymentStatus === 'completed') return; // already processed
await paymentGateway.charge(order);
await orderRepo.updatePaymentStatus(order.id, 'completed');
}
9. Caching Patterns (MEDIUM)
Cache-Aside (Lazy Loading)
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await userRepo.findById(id);
if (!user) throw new NotFoundError('User', id);
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900); // 15min TTL
return user;
}
Rules
✅ ALWAYS set TTL — never cache without expiry
✅ Invalidate on write (delete cache key after update)
✅ Use cache for reads, never for authoritative state
❌ Never cache without TTL (stale data is worse than slow data)
Data Type
Suggested TTL
User profile
5-15 min
Product catalog
1-5 min
Config / feature flags
30-60 sec
Session
Match session duration
10. File Upload Patterns (MEDIUM)
Option A: Presigned URL (Recommended for Large Files)
Client → GET /api/uploads/presign?filename=photo.jpg&type=image/jpeg
Server → { uploadUrl: "https://s3.../presigned", fileKey: "uploads/abc123.jpg" }
Client → PUT uploadUrl (direct to S3, bypasses your server)
Client → POST /api/photos { fileKey: "uploads/abc123.jpg" } (save reference)
// lib/error-handler.ts
export function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
switch (error.status) {
case 401: return 'Please log in to continue.';
case 403: return 'You don\'t have permission to do this.';
case 404: return 'The item you\'re looking for doesn\'t exist.';
case 409: return 'This conflicts with an existing item.';
case 422:
const fields = error.body?.errors;
if (fields?.length) return fields.map((f: any) => f.message).join('. ');
return 'Please check your input.';
case 429: return 'Too many requests. Please wait a moment.';
default: return 'Something went wrong. Please try again.';
}
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return 'Cannot connect to server. Check your internet connection.';
}
return 'An unexpected error occurred.';
}
✅ Map every API error code to a human-readable message
✅ Show field-level validation errors next to form inputs
✅ Auto-retry on 5xx (max 3, with backoff), never on 4xx
✅ Redirect to login on 401 (after refresh attempt fails)
✅ Show "offline" banner when fetch fails with TypeError
❌ Never show raw API error messages to users ("NullPointerException")
❌ Never silently swallow errors (show toast or log)
❌ Never retry 4xx errors (client is wrong, retrying won't help)
Integration Decision Tree
Same team owns frontend + backend?
│
├─ YES, both TypeScript
│ └─ tRPC (end-to-end type safety, zero codegen)
│
├─ YES, different languages
│ └─ OpenAPI spec → generated client (type safety via codegen)
│
├─ NO, public API
│ └─ REST + OpenAPI → generated SDKs for consumers
│
└─ Complex data needs, multiple frontends
└─ GraphQL + codegen (flexible queries per client)
Real-time needed?
│
├─ Server → Client only (notifications, feeds, AI streaming)
│ └─ SSE (simplest, auto-reconnect, works through proxies)
│
├─ Bidirectional (chat, collaboration)
│ └─ WebSocket (need heartbeat + reconnection logic)
│
└─ Simple status polling (< 10 clients)
└─ React Query refetchInterval (no infrastructure needed)
✅ CORS: explicit origins (never '*' in production)
✅ Security headers (helmet / equivalent)
✅ Rate limiting on public endpoints
✅ Input validation on ALL endpoints (trust nothing)
✅ HTTPS enforced
❌ Never expose internal errors to clients
Anti-Patterns
#
❌ Don't
✅ Do Instead
1
Business logic in routes/controllers
Move to service layer
2
process.env scattered everywhere
Centralized typed config
3
console.log for logging
Structured JSON logger
4
Generic Error('oops')
Typed error hierarchy
5
Direct DB calls in controllers
Repository pattern
6
No input validation
Validate at boundary (Zod/Pydantic)
7
Catching errors silently
Log + rethrow or return error
8
No health check endpoints
/health + /ready
9
Hardcoded config/secrets
Environment variables
10
No graceful shutdown
Handle SIGTERM properly
11
Hardcode API URL in frontend
Environment variable (NEXT_PUBLIC_API_URL)
12
Store JWT in localStorage
Memory + httpOnly refresh cookie
13
Show raw API errors to users
Map to human-readable messages
14
Retry 4xx errors
Only retry 5xx (server failures)
15
Skip loading states
Skeleton/spinner while fetching
16
Upload large files through API server
Presigned URL → direct to S3
17
Poll for real-time data
SSE or WebSocket
18
Duplicate types frontend + backend
Shared types, tRPC, or OpenAPI codegen
Common Issues
Issue 1: "Where does this business rule go?"
Rule: If it involves HTTP (request parsing, status codes, headers) → controller. If it involves business decisions (pricing, permissions, rules) → service. If it touches the database → repository.
Issue 2: "Service is getting too big"
Symptom: One service file > 500 lines with 20+ methods.
Fix: Split by sub-domain. OrderService → OrderCreationService + OrderFulfillmentService + OrderQueryService. Each focused on one workflow.
Issue 3: "Tests are slow because they hit the database"
Fix: Unit tests mock the repository layer (fast). Integration tests use test containers or transaction rollback (real DB, still fast). Never mock the service layer in integration tests.
Reference Documents
This skill includes deep-dive references for specialized topics. Read the relevant reference when you need detailed guidance.