0

API design best practices — REST, versioning, errors

Intermediate5 min read·eng-11-008
interview

Concept

API design best practices — building REST APIs that clients love and that scale.

Resource naming:

  • Use nouns, not verbs: /users not /getUsers.
  • Plural nouns: /orders, /products.
  • Nested for relationships: /users/{id}/orders (max 2 levels deep).
  • Actions that don't fit REST: /orders/{id}/confirm is acceptable as a "sub-resource action".

HTTP methods:

  • GET: Read. Safe + idempotent.
  • POST: Create. Not idempotent.
  • PUT: Replace (full update). Idempotent.
  • PATCH: Partial update. Idempotent.
  • DELETE: Delete. Idempotent.

Status codes (pick the right one):

  • 200 OK: Success, body has data.
  • 201 Created: POST success, new resource created. Include Location: /resources/{id} header.
  • 204 No Content: Success, no body (DELETE, PATCH with no response).
  • 400 Bad Request: Malformed request syntax.
  • 401 Unauthorized: Not authenticated.
  • 403 Forbidden: Authenticated but not authorized.
  • 404 Not Found: Resource doesn't exist.
  • 409 Conflict: State conflict (optimistic lock failure, duplicate).
  • 422 Unprocessable Entity: Validation errors.
  • 429 Too Many Requests: Rate limited.
  • 500 Internal Server Error: Server bug.

Error response format (be consistent):

json
{"message": "Validation failed", "errors": {"email": ["Email is required"]}}

Versioning: URL path (/api/v1/users) is most common — simple, explicit, easy to route. Header versioning is cleaner but harder to test. Query string versioning is messy.

Pagination: Always paginate list endpoints. Return data, meta.total, meta.per_page, meta.current_page, links.next, links.prev.

HATEOAS: Include links to related resources in responses. Not always practical, but rel=self links are good habit.

Code Example

php
<?php
// API Resource — consistent response format
class UserResource extends \Illuminate\Http\Resources\Json\JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'role'       => $this->role,
            'created_at' => $this->created_at->toIso8601String(),
            'links'      => [
                'self'   => route('api.users.show', $this),
                'orders' => route('api.users.orders.index', $this),
            ],
            // Conditionally include sensitive data (only for admins or self)
            'email_verified_at' => $this->when(
                $request->user()?->can('viewPrivate', $this->resource),
                $this->email_verified_at?->toIso8601String()
            ),
        ];
    }
}

// Controller — clean, thin, consistent
class UserController extends Controller
{
    public function index(IndexUsersRequest $request): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
    {
        $users = User::query()
            ->when($request->search, fn($q) => $q->where('name', 'like', "%{$request->search}%"))
            ->latest()
            ->paginate($request->per_page ?? 15);

        return UserResource::collection($users); // includes pagination meta automatically
    }

    public function store(StoreUserRequest $request): UserResource
    {
        $user = User::create($request->validated());
        return (new UserResource($user))
            ->response()
            ->setStatusCode(201)
            ->header('Location', route('api.users.show', $user));
    }

    public function destroy(User $user): \Illuminate\Http\Response
    {
        $this->authorize('delete', $user);
        $user->delete();
        return response()->noContent(); // 204
    }
}

// Versioning — routes/api.php
Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('users', V1\UserController::class);
});

Route::prefix('v2')->name('api.v2.')->group(function () {
    Route::apiResource('users', V2\UserController::class);
});

// Consistent error responses — Handler.php
class Handler extends \Illuminate\Foundation\Exceptions\Handler
{
    public function render($request, \Throwable $e): \Illuminate\Http\Response
    {
        if ($request->expectsJson()) {
            if ($e instanceof \Illuminate\Validation\ValidationException) {
                return response()->json(['message' => 'Validation failed', 'errors' => $e->errors()], 422);
            }
            if ($e instanceof \Illuminate\Auth\AuthenticationException) {
                return response()->json(['message' => 'Unauthenticated'], 401);
            }
            if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
                return response()->json(['message' => 'Forbidden'], 403);
            }
        }
        return parent::render($request, $e);
    }
}