Scalability is rarely a framework issue. It is usually structural. Systems break at boundaries.
System Overview
A scalable architecture separates concerns into distinct layers. Each layer has a single responsibility, making testing and debugging straightforward.
Client → API Gateway → Service Layer → Data Layer → Database
↓ ↓
Cache ReplicationArchitectural Boundaries
Your folder structure encodes responsibility. When developers open your repository, the organization should communicate intent. Here is a production-ready structure:
src/
├── app/ # Next.js App Router
├── api/ # Route handlers
├── components/ # UI components (reusable)
├── hooks/ # Custom React hooks
├── lib/ # Utilities, helpers
├── services/ # Business logic
├── types/ # TypeScript interfaces
├── middleware.ts # Request middleware
└── env.ts # Environment validationRequest Flow Architecture
Understanding the flow of a request helps identify where to add logic:
// Flow: Request → Middleware → Handler → Service → Database
// Each layer validates its input and handles its error case
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.headers.get("authorization");
if (!token) return NextResponse.json({}, { status: 401 });
}
// api/users/route.ts
export async function GET(request: NextRequest) {
try {
const users = await userService.getAll();
return NextResponse.json(users);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}Real-World Failure Scenarios
Systems fail in predictable ways. Design for them from the start:
- Missing environment variables: Application crashes on startup. Solution: Validate env at server startup time, not runtime.
- Connection pool exhaustion: Under load, all connections are consumed. Requests queue and timeout. Solution: Connection pooling with defined limits.
- Mixed transport and domain logic: Business logic scattered in route handlers. Difficult to test and reason about. Solution: Services own all business logic.
- No request validation: Invalid data reaches your service layer. Solution: Validate at the API boundary.
Environment Validation
Never trust environment variables. Validate them explicitly at startup:
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production"]),
LOG_LEVEL: z.enum(["debug", "info", "error"]).default("info"),
});
export const env = envSchema.parse(process.env);
// main.ts - This fails loudly if env is invalid
import { env } from "./env";
console.log("✓ Environment validated successfully");Service Layer Pattern
Services encapsulate business logic. They are independent of frameworks and easy to test:
// src/services/UserService.ts
export class UserService {
constructor(private db: Database) {}
async getById(id: string) {
if (!id) throw new Error("Invalid user ID");
const user = await this.db.user.findUnique({ where: { id } });
if (!user) throw new NotFoundError("User not found");
return user;
}
async create(data: CreateUserInput) {
this.validateInput(data);
return await this.db.user.create({ data });
}
private validateInput(data: unknown) {
// Validation logic
}
}Trade-offs and Decisions
Structure increases initial effort but reduces entropy over time. Here are typical decisions:
- Monolith vs Microservices: Start with a monolith. Microservices solve organizational problems, not technical ones.
- Database per service: Only if you need independent scaling. Usually premature optimization.
- Logging strategy: Structured logging from day one. Parse logs into JSON. Future you will thank present you.