Backend Best Practices: SOLID, AI, and Clean Code
2025-02-01 · 8 min read
TL;DR — Apply SOLID principles, use AI as a pair programmer (not a replacement), validate every input, keep controllers thin, and never skip security. Start with Single Responsibility and Dependency Inversion—they change how you structure code.
Great backend code is readable, testable, and easy to change. It doesn't happen by accident. This guide covers the principles that matter most: SOLID, using AI for better code, validation, error handling, and security. Whether you're building your first API or refactoring a legacy system, these practices will save you time and reduce bugs.
1. SOLID Principles — The Foundation
SOLID is five principles that help you design code that scales with your product. You don't need to apply every letter everywhere—but understanding them changes how you think about structure.
Single Responsibility (S)
One class, one job. A user service handles user logic. A payment service handles payments. Don't mix them. When a class does too much, it becomes hard to test and change.
// Bad: UserService does auth, validation, and email
class UserService {
createUser(data) { /* ... */ }
sendWelcomeEmail(user) { /* ... */ }
validatePassword(pwd) { /* ... */ }
}
// Good: Each class has one reason to change
class UserService { createUser(data) { /* ... */ } }
class EmailService { sendWelcome(user) { /* ... */ } }
class PasswordValidator { validate(pwd) { /* ... */ } }
Open/Closed (O)
Open for extension, closed for modification. Add new behavior by extending, not by editing existing code. Use interfaces, strategy patterns, or plugins.
// Good: Add new payment methods without touching existing code
interface PaymentProcessor {
charge(amount: number, metadata: object): Promise<Result>;
}
class StripeProcessor implements PaymentProcessor { /* ... */ }
class PayPalProcessor implements PaymentProcessor { /* ... */ }
Liskov Substitution (L)
Subtypes must be replaceable with their base types. If you have a Database interface, any implementation (Postgres, MongoDB) should work wherever Database is expected. Don't break contracts.
Interface Segregation (I)
Many small interfaces beat one large one. Don't force classes to depend on methods they don't use. Split fat interfaces into focused ones.
// Bad: OrderService must implement methods it doesn't need
interface Repository {
find(id: string): Promise<Entity>;
create(data: object): Promise<Entity>;
sendEmail(to: string): Promise<void>;
}
// Good: Separate concerns
interface ReadRepository { find(id: string): Promise<Entity>; }
interface WriteRepository { create(data: object): Promise<Entity>; }
Dependency Inversion (D)
Depend on abstractions, not concretions. High-level modules shouldn't depend on low-level ones. Both should depend on interfaces. This makes testing and swapping implementations easy.
// Bad: Controller depends on concrete PostgresRepo
class OrderController {
private repo = new PostgresOrderRepo();
}
// Good: Depend on interface, inject implementation
class OrderController {
constructor(private repo: OrderRepository) {}
}
2. Using AI for Better Code — Do's and Don'ts
AI tools (GitHub Copilot, Cursor, ChatGPT) can speed you up—but they amplify both good and bad habits. Use them as a pair programmer, not a replacement for thinking.
Do: Use AI For
- Boilerplate — Repetitive code, DTOs, schema definitions.
- Exploration — "Show me three ways to implement rate limiting."
- Documentation — Comments, README sections, API docs.
- Tests — Unit test skeletons, edge cases you might miss.
- Refactoring suggestions — "How can I simplify this function?"
Don't: Blindly Trust AI For
- Business logic — You understand the domain; AI doesn't.
- Security-sensitive code — Auth, hashing, crypto. Always review.
- Architecture decisions — AI suggests patterns; you decide what fits.
- Copy-paste without reading — Always understand and adapt.
Best Practices
- Write the signature first — Define the function/class you need, then ask AI to fill the body. You stay in control.
- Review every suggestion — Treat AI output as a draft. Refactor for clarity and consistency.
- Use small, focused prompts — "Add Zod validation for this endpoint" beats "build my API."
- Keep your style — Tell AI your conventions (e.g., "use async/await, no callbacks").
3. Validate Every Input — What Is Zod and Do You Need It?
Zod is a TypeScript-first schema validation library. You define a schema (shape + rules), then parse unknown input. If valid, you get typed data. If invalid, you get clear error messages.
Is it good practice? Yes. Validating input prevents injection, bad data, and runtime crashes. Schema libraries give you one place to define rules and reuse them.
Is it a must-have? Not strictly—you can use Joi, Yup, or class-validator (NestJS). But Zod is popular because:
- TypeScript inference —
z.infer<typeof schema>gives you the type for free. - Composable — Combine schemas, add transforms, optional fields.
- Small — ~12KB, no dependencies.
- Runtime + compile time — Catches errors at build and runtime.
Use a schema library for every API input (body, query, params). Zod is a solid choice for Node/TypeScript backends.
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["user", "admin"]).optional(),
});
app.post("/users", (req, res) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Validation failed",
details: parsed.error.flatten().fieldErrors,
});
}
const body = parsed.data;
// body is now typed and safe
});
4. Caching — When and How
Caching reduces database load and latency for read-heavy data. Use it when the same data is requested often and doesn't change every second.
When to cache:
- User profiles, product catalogs, static config
- Expensive queries or aggregations
- External API responses (with TTL)
When not to cache:
- Real-time data (stock prices, live scores)
- User-specific data that changes frequently
- Data that must be strongly consistent
Options:
- In-memory (Node cache) — Fast, but lost on restart. Good for single-instance apps.
- Redis — Shared across instances, TTL, pub/sub. Use for multi-server setups.
- CDN — For static assets and public API responses.
Example: Redis cache-aside
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
await redis.setex(`user:${id}`, 3600, JSON.stringify(user)); // 1hr TTL
return user;
}
Invalidation: On write, delete or update the cache. Stale cache is a common bug—always invalidate when data changes.
5. Structured Error Handling
Centralize errors with a global middleware. Map known errors to HTTP status codes. Log with context (request id, user id). Never expose stack traces to clients.
interface AppError extends Error {
statusCode?: number;
}
app.use((err: AppError, req: Request, res: Response) => {
const status = err.statusCode ?? 500;
const requestId = req.headers["x-request-id"] ?? crypto.randomUUID();
res.status(status).json({
error: status >= 500 ? "Internal server error" : err.message,
requestId,
});
});
6. Keep Controllers Thin
Controllers handle HTTP only: parse input, call a service, return a response. Put business logic in services. This follows Single Responsibility and makes logic testable.
// Controller — HTTP only
app.post("/orders", async (req, res) => {
const body = createOrderSchema.parse(req.body);
const order = await orderService.create(body, req.user.id);
res.status(201).json(order);
});
// Service — business logic
class OrderService {
async create(dto: CreateOrderDto, userId: string) {
const user = await this.userRepo.findById(userId);
if (!user?.canOrder) throw new ForbiddenError("Account restricted");
return this.orderRepo.create({ ...dto, userId });
}
}
7. Security Basics
- Passwords — Hash with bcrypt or argon2. Never store plain text.
- HTTPS — Always. Secure cookies, CORS, CSP.
- Injection — Use parameterized queries or an ORM.
- Rate limiting — Throttle public endpoints.
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
message: { error: "Too many requests" },
});
app.use("/api/", limiter);
Summary
| Principle | Action |
|---|---|
| SOLID | Start with Single Responsibility and Dependency Inversion |
| AI | Use for boilerplate and exploration; always review |
| Validation | Zod/Joi on every input—typed, safe, clear errors |
| Cache | Redis for shared cache; invalidate on write |
| Errors | Centralize, log with context, never expose internals |
| Controllers | Thin—delegate to services |
| Security | Hash, HTTPS, rate limit |
Apply these practices incrementally. Start with validation and thin controllers, then layer in SOLID and AI workflows. For scaling patterns, see System Design Beginner Guide.