RESTful API Design: Best Practices and Patterns
Throughout my career designing and building APIs, I've learned that good API design is crucial for creating maintainable and developer-friendly systems. Let's explore the best practices and patterns for designing RESTful APIs.
API Design Principles
1. Resource Modeling
Design clear and intuitive resource endpoints:
// Example resource endpoints interface APIEndpoints { // Collection endpoints "GET /users": () => User[]; "POST /users": (user: CreateUserDTO) => User; // Single resource endpoints "GET /users/:id": (id: string) => User; "PUT /users/:id": (id: string, user: UpdateUserDTO) => User; "DELETE /users/:id": (id: string) => void; // Nested resources "GET /users/:id/orders": (userId: string) => Order[]; "POST /users/:id/orders": (userId: string, order: CreateOrderDTO) => Order; // Filtering and pagination "GET /users?role=admin&status=active&page=1&limit=10": () => PaginatedResponse<User>; } // Response types interface PaginatedResponse<T> { data: T[]; meta: { total: number; page: number; limit: number; totalPages: number; }; }
2. Request/Response Structure
Implement consistent data structures:
// Request DTOs interface CreateUserDTO { email: string; password: string; firstName: string; lastName: string; role: UserRole; } interface UpdateUserDTO { email?: string; firstName?: string; lastName?: string; role?: UserRole; } // Response structure interface APIResponse<T> { data: T; meta?: Record<string, any>; links?: { self: string; next?: string; prev?: string; }; } // Error response interface APIError { status: number; code: string; message: string; details?: Record<string, any>; timestamp: string; } // Example implementation class APIResponseBuilder { static success<T>(data: T, meta?: Record<string, any>): APIResponse<T> { return { data, meta, links: { self: `${process.env.API_BASE_URL}/current-path`, }, }; } static error(error: Error): APIError { return { status: 400, code: "BAD_REQUEST", message: error.message, timestamp: new Date().toISOString(), }; } }
API Implementation
1. Middleware Setup
Implement common middleware:
// middleware/error-handler.ts import { Request, Response, NextFunction } from "express"; export function errorHandler( error: Error, req: Request, res: Response, next: NextFunction ) { console.error("Error:", error); if (error.name === "ValidationError") { return res.status(400).json( APIResponseBuilder.error({ status: 400, code: "VALIDATION_ERROR", message: "Invalid request data", details: error.details, }) ); } if (error.name === "UnauthorizedError") { return res.status(401).json( APIResponseBuilder.error({ status: 401, code: "UNAUTHORIZED", message: "Authentication required", }) ); } return res.status(500).json( APIResponseBuilder.error({ status: 500, code: "INTERNAL_ERROR", message: "Internal server error", }) ); } // middleware/validation.ts import { validate } from "class-validator"; export function validateRequest(schema: any) { return async (req: Request, res: Response, next: NextFunction) => { try { const errors = await validate(Object.assign(new schema(), req.body)); if (errors.length > 0) { throw new ValidationError("Invalid request data", errors); } next(); } catch (error) { next(error); } }; }
2. Controller Implementation
Create clean controller classes:
// controllers/user.controller.ts @Controller("users") export class UserController { constructor(private userService: UserService) {} @Get() async getUsers( @Query() query: GetUsersQueryDTO ): Promise<APIResponse<User[]>> { const users = await this.userService.findAll(query); return APIResponseBuilder.success(users, { total: users.length, filtered: query.role ? true : false, }); } @Post() @UseGuards(AuthGuard) @Validate(CreateUserDTO) async createUser( @Body() userData: CreateUserDTO ): Promise<APIResponse<User>> { const user = await this.userService.create(userData); return APIResponseBuilder.success(user); } @Get(":id") async getUser(@Param("id") id: string): Promise<APIResponse<User>> { const user = await this.userService.findById(id); if (!user) { throw new NotFoundException(`User with ID ${id} not found`); } return APIResponseBuilder.success(user); } }
API Documentation
1. OpenAPI Specification
Document your API using OpenAPI:
# openapi.yaml openapi: 3.0.0 info: title: User Management API version: 1.0.0 description: API for managing users and their data paths: /users: get: summary: Get all users parameters: - in: query name: role schema: type: string enum: [admin, user] - in: query name: page schema: type: integer minimum: 1 default: 1 - in: query name: limit schema: type: integer minimum: 1 maximum: 100 default: 10 responses: "200": description: List of users content: application/json: schema: $ref: "#/components/schemas/PaginatedUserResponse" post: summary: Create a new user requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateUserDTO" responses: "201": description: User created content: application/json: schema: $ref: "#/components/schemas/UserResponse" components: schemas: User: type: object properties: id: type: string format: uuid email: type: string format: email firstName: type: string lastName: type: string role: type: string enum: [admin, user] createdAt: type: string format: date-time required: - id - email - role
2. API Versioning
Implement API versioning:
// config/versioning.ts import { VersioningType } from "@nestjs/common"; export const versioningConfig = { type: VersioningType.URI, prefix: "v", defaultVersion: "1", }; // main.ts async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableVersioning(versioningConfig); await app.listen(3000); } // controllers/user.controller.ts @Controller({ version: "1", path: "users", }) export class UserControllerV1 { // V1 implementation } @Controller({ version: "2", path: "users", }) export class UserControllerV2 { // V2 implementation with new features }
Security Implementation
1. Authentication
Implement JWT authentication:
// auth/jwt.strategy.ts import { PassportStrategy } from "@nestjs/passport"; import { Strategy, ExtractJwt } from "passport-jwt"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: process.env.JWT_SECRET, }); } async validate(payload: any) { return { userId: payload.sub, email: payload.email, role: payload.role, }; } } // auth/auth.service.ts @Injectable() export class AuthService { constructor( private jwtService: JwtService, private userService: UserService ) {} async validateUser(email: string, password: string): Promise<any> { const user = await this.userService.findByEmail(email); if (user && (await bcrypt.compare(password, user.password))) { const { password, ...result } = user; return result; } return null; } async login(user: any) { const payload = { email: user.email, sub: user.id, role: user.role, }; return { access_token: this.jwtService.sign(payload), }; } }
2. Rate Limiting
Implement rate limiting:
// middleware/rate-limit.ts import rateLimit from "express-rate-limit"; import RedisStore from "rate-limit-redis"; export const rateLimiter = rateLimit({ store: new RedisStore({ client: redisClient, prefix: "rate-limit:", }), windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs message: { status: 429, code: "RATE_LIMIT_EXCEEDED", message: "Too many requests, please try again later", }, }); // Apply to specific routes app.use("/api/v1/users", rateLimiter);
Best Practices
- Use HTTP Methods Correctly: GET for reading, POST for creating, etc.
- Implement Proper Status Codes: Use appropriate HTTP status codes
- Version Your API: Plan for changes and backwards compatibility
- Secure Your Endpoints: Implement authentication and authorization
- Document Everything: Provide comprehensive API documentation
Implementation Checklist
- Define API resources
- Design request/response structures
- Implement authentication
- Add validation
- Set up error handling
- Document endpoints
- Implement versioning
- Add security measures
Conclusion
Good API design is crucial for creating maintainable and developer-friendly systems. Focus on consistency, security, and documentation to create APIs that developers love to use.